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"] 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..99db399 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 @@ -248,7 +238,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 +267,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 +286,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 +879,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)} 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": { 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) diff --git a/server/db.js b/server/db.js index 7acc983..ac9602f 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') { @@ -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..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(', ')}`); @@ -376,6 +388,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 +424,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' }); + withDiscussionLock(doc, () => { + const data = loadDiscussions(doc); - discussion.messages.push({ - role: 'user', - content: message, - userName, - timestamp: new Date().toISOString() - }); - - 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 +480,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 +516,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 +547,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) @@ -592,6 +650,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 +696,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 +714,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 +727,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 +740,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 +752,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 +764,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 +781,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 }); @@ -1095,7 +1187,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 }); }); @@ -1106,9 +1198,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 +1216,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); 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' });