From cd6bca978f0d31377cfc2aa448c95148507ceb58 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 10 May 2026 18:05:35 +0200 Subject: [PATCH] feat(voyage): implement path-traversal + symlink/dotfile filter on loaded files --- .../voyage/playground/voyage-playground.html | 40 ++++++++++-- .../annotation-export-schema.test.mjs | 65 ++++++++++++++++++- .../playground/voyage-playground.test.mjs | 20 ++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index 36753d4..376711c 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -1393,6 +1393,30 @@ playground first-run shows a complete round-trip-able artifact. // ProjectArtifacts. Mirrors lib/parsers/project-discovery.mjs:15-24 // typedef on the browser side; storage-key derived deterministically // from basePath so per-project draft state cannot leak across projects. + // v4.3 Step 26 — path-traversal + symlink/dotfile filter. + // Pure predicate: returns true if `inside` (the path RELATIVE to the + // project root, i.e. webkitRelativePath with the leading basePath + // stripped) is safe to read. Rejects: + // 1. Path-traversal (`..` anywhere) — covers symlinks pointing + // outside the chosen directory: webkitRelativePath returns the + // symlink's resolved relative path, so `../etc/passwd` shows up + // with literal `..`. + // 2. Dotfiles at root or any nested level (`.git/`, `.gitignore`, + // `.DS_Store`, `.env`, etc.). + // 3. Known unwanted directories at any depth (`node_modules/`, + // `dist/`, `build/`). + // Pure string-in-bool-out — no DOM, no I/O. + function isProjectPathSafe(inside) { + if (typeof inside !== 'string' || !inside) return false; + if (inside.indexOf('..') !== -1) return false; + if (inside.charAt(0) === '.') return false; + if (inside.indexOf('/.') !== -1) return false; + if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false; + if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false; + if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false; + return true; + } + async function loadProjectDirectory(files, basePath) { if (!files || !files.length) return null; var prefix = basePath ? basePath + '/' : ''; @@ -1410,14 +1434,15 @@ playground first-run shows a complete round-trip-able artifact. // Phase 1 — validate + classify (sync). var classified = []; + var filteredCount = 0; // v4.3 Step 26 — track suppressed entries for (var i = 0; i < files.length; i++) { var f = files[i]; var rel = f.webkitRelativePath || ''; - // Validate: path must start with basePath prefix; reject parent traversal. - if (basePath && rel.indexOf(prefix) !== 0) continue; - if (rel.indexOf('..') !== -1) continue; + // Validate: path must start with basePath prefix. + if (basePath && rel.indexOf(prefix) !== 0) { filteredCount++; continue; } var inside = rel.slice(prefix.length); - if (!inside) continue; + // v4.3 Step 26 — reject path-traversal, dotfiles, node_modules, dist, build. + if (!isProjectPathSafe(inside)) { filteredCount++; continue; } var parts = inside.split('/'); var name = parts[parts.length - 1]; var bucket = null; @@ -1462,6 +1487,13 @@ playground first-run shows a complete round-trip-able artifact. if (!artifacts.brief) { announce('Advarsel: brief.md mangler i prosjektmappen.'); } + // v4.3 Step 26 — surface filter-rejection count via aria-live so + // screen-readers (and curious operators) know what was suppressed. + // Treat as informational, not error: dotfile + node_modules omission + // is the common case for a clean dev directory. + if (filteredCount > 0) { + announce(filteredCount + ' fil(er) filtrert (dotfiles / node_modules / path-traversal).'); + } // Phase 5 — render dashboard if available, else log. if (typeof renderDashboard === 'function') { diff --git a/plugins/voyage/tests/integration/annotation-export-schema.test.mjs b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs index 7d936f9..b8570d5 100644 --- a/plugins/voyage/tests/integration/annotation-export-schema.test.mjs +++ b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs @@ -98,5 +98,66 @@ test('voyage-playground.html stripUnsafeComments wired into renderArtifact (v4.3 assert.match(text, /var\s+safeText\s*=\s*stripUnsafeComments\(/, 'renderArtifact must call stripUnsafeComments before md.render'); }); -// --- Step 26 placeholder — full filter test added by Sesjon 5 Step 26 ---- -// (Test below activates after Step 26 lands; kept as documentation stub.) +// --- Step 26 — path-traversal + symlink/dotfile filter ------------------ +// Mirror of the browser-side isProjectPathSafe predicate. Kept verbatim so +// the playground's filter cannot drift without breaking this test. +function isProjectPathSafe(inside) { + if (typeof inside !== 'string' || !inside) return false; + if (inside.indexOf('..') !== -1) return false; + if (inside.charAt(0) === '.') return false; + if (inside.indexOf('/.') !== -1) return false; + if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false; + if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false; + if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false; + return true; +} + +test('isProjectPathSafe — rejects path-traversal (v4.3 Step 26)', () => { + assert.equal(isProjectPathSafe('../etc/passwd'), false); + assert.equal(isProjectPathSafe('foo/../etc/passwd'), false); + assert.equal(isProjectPathSafe('a/b/../c'), false); +}); + +test('isProjectPathSafe — rejects dotfiles at root + nested (v4.3 Step 26)', () => { + assert.equal(isProjectPathSafe('.gitignore'), false); + assert.equal(isProjectPathSafe('.git/config'), false); + assert.equal(isProjectPathSafe('.DS_Store'), false); + assert.equal(isProjectPathSafe('.env'), false); + assert.equal(isProjectPathSafe('docs/.hidden/file'), false); + assert.equal(isProjectPathSafe('research/.git/HEAD'), false); +}); + +test('isProjectPathSafe — rejects node_modules / dist / build at any depth (v4.3 Step 26)', () => { + assert.equal(isProjectPathSafe('node_modules/foo/index.js'), false); + assert.equal(isProjectPathSafe('packages/sub/node_modules/x'), false); + assert.equal(isProjectPathSafe('dist/bundle.js'), false); + assert.equal(isProjectPathSafe('packages/x/dist/y.js'), false); + assert.equal(isProjectPathSafe('build/output.js'), false); + assert.equal(isProjectPathSafe('packages/x/build/y.js'), false); +}); + +test('isProjectPathSafe — accepts valid project artifacts (v4.3 Step 26)', () => { + assert.equal(isProjectPathSafe('brief.md'), true); + assert.equal(isProjectPathSafe('plan.md'), true); + assert.equal(isProjectPathSafe('review.md'), true); + assert.equal(isProjectPathSafe('progress.json'), true); + assert.equal(isProjectPathSafe('research/01-foo.md'), true); + assert.equal(isProjectPathSafe('architecture/overview.md'), true); + assert.equal(isProjectPathSafe('architecture/gaps.md'), true); +}); + +test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v4.3 Step 26)', () => { + // Fixture mirroring Step 26 plan-Verify scenario: load a directory + // containing the four hostile entries plus a valid brief.md and verify + // only brief.md survives. + const fixture = [ + '../etc/passwd', + '.git/config', + 'node_modules/foo/index.js', + 'brief.md', + '.DS_Store', + 'dist/junk.js', + ]; + const survivors = fixture.filter(isProjectPathSafe); + assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter'); +}); diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index 27cd1a2..2aea598 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -482,3 +482,23 @@ test('voyage-playground.html renderArtifact strips comments before md.render (v4 const renderIdx = body.indexOf('md.render'); assert.ok(stripIdx > 0 && stripIdx < renderIdx, 'stripUnsafeComments must run before md.render'); }); + +// v4.3 Step 26 — path-traversal + symlink/dotfile filter. +test('voyage-playground.html declares isProjectPathSafe filter (v4.3 Step 26)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /function\s+isProjectPathSafe\s*\(/, 'isProjectPathSafe() function required'); + // Must reject the four documented threat-classes + assert.match(text, /indexOf\('\.\.'\)/, '..-rejection required'); + assert.match(text, /indexOf\('node_modules\//, 'node_modules/-rejection required'); + assert.match(text, /indexOf\('dist\//, 'dist/-rejection required'); + assert.match(text, /indexOf\('build\//, 'build/-rejection required'); +}); + +test('voyage-playground.html loadProjectDirectory wires isProjectPathSafe filter (v4.3 Step 26)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Must call the filter before classification, AND track filteredCount + assert.match(text, /isProjectPathSafe\(inside\)/, 'isProjectPathSafe must be called on `inside` path'); + assert.match(text, /filteredCount\+\+/, 'filteredCount tracking required'); + // aria-live announce must fire when something is filtered + assert.match(text, /announce\(filteredCount/, 'filteredCount announce required'); +});