From 1a24d781c91054001188c7a4cbcff14f06deae78 Mon Sep 17 00:00:00 2001 From: alloevil <12680612+alloevil@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:45:04 -0700 Subject: [PATCH 1/2] security: linear trailing-newline trim and complete escaping (2 js/polynomial-redos, 2 js/incomplete-sanitization) - lib/library.js: replace content.replace(/\n*$/, '\n') with a linear scan (withOneTrailingNewline); same output, no polynomial backtracking. - lib/export.js: markdown table cells escape backslashes before pipes. - public/js/app.js: CSS.escape fallback escapes backslashes as well as quotes. Tests: 2 new cases in existing files. --- lib/export.js | 8 +++++++- lib/library.js | 12 ++++++++++-- public/js/app.js | 2 +- test/export.test.js | 10 ++++++++++ test/unit.test.js | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/export.js b/lib/export.js index 245c635..402180e 100644 --- a/lib/export.js +++ b/lib/export.js @@ -171,6 +171,12 @@ function fenceFor(text) { return '`'.repeat(longest + 1); } +// Markdown table cell: escape backslashes first, then pipes, so a value ending +// in a backslash cannot turn our `\|` back into a live column separator. +function escapeTableCell(value) { + return String(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + function renderSessionMarkdown(platform, payload, { maxToolBytes } = {}) { const session = payload.session || {}; const lines = []; @@ -179,7 +185,7 @@ function renderSessionMarkdown(platform, payload, { maxToolBytes } = {}) { lines.push('| | |'); lines.push('|---|---|'); for (const [key, value] of metadataRows(platform, payload)) { - lines.push(`| ${key} | ${String(value).replace(/\|/g, '\\|')} |`); + lines.push(`| ${key} | ${escapeTableCell(value)} |`); } lines.push(''); lines.push( diff --git a/lib/library.js b/lib/library.js index 0f38cac..d84ab43 100644 --- a/lib/library.js +++ b/lib/library.js @@ -61,6 +61,14 @@ function parseLibraryFile(raw) { return { meta, content }; } +// Collapse any run of trailing newlines to exactly one. A linear scan rather +// than /\n*$/, which backtracks polynomially on long newline runs (ReDoS). +function withOneTrailingNewline(content) { + let end = content.length; + while (end > 0 && content.charCodeAt(end - 1) === 10) end--; + return `${content.slice(0, end)}\n`; +} + function serializeLibraryFile(meta, content) { const lines = [ '---', @@ -71,12 +79,12 @@ function serializeLibraryFile(meta, content) { '---', '', ]; - return `${lines.join('\n')}${content.replace(/\n*$/, '\n')}`; + return `${lines.join('\n')}${withOneTrailingNewline(content)}`; } // Installed slash-command file: frontmatter with description only + prompt body function serializeInstalledFile(description, content) { - return `---\ndescription: ${description || ''}\n---\n\n${content.replace(/\n*$/, '\n')}`; + return `---\ndescription: ${description || ''}\n---\n\n${withOneTrailingNewline(content)}`; } // Installed detection: file exists at the target path diff --git a/public/js/app.js b/public/js/app.js index 0e4a924..2fd062b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2898,7 +2898,7 @@ sessionList.addEventListener('scroll', () => { // Ensure the selected card is visible; when virtualized it may not even be in the DOM function scrollSessionIntoView(id) { if (!id) return; - const esc = window.CSS && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + const esc = window.CSS && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, '\\$&'); const el = sessionList.querySelector(`.session-item[data-session-id="${esc}"]`); if (el) { el.scrollIntoView({ block: 'nearest' }); diff --git a/test/export.test.js b/test/export.test.js index 4e8ed2b..04e1784 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -44,6 +44,16 @@ test('redactSecrets leaves ordinary text alone', () => { // --- renderers (unit, tiny synthetic payload) --- +test('markdown metadata cells escape backslashes before pipes', () => { + const payload = { session: { id: 'synth-esc', cwd: 'C:\\proj|x\\' }, messages: [] }; + const md = renderSessionMarkdown('codex', payload); + const row = md.split('\n').find((line) => line.startsWith('| Working directory |')); + assert.equal(row, '| Working directory | C:\\\\proj\\|x\\\\ |'); + // Every pipe inside the value is preceded by an odd run of backslashes, i.e. escaped. + const cell = row.slice('| Working directory | '.length, -2); + for (const m of cell.matchAll(/(\\*)\|/g)) assert.equal(m[1].length % 2, 1); +}); + const SYNTH = { session: { id: 'synth-1', timestamp: '2026-01-01T00:00:00Z', cwd: '/tmp/proj', model: 'test-model' }, messages: [ diff --git a/test/unit.test.js b/test/unit.test.js index e58f8bb..3134976 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -408,3 +408,17 @@ test('buildTurnLedger without usage still yields time rows and reports hasUsage= assert.equal(l.hasUsage, false); assert.equal(l.hasCost, false); }); + +const library = require(path.join(__dirname, '..', 'lib', 'library')); + +test('serializeLibraryFile ends the body with exactly one newline, in linear time', () => { + const header = library.serializeLibraryFile({ name: 'x' }, '').slice(0, -1); + const body = (content) => library.serializeLibraryFile({ name: 'x' }, content).slice(header.length); + assert.equal(body('hello'), 'hello\n'); + assert.equal(body('hello\n\n\n'), 'hello\n'); + assert.equal(body('a\n\nb\n'), 'a\n\nb\n'); + assert.equal(body(''), '\n'); + const started = process.hrtime.bigint(); + assert.equal(body(`${'\n'.repeat(200000)}x${'\n'.repeat(200000)}`).length, 200002); + assert.ok(Number(process.hrtime.bigint() - started) / 1e6 < 1000); +}); From b7eb8a2f4b79516cc659c5e73fb236ba78eaf64d Mon Sep 17 00:00:00 2001 From: alloevil <12680612+alloevil@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:46:36 -0700 Subject: [PATCH 2/2] test: restore closing brace lost in merge; test count 356 -> 358 --- claims.json | 10 +++++----- docs/ROADMAP.md | 2 +- test/unit.test.js | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/claims.json b/claims.json index 7fce028..5776164 100644 --- a/claims.json +++ b/claims.json @@ -106,16 +106,16 @@ }, { "id": "test-count", - "claim": "356 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.", - "value": "356", - "metric": "passing node:test cases (# tests 356 / # pass 356 / # fail 0)", - "method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('356 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.", + "claim": "358 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.", + "value": "358", + "metric": "passing node:test cases (# tests 358 / # pass 358 / # fail 0)", + "method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('358 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.", "repro": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'", "evidence": "docs/ROADMAP.md", "as_of": "2026-10-17", "check": { "cmd": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'", - "expect": { "contains": ["# tests 356", "# pass 356", "# fail 0"] }, + "expect": { "contains": ["# tests 358", "# pass 358", "# fail 0"] }, "timeout": 120 } }, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5a98043..edb17de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ - **Session browser** with tool-call inspection, trace/waterfall view, spawn tracking and message timeline - **Prompt tooling** — extraction (noise filtered), template clustering with outcome attribution, Claude-powered rewrites, and a prompt library that installs entries as native slash commands - **Global search** across all platforms, insights dashboard, incremental session backup -- **React + Vite frontend** served by an Express backend; 356 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22 +- **React + Vite frontend** served by an Express backend; 358 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22 - **Evidence-backed failure events and local review** with full-result invalidation, evidence navigation and narrow-screen session layout ## Current priorities diff --git a/test/unit.test.js b/test/unit.test.js index e428d1a..7399f76 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -428,6 +428,8 @@ test('resolveAllowedDir accepts absolute dirs under a root and rejects the rest' for (const bad of ['relative/x', `${root}/../etc`, path.join(path.sep, 'etc'), `${root}\0`]) { assert.throws(() => safePath.resolveAllowedDir(bad, [root]), { name: 'PathEscapeError' }); } +}); + const library = require(path.join(__dirname, '..', 'lib', 'library')); test('serializeLibraryFile ends the body with exactly one newline, in linear time', () => {