From bd9797b68cbf2b5e5ac6a08609965920e17958b3 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 3 Apr 2026 03:58:29 +0200 Subject: [PATCH] fix: sanitize case names from filesystem to prevent XSS in inline handlers Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before returning them from GET /api/cases. Prevents XSS via maliciously-named directories reaching frontend inline onclick handlers where escapeHtml is insufficient (HTML-decoded back to quotes before JS execution). Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists). Co-Authored-By: Claude Opus 4.6 --- src/web/public/index.html | 2 +- src/web/routes/case-routes.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index b8599ba1..4dc12f27 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1393,7 +1393,7 @@
- Drag or use arrows to reorder. Changes are saved automatically. + Use arrows to reorder. Changes are saved automatically.
diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6b825164..b3508ddc 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -19,6 +19,7 @@ import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json'); +const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ async function readLinkedCases(): Promise> { @@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config try { const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); for (const e of entries) { - if (e.isDirectory()) { + if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) { cases.push({ name: e.name, path: join(CASES_DIR, e.name), @@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const linkedCases = await readLinkedCases(); const existingNames = new Set(cases.map((c) => c.name)); for (const [name, path] of Object.entries(linkedCases)) { - if (!existingNames.has(name) && existsSync(path)) { + if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) { cases.push({ name, path,