From 8fba6be213c69ce9f98026c538a53417d6368433 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:37:21 +0800 Subject: [PATCH 01/11] fix: use json_each() to prevent SQL injection in getItemsByTag (#243) Replace LIKE pattern matching with SQLite json_each() for proper JSON array searching. The previous approach interpolated the tag parameter into a LIKE pattern, allowing SQL wildcards (%, _) and double-quotes to alter query semantics and leak data. Co-Authored-By: Claude Opus 4.6 --- server/db.js | 10 +++++----- test/db.test.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server/db.js b/server/db.js index 7acc983..aba5682 100644 --- a/server/db.js +++ b/server/db.js @@ -624,15 +624,15 @@ function initDb(dataDir) { } function getItemsByTag({ app_id, tag }) { - // SQLite JSON: tags is stored as '["bug","ui"]', search with LIKE + // Use json_each() for proper JSON array searching (avoids LIKE injection) if (app_id) { return db.prepare( - `SELECT * FROM items WHERE app_id = ? AND tags LIKE ? ORDER BY created_at DESC` - ).all(app_id, `%"${tag}"%`); + `SELECT i.* FROM items i, json_each(i.tags) t WHERE i.app_id = ? AND t.value = ? ORDER BY i.created_at DESC` + ).all(app_id, tag); } return db.prepare( - `SELECT * FROM items WHERE tags LIKE ? ORDER BY created_at DESC` - ).all(`%"${tag}"%`); + `SELECT i.* FROM items i, json_each(i.tags) t WHERE t.value = ? ORDER BY i.created_at DESC` + ).all(tag); } function getDistinctUrls(app_id = 'default') { diff --git a/test/db.test.js b/test/db.test.js index cb633f4..ad03977 100644 --- a/test/db.test.js +++ b/test/db.test.js @@ -433,6 +433,25 @@ describe('DB — V2 queries', () => { assert.equal(items.length, 1); }); + it('getItemsByTag is not vulnerable to LIKE wildcards', () => { + dbApi.createItem({ + doc: 'd', created_by: 'A', + tags: ['bug', 'ui'], + }); + dbApi.createItem({ + doc: 'd', created_by: 'B', + tags: ['feature'], + }); + + // '%' should not match all items (was vulnerable with LIKE pattern) + const wildcard = dbApi.getItemsByTag({ tag: '%' }); + assert.equal(wildcard.length, 0); + + // Partial match should not work — must be exact + const partial = dbApi.getItemsByTag({ tag: 'bu' }); + assert.equal(partial.length, 0); + }); + it('getDistinctUrls returns unique source_urls with counts', () => { dbApi.createItem({ doc: 'd', created_by: 'A', source_url: 'https://a.com' }); dbApi.createItem({ doc: 'd', created_by: 'B', source_url: 'https://a.com' }); From d20b1248818a13a9ee37d9bb763b37349278914f Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:40:22 +0800 Subject: [PATCH 02/11] fix: prevent auth token exposure via externally_connectable (#246) - Restrict externally_connectable localhost to port 3462 only - GET_AUTH_STATE: return { authenticated, user } instead of raw JWT token - DASHBOARD_LOGIN: validate token with server before accepting - Dashboard: remove extension token auto-sync (use own OAuth flow) Co-Authored-By: Claude Opus 4.6 --- dashboard/src/api.js | 8 ++++---- dashboard/src/main.js | 18 ++++-------------- extension/background/service-worker.js | 22 +++++++++++++++++++--- extension/manifest.json | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/dashboard/src/api.js b/dashboard/src/api.js index 1cf8ab0..51f5515 100644 --- a/dashboard/src/api.js +++ b/dashboard/src/api.js @@ -222,16 +222,16 @@ export async function detectExtension() { } /** - * Try to get auth token from the extension. - * Returns { token, user } or null if extension not available. + * Try to get auth state from the extension. + * Returns { user } or null if extension not available or not authenticated. */ export async function getAuthFromExtension() { const extId = await detectExtension(); if (!extId) return null; try { const resp = await chrome.runtime.sendMessage(extId, { type: 'GET_AUTH_STATE' }); - if (resp?.authToken && resp?.authUser) { - return { token: resp.authToken, user: resp.authUser }; + if (resp?.authenticated && resp?.authUser) { + return { user: resp.authUser }; } } catch { // Extension unavailable diff --git a/dashboard/src/main.js b/dashboard/src/main.js index feb4efe..01bab21 100644 --- a/dashboard/src/main.js +++ b/dashboard/src/main.js @@ -23,13 +23,8 @@ import { startGoogleLogin, extractAuthCode, getRedirectUri, clearUrlParams } fro // ------------------------------------------------------------------ init async function init() { - // Try extension auth first - const extAuth = await getAuthFromExtension(); - if (extAuth) { - setAuth(extAuth.token, extAuth.user); - showApp(extAuth.user); - return; - } + // Check if extension is authenticated (token is no longer shared for security) + // Dashboard must obtain its own token via OAuth login flow // Handle OAuth callback const code = extractAuthCode(); @@ -46,13 +41,8 @@ async function init() { const isWelcome = location.hash === '#welcome'; - // If not logged in locally, try syncing auth from the Chrome extension - if (!isLoggedIn()) { - const extAuth = await getAuthFromExtension(); - if (extAuth) { - setAuth(extAuth.authToken, extAuth.authUser); - } - } + // Extension no longer shares the auth token for security. + // Dashboard relies on its own OAuth login flow. if (isLoggedIn()) { // Verify token is still valid diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index 4978119..408c3be 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -342,15 +342,31 @@ async function handleExternalMessage(message, sender) { switch (message.type) { case 'GET_AUTH_STATE': { const state = await getAuthState(); + const authenticated = !!(state.authToken && state.authUser); return { - authToken: state.authToken || '', - authUser: state.authUser || null, + authenticated, + authUser: authenticated + ? { name: state.authUser.name, email: state.authUser.email } + : null, }; } case 'DASHBOARD_LOGIN': { // Dashboard completed OAuth and wants to sync token to extension if (!message.token || !message.user) return { error: 'Missing token or user' }; - await setAuthState(message.token, message.user); + // Validate token with the server before accepting + try { + const config = await getConfig(); + const serverUrl = config.serverUrl.replace(/\/$/, ''); + const verifyResp = await fetch(`${serverUrl}/api/v2/auth/me`, { + headers: { 'Authorization': `Bearer ${message.token}` }, + }); + if (!verifyResp.ok) return { error: 'Invalid or expired token' }; + const data = await verifyResp.json(); + const verifiedUser = data.user || data; + await setAuthState(message.token, verifiedUser); + } catch { + return { error: 'Token validation failed' }; + } chrome.runtime.sendMessage({ type: 'AUTH_STATE_CHANGED' }).catch(() => {}); return { success: true }; } diff --git a/extension/manifest.json b/extension/manifest.json index 4cdb44f..ace412c 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -19,7 +19,7 @@ "matches": [ "*://*.coco.site/*", "*://*.coco.xyz/*", - "*://localhost/*" + "*://localhost:3462/*" ] }, "background": { From c581693a517cc005b1c6461d9035a2dc3f197414 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:44:39 +0800 Subject: [PATCH 03/11] 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 04/11] 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 }); From 0f232bddccddae06d551873ed9c20f54a3de9882 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:44:39 +0800 Subject: [PATCH 05/11] 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 }); From 468d0ba1e8335114c47bfdebbd56daca9797e6b3 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:46:42 +0800 Subject: [PATCH 06/11] fix: return 404 for deleted items in dispatch log endpoints (#248) Both GET /api/v2/distributions/:item_id and POST .../retry had a flawed access check that only ran when `item` was truthy. When an item was deleted (null), the check was skipped entirely, leaking dispatch logs and allowing retry triggers for any authenticated user. Add an explicit null check before the access control guard so deleted items return 404 immediately. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/index.js b/server/index.js index e5b751d..802281c 100644 --- a/server/index.js +++ b/server/index.js @@ -1106,9 +1106,10 @@ app.get('/api/v2/adapters', (req, res) => { // -- GET /api/v2/distributions/:item_id — get dispatch log for an item app.get('/api/v2/distributions/:item_id', apiReadLimiter, v2Auth, (req, res) => { - // Verify item belongs to caller's app + // Verify item exists and belongs to caller's app const item = itemsDb.getItem(req.params.item_id); - if (item && req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_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 log = itemsDb.getDispatchLog(req.params.item_id); @@ -1123,9 +1124,10 @@ app.get('/api/v2/distributions/:item_id', apiReadLimiter, v2Auth, (req, res) => // -- POST /api/v2/distributions/:item_id/retry — retry failed dispatches for an item app.post('/api/v2/distributions/:item_id/retry', apiWriteLimiter, v2Auth, async (req, res) => { - // Verify item belongs to caller's app + // Verify item exists and belongs to caller's app const item = itemsDb.getItem(req.params.item_id); - if (item && req.v2Auth?.app_id && item.app_id !== req.v2Auth.app_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 log = itemsDb.getDispatchLog(req.params.item_id); From 01ed41c4e5e2588b0884eed7c27390c568434c58 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:47:58 +0800 Subject: [PATCH 07/11] fix: add .dockerignore and non-root user to prevent secret leakage (#249) - Create .dockerignore to exclude .git, .env, config.json, data/, *.db, test/, node_modules/, extension/, docs/, and *.zip from Docker image - Add non-root clawmark user in Dockerfile to reduce container blast radius Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 11 +++++++++++ Dockerfile | 5 +++++ 2 files changed, 16 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0873015 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +config.json +data/ +*.db +test/ +dashboard/node_modules/ +extension/ +docs/ +*.zip +node_modules/ diff --git a/Dockerfile b/Dockerfile index 4ce1d8a..f86add4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ RUN mkdir -p /data ENV CLAWMARK_PORT=3458 ENV CLAWMARK_DATA_DIR=/data +RUN addgroup -S clawmark && adduser -S clawmark -G clawmark +RUN chown -R clawmark:clawmark /app /data + EXPOSE 3458 +USER clawmark + CMD ["node", "server/index.js"] From 7f12d5531508310720a66bcd874dc62e704b1746 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:50:20 +0800 Subject: [PATCH 08/11] fix: add per-discussion write lock to prevent concurrent write data loss (#250) Add an in-memory per-document mutex (promise chain) that serializes all read-modify-write operations on discussion JSON files. All four mutating endpoints (POST /discussions, POST /respond, POST /discussions/resolve, POST /submit-reply) are now wrapped in withDiscussionLock(doc, fn) so concurrent requests on the same document are queued instead of racing. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 212 +++++++++++++++++++++++++++++------------------- 1 file changed, 129 insertions(+), 83 deletions(-) diff --git a/server/index.js b/server/index.js index e5b751d..7d4adea 100644 --- a/server/index.js +++ b/server/index.js @@ -376,6 +376,20 @@ function saveDiscussions(docId, data) { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } +// Per-document write lock to prevent concurrent read-modify-write data loss (#250) +const discussionLocks = new Map(); + +function withDiscussionLock(docId, fn) { + if (!discussionLocks.has(docId)) { + discussionLocks.set(docId, Promise.resolve()); + } + const prev = discussionLocks.get(docId); + let resolve; + const next = new Promise(r => resolve = r); + discussionLocks.set(docId, next); + return prev.then(() => fn()).finally(() => resolve()); +} + // --------------------------------------------------------- discussion endpoints // Get discussions for a document (auth added per #239 C-2) @@ -398,45 +412,53 @@ app.post('/discussions', v2Auth, (req, res) => { return res.status(400).json({ error: 'Missing required fields' }); } - const data = loadDiscussions(doc); - - if (discussionId) { - // Append to existing discussion - const discussion = data.discussions.find(d => d.id === discussionId); - if (!discussion) return res.status(404).json({ error: 'Discussion not found' }); - - discussion.messages.push({ - role: 'user', - content: message, - userName, - timestamp: new Date().toISOString() - }); + withDiscussionLock(doc, () => { + const data = loadDiscussions(doc); - saveDiscussions(doc, data); + if (discussionId) { + // Append to existing discussion + const discussion = data.discussions.find(d => d.id === discussionId); + if (!discussion) { + res.status(404).json({ error: 'Discussion not found' }); + return; + } - sendWebhook('discussion.message', { doc, discussionId, userName, message }); - res.json({ success: true, discussionId }); - } else { - // New discussion - const newDiscussion = { - id: `disc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - quote: quote || '', - version: version || 'latest', - createdAt: new Date().toISOString(), - messages: [{ + discussion.messages.push({ role: 'user', content: message, userName, timestamp: new Date().toISOString() - }] - }; + }); - data.discussions.push(newDiscussion); - saveDiscussions(doc, data); + saveDiscussions(doc, data); - sendWebhook('discussion.created', { doc, discussionId: newDiscussion.id, userName, quote, message }); - res.json({ success: true, discussionId: newDiscussion.id }); - } + sendWebhook('discussion.message', { doc, discussionId, userName, message }); + res.json({ success: true, discussionId }); + } else { + // New discussion + const newDiscussion = { + id: `disc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + quote: quote || '', + version: version || 'latest', + createdAt: new Date().toISOString(), + messages: [{ + role: 'user', + content: message, + userName, + timestamp: new Date().toISOString() + }] + }; + + data.discussions.push(newDiscussion); + saveDiscussions(doc, data); + + sendWebhook('discussion.created', { doc, discussionId: newDiscussion.id, userName, quote, message }); + res.json({ success: true, discussionId: newDiscussion.id }); + } + }).catch(err => { + console.error('Discussion write error:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + }); }); // Post a response to a discussion (called by an AI agent or admin) @@ -446,27 +468,35 @@ app.post('/respond', v2Auth, (req, res) => { return res.status(400).json({ error: 'Missing required fields' }); } - const data = loadDiscussions(doc); - const discussion = data.discussions.find(d => d.id === discussionId); - if (!discussion) return res.status(404).json({ error: 'Discussion not found' }); - - const pendingIdx = discussion.messages.findIndex(m => m.pending); - if (pendingIdx !== -1) { - discussion.messages[pendingIdx] = { - role: 'assistant', - content: response, - timestamp: new Date().toISOString() - }; - } else { - discussion.messages.push({ - role: 'assistant', - content: response, - timestamp: new Date().toISOString() - }); - } + withDiscussionLock(doc, () => { + const data = loadDiscussions(doc); + const discussion = data.discussions.find(d => d.id === discussionId); + if (!discussion) { + res.status(404).json({ error: 'Discussion not found' }); + return; + } - saveDiscussions(doc, data); - res.json({ success: true }); + const pendingIdx = discussion.messages.findIndex(m => m.pending); + if (pendingIdx !== -1) { + discussion.messages[pendingIdx] = { + role: 'assistant', + content: response, + timestamp: new Date().toISOString() + }; + } else { + discussion.messages.push({ + role: 'assistant', + content: response, + timestamp: new Date().toISOString() + }); + } + + saveDiscussions(doc, data); + res.json({ success: true }); + }).catch(err => { + console.error('Discussion write error:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + }); }); // Resolve or reopen a discussion @@ -474,20 +504,28 @@ app.post('/discussions/resolve', v2Auth, (req, res) => { const { doc, discussionId, action } = req.body; if (!doc || !discussionId) return res.status(400).json({ error: 'Missing doc or discussionId' }); - const data = loadDiscussions(doc); - const disc = data.discussions.find(d => d.id === discussionId); - if (!disc) return res.status(404).json({ error: 'Discussion not found' }); + withDiscussionLock(doc, () => { + const data = loadDiscussions(doc); + const disc = data.discussions.find(d => d.id === discussionId); + if (!disc) { + res.status(404).json({ error: 'Discussion not found' }); + return; + } - if (action === 'reopen') { - disc.applied = false; - disc.appliedAt = null; - } else { - disc.applied = true; - disc.appliedAt = new Date().toISOString(); - } + if (action === 'reopen') { + disc.applied = false; + disc.appliedAt = null; + } else { + disc.applied = true; + disc.appliedAt = new Date().toISOString(); + } - saveDiscussions(doc, data); - res.json({ success: true }); + saveDiscussions(doc, data); + res.json({ success: true }); + }).catch(err => { + console.error('Discussion write error:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + }); }); // Submit a reply via API (for AI agent or admin use) @@ -497,27 +535,35 @@ app.post('/submit-reply', v2Auth, (req, res) => { return res.status(400).json({ error: 'Missing doc, discussionId, or reply' }); } - const data = loadDiscussions(doc); - const discussion = data.discussions.find(d => d.id === discussionId); - if (!discussion) return res.status(404).json({ error: 'Discussion not found' }); - - const pendingIdx = discussion.messages.findIndex(m => m.pending); - if (pendingIdx !== -1) { - discussion.messages[pendingIdx] = { - role: 'assistant', - content: reply, - timestamp: new Date().toISOString() - }; - } else { - discussion.messages.push({ - role: 'assistant', - content: reply, - timestamp: new Date().toISOString() - }); - } + withDiscussionLock(doc, () => { + const data = loadDiscussions(doc); + const discussion = data.discussions.find(d => d.id === discussionId); + if (!discussion) { + res.status(404).json({ error: 'Discussion not found' }); + return; + } - saveDiscussions(doc, data); - res.json({ success: true }); + const pendingIdx = discussion.messages.findIndex(m => m.pending); + if (pendingIdx !== -1) { + discussion.messages[pendingIdx] = { + role: 'assistant', + content: reply, + timestamp: new Date().toISOString() + }; + } else { + discussion.messages.push({ + role: 'assistant', + content: reply, + timestamp: new Date().toISOString() + }); + } + + saveDiscussions(doc, data); + res.json({ success: true }); + }).catch(err => { + console.error('Discussion write error:', err); + if (!res.headersSent) res.status(500).json({ error: 'Internal server error' }); + }); }); // List pending discussions (discussions that have an unanswered pending message) From 15c63c63673750550edf28b724b50b517147101c Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:52:37 +0800 Subject: [PATCH 09/11] fix: add auth to adapters endpoint and encrypt endpoint configs (#251) - Add v2Auth middleware to GET /api/v2/adapters (was unauthenticated) - Encrypt endpoint configs at rest using the same encrypt/decrypt pattern already used for user_auths credentials - Decrypt on read in getEndpoint, getEndpoints, updateEndpoint, setEndpointDefault Co-Authored-By: Claude Opus 4.6 --- server/db.js | 34 ++++++++++++++++++++++++++-------- server/index.js | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/server/db.js b/server/db.js index 7acc983..0b0a72f 100644 --- a/server/db.js +++ b/server/db.js @@ -835,6 +835,19 @@ function initDb(dataDir) { // ---------------------------------------------------- endpoint methods + /** Decrypt config field in an endpoints row (in-place). */ + function decryptEndpointRow(row) { + if (!row) return row; + if (isEncrypted(row.config)) { + try { + row.config = decrypt(row.config); + } catch (err) { + throw new Error(`Failed to decrypt config for endpoint ${row.id}: ${err.message}. Check CLAWMARK_ENCRYPTION_KEY.`); + } + } + return row; + } + function createEndpoint({ user_name, name, type, config, is_default = 0 }) { const now = new Date().toISOString(); const id = genId('ep'); @@ -850,7 +863,7 @@ function initDb(dataDir) { endpointStmts.insertEndpoint.run({ id, user_name, name, type, - config: configStr, + config: encrypt(configStr), is_default: shouldDefault, created_at: now, updated_at: now, }); @@ -858,28 +871,33 @@ function initDb(dataDir) { } function getEndpoints(user_name) { - return endpointStmts.getEndpointsByUser.all(user_name); + return endpointStmts.getEndpointsByUser.all(user_name).map(decryptEndpointRow); } function getEndpoint(id) { - return endpointStmts.getEndpointById.get(id) || null; + return decryptEndpointRow(endpointStmts.getEndpointById.get(id)) || null; } function updateEndpoint(id, updates) { const existing = endpointStmts.getEndpointById.get(id); if (!existing) return null; const now = new Date().toISOString(); + let configStr; + if (updates.config) { + configStr = typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config); + configStr = encrypt(configStr); + } else { + configStr = existing.config; // already encrypted (or plaintext legacy) + } const merged = { id, name: updates.name ?? existing.name, type: updates.type ?? existing.type, - config: updates.config - ? (typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config)) - : existing.config, + config: configStr, updated_at: now, }; endpointStmts.updateEndpoint.run(merged); - return endpointStmts.getEndpointById.get(id); + return decryptEndpointRow(endpointStmts.getEndpointById.get(id)); } function deleteEndpoint(id) { @@ -903,7 +921,7 @@ function initDb(dataDir) { const now = new Date().toISOString(); endpointStmts.clearDefaultForUser.run(now, existing.user_name); endpointStmts.setDefault.run(now, id); - return endpointStmts.getEndpointById.get(id); + return decryptEndpointRow(endpointStmts.getEndpointById.get(id)); } // ---------------------------------------------------------- app methods diff --git a/server/index.js b/server/index.js index e5b751d..31c11c1 100644 --- a/server/index.js +++ b/server/index.js @@ -1095,7 +1095,7 @@ app.post('/api/v2/auth/apikey-legacy', apiWriteLimiter, (req, res) => { }); // -- GET /api/v2/adapters — list adapter channels and their status -app.get('/api/v2/adapters', (req, res) => { +app.get('/api/v2/adapters', apiReadLimiter, v2Auth, (req, res) => { res.json({ channels: registry.getStatus(), rules: registry.rules.length }); }); From 71251c793d292e17ab4af90007b5b739ceb18781 Mon Sep 17 00:00:00 2001 From: Boot Date: Tue, 10 Mar 2026 03:54:23 +0800 Subject: [PATCH 10/11] fix: dashboard XSS, credential masking, auth property names, URL parsing (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate URL scheme (https?://) before window.open to prevent javascript: XSS - Mask credential values in auth settings table (show last 4 chars only) - Fix double-fetch auth using wrong property names (authToken/authUser → token/user) - Wrap new URL() in try/catch to prevent crash on malformed source_url Co-Authored-By: Claude Opus 4.6 --- dashboard/src/main.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/dashboard/src/main.js b/dashboard/src/main.js index feb4efe..372d3cb 100644 --- a/dashboard/src/main.js +++ b/dashboard/src/main.js @@ -50,7 +50,7 @@ async function init() { if (!isLoggedIn()) { const extAuth = await getAuthFromExtension(); if (extAuth) { - setAuth(extAuth.authToken, extAuth.authUser); + setAuth(extAuth.token, extAuth.user); } } @@ -248,7 +248,7 @@ function renderTopPages(topUrls) {
${escHtml(page.source_title || page.source_url)}
${Number(page.count) || 0} annotation${Number(page.count) !== 1 ? 's' : ''}
`; - if (page.source_url) { + if (page.source_url && /^https?:\/\//i.test(page.source_url)) { el.addEventListener('click', () => window.open(page.source_url, '_blank')); } listEl.appendChild(el); @@ -277,7 +277,15 @@ async function loadItemsList(typeFilter) { for (const item of items.slice(0, 50)) { const icon = item.type === 'issue' ? '\ud83d\udc1b' : '\ud83d\udcac'; const time = item.created_at ? new Date(item.created_at).toLocaleString() : ''; - const sourceLabel = item.source_title || (item.source_url ? new URL(item.source_url).hostname + new URL(item.source_url).pathname.substring(0, 30) : ''); + let sourceLabel = item.source_title || ''; + if (!sourceLabel && item.source_url) { + try { + const parsed = new URL(item.source_url); + sourceLabel = parsed.hostname + parsed.pathname.substring(0, 30); + } catch { + sourceLabel = item.source_url.substring(0, 40); + } + } const el = document.createElement('div'); el.className = 'activity-item' + (item.source_url ? ' activity-item-link' : ''); el.innerHTML = ` @@ -288,7 +296,7 @@ async function loadItemsList(typeFilter) { ${renderItemDispatches(item.dispatches)}
${time ? escHtml(time) : ''}
`; - if (item.source_url) { + if (item.source_url && /^https?:\/\//i.test(item.source_url)) { el.addEventListener('click', (e) => { if (e.target.closest('.dispatch-ext-link')) return; window.open(item.source_url, '_blank'); @@ -881,7 +889,10 @@ function renderAuthsTable() { for (const auth of allAuths) { const creds = typeof auth.credentials === 'string' ? JSON.parse(auth.credentials) : (auth.credentials || {}); - const credSummary = Object.entries(creds).map(([k, v]) => `${k}: ${v}`).join(', '); + const credSummary = Object.entries(creds).map(([k, v]) => { + const masked = typeof v === 'string' && v.length > 4 ? '••••' + v.slice(-4) : '••••'; + return `${k}: ${masked}`; + }).join(', '); const tr = document.createElement('tr'); tr.innerHTML = ` ${escHtml(auth.name)} From 87bae918a373075aa43c369d8e4f54d55912fda7 Mon Sep 17 00:00:00 2001 From: Jessie Date: Tue, 10 Mar 2026 04:51:39 +0800 Subject: [PATCH 11/11] fix: resolve user auth for github_auto dispatch (#264) After token inheritance was removed in #261 for security, github_auto and system_default routing lost access to GitHub tokens. This adds a fallback that looks up the user's own GitHub auth credential when no token is present on the target config. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/index.js b/server/index.js index 7d198c3..c88b69d 100644 --- a/server/index.js +++ b/server/index.js @@ -316,6 +316,18 @@ async function sendWebhook(event, payload) { console.warn(`[routing] Auth ${t.matched_rule.auth_id} referenced by rule ${t.matched_rule.id} not found`); } } + + // For auto-detected targets (github_auto, system_default) without a token, + // look up the user's first matching auth credential (#264). + if (t.target_type === 'github-issue' && !t.target_config.token && payload.created_by) { + const userAuths = itemsDb.getUserAuths(payload.created_by); + const ghAuth = userAuths.find(a => a.auth_type === 'github_pat' || a.auth_type === 'github_oauth'); + if (ghAuth) { + let creds; + try { creds = typeof ghAuth.credentials === 'string' ? JSON.parse(ghAuth.credentials) : ghAuth.credentials; } catch { creds = {}; } + t.target_config = { ...t.target_config, ...creds }; + } + } } console.log(`[routing] ${event}: ${filteredTargets.length} target(s) — ${filteredTargets.map(t => `${t.method}→${t.target_type}`).join(', ')}`);