From c581693a517cc005b1c6461d9035a2dc3f197414 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:44:39 +0800 Subject: [PATCH 1/2] fix: escape server data in side panel innerHTML to prevent XSS (#245) - Add single-quote escaping to escapeHtml() (') - Escape err.message in error handlers (lines 119, 137) - Escape item.type, item.status, item.priority, item.created_by in list and thread views - Escape item.id in data-id attribute Co-Authored-By: Claude Opus 4.6 --- extension/sidepanel/panel.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/extension/sidepanel/panel.js b/extension/sidepanel/panel.js index fee21a9..a554e22 100644 --- a/extension/sidepanel/panel.js +++ b/extension/sidepanel/panel.js @@ -116,7 +116,7 @@ async function loadItems(skipCache = false) { updateCounts(); renderItems(); } catch (err) { - itemsContainer.innerHTML = `
${err.message}
`; + itemsContainer.innerHTML = `
${escapeHtml(err.message)}
`; } } @@ -134,7 +134,7 @@ async function loadThread(itemId) { renderThread(response); showThreadView(); } catch (err) { - threadMessages.innerHTML = `
${err.message}
`; + threadMessages.innerHTML = `
${escapeHtml(err.message)}
`; } } @@ -191,18 +191,18 @@ function renderItems() { const sourceHost = item.source_url ? (() => { try { return new URL(item.source_url).hostname; } catch { return ''; } })() : ''; return ` -
+
- ${item.type} - ${item.priority !== 'normal' ? `${item.priority}` : ''} - ${item.status} + ${escapeHtml(item.type)} + ${item.priority !== 'normal' ? `${escapeHtml(item.priority)}` : ''} + ${escapeHtml(item.status)}
${item.title ? `
${escapeHtml(item.title)}
` : ''} ${item.quote ? `
${escapeHtml(item.quote)}
` : ''} ${sourceHost ? `
\ud83d\udcc4 ${escapeHtml(item.source_title || sourceHost)}
` : ''} ${renderDispatchBadges(item.dispatches)}
- ${item.created_by} + ${escapeHtml(item.created_by)} ${time}
${tags.length > 0 ? `
${tags.map(t => `${escapeHtml(t)}`).join('')}
` : ''} @@ -224,8 +224,8 @@ function renderThread(item) { threadHeader.innerHTML = `
- ${item.type} - ${item.status} + ${escapeHtml(item.type)} + ${escapeHtml(item.status)}
${item.title ? `
${escapeHtml(item.title)}
` : ''} ${item.quote ? `
${escapeHtml(item.quote)}
` : ''} @@ -302,7 +302,7 @@ function formatTime(isoString) { function escapeHtml(str) { if (!str) return ''; - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // ------------------------------------------------------------------ dispatch display (#115) From 0c579056fc142a71327518518a0a49c4bbd92ce7 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:44:39 +0800 Subject: [PATCH 2/2] fix: add app_id scope check to V1 API handlers (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1 item handlers (handleGetItem, handleAddMessage, handleAssignItem, handleResolveItem, handleVerifyItem, handleReopenItem, handleCloseItem, handleRespondToItem) did not verify that the requested item belongs to the authenticated user's app_id. This allowed any authenticated user to read, modify, or close items belonging to other apps. Add the same app_id scoping check used by V2 endpoints: if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) → 403 Forbidden For handlers that previously skipped the item fetch (assign, resolve, verify, reopen, close), an explicit getItem() call is added before the scope check. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/index.js b/server/index.js index e5b751d..0530adf 100644 --- a/server/index.js +++ b/server/index.js @@ -592,6 +592,9 @@ function handleGetItems(req, res) { function handleGetItem(req, res) { const item = itemsDb.getItem(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } res.json(item); } @@ -635,6 +638,9 @@ function handleAddMessage(req, res) { const item = itemsDb.getItem(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.addMessage({ item_id: req.params.id, @@ -650,6 +656,11 @@ function handleAddMessage(req, res) { function handleAssignItem(req, res) { const { assignee } = req.body; if (!assignee) return res.status(400).json({ error: 'Missing assignee' }); + const item = itemsDb.getItem(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.assignItem(req.params.id, assignee); if (!result.changes) return res.status(404).json({ error: 'Item not found' }); sendWebhook('item.assigned', { id: req.params.id, assignee }); @@ -658,6 +669,11 @@ function handleAssignItem(req, res) { // -- POST /items/:id/resolve function handleResolveItem(req, res) { + const item = itemsDb.getItem(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.resolveItem(req.params.id); if (!result.changes) return res.status(404).json({ error: 'Item not found' }); sendWebhook('item.resolved', { id: req.params.id }); @@ -666,6 +682,11 @@ function handleResolveItem(req, res) { // -- POST /items/:id/verify function handleVerifyItem(req, res) { + const item = itemsDb.getItem(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.verifyItem(req.params.id); if (!result.changes) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true }); @@ -673,6 +694,11 @@ function handleVerifyItem(req, res) { // -- POST /items/:id/reopen function handleReopenItem(req, res) { + const item = itemsDb.getItem(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.reopenItem(req.params.id); if (!result.changes) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true }); @@ -680,6 +706,11 @@ function handleReopenItem(req, res) { // -- POST /items/:id/close function handleCloseItem(req, res) { + const item = itemsDb.getItem(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } const result = itemsDb.closeItem(req.params.id); if (!result.changes) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true }); @@ -692,6 +723,9 @@ function handleRespondToItem(req, res) { const item = itemsDb.getItem(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); + if (req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_id) { + return res.status(403).json({ error: 'Access denied — item belongs to a different app' }); + } itemsDb.respondToItem(req.params.id, response); res.json({ success: true });