From ddd5fe77af21b08537ba50ddc7b248dbafb7d009 Mon Sep 17 00:00:00 2001 From: anupamme Date: Mon, 17 Aug 2026 11:12:56 +0000 Subject: [PATCH 1/2] fix: V-001 security vulnerability Automated security fix generated by OrbisAI Security --- scripts/sync-chapters.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/sync-chapters.js b/scripts/sync-chapters.js index f9ae1ed2..23fd73e7 100644 --- a/scripts/sync-chapters.js +++ b/scripts/sync-chapters.js @@ -75,7 +75,13 @@ function main() { process.exit(1); } - const data = JSON.parse(fs.readFileSync(src, "utf8")); + const resolvedSrc = path.resolve(src); + if (!resolvedSrc.endsWith(".json")) { + console.error("Error: input file must have a .json extension"); + process.exit(1); + } + + const data = JSON.parse(fs.readFileSync(resolvedSrc, "utf8")); const live = data.chapters.filter((c) => c.leaders && c.leaders.length > 0); if (live.length < MIN_LIVE_CHAPTERS) { console.error( From 4960f032434d02aaa87d3f81914135e8b99f77de Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 20 Aug 2026 12:20:41 +0530 Subject: [PATCH 2/2] fix: restrict sync-chapters input path to /tmp with real-path containment The previous path.resolve + .endsWith(".json") guard did not prevent path traversal: it still permitted absolute paths, ../ escapes, and symlinks that resolve outside the intended input location. Replace it with a canonical-path containment check (resolveInputPath): realpath the boundary and the target, require the resolved path to stay within the approved input directory (/tmp, where the sync-chapters workflow downloads chapters.json), and keep the .json extension check. Guard main() behind require.main so the module can be required by tests, and add tests/sync-chapters.test.js covering valid input, ../ traversal, absolute paths outside the boundary, lexical escapes, symlink escapes, and non-.json input. Co-Authored-By: Claude Opus 4.8 --- scripts/sync-chapters.js | 50 +++++++++++++++++++-- tests/sync-chapters.test.js | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 tests/sync-chapters.test.js diff --git a/scripts/sync-chapters.js b/scripts/sync-chapters.js index 23fd73e7..90dc9b21 100644 --- a/scripts/sync-chapters.js +++ b/scripts/sync-chapters.js @@ -28,6 +28,42 @@ const REGION = { // must not mass-delete chapter pages. const MIN_LIVE_CHAPTERS = 15; +// The sync-chapters workflow always hands us a file inside /tmp +// (see .github/workflows/sync-chapters.yml). Treat that as the only +// approved input location and refuse to read anything outside it. +const INPUT_DIR = "/tmp"; + +// Resolve the caller-provided input path against the approved input directory +// and refuse anything that escapes it. path.resolve + a .json suffix check are +// not enough on their own: they still permit absolute paths, ../ traversal, and +// symlinks that point elsewhere. We canonicalize with fs.realpathSync so a +// symlinked file cannot smuggle us outside the boundary, then verify +// containment with path.relative before the file is ever read. +function resolveInputPath(src, inputDir = INPUT_DIR) { + // realpath the boundary too: on macOS /tmp is itself a symlink to + // /private/tmp, so both sides must be canonicalized to compare. + const baseReal = fs.realpathSync(inputDir); + const candidate = path.resolve(baseReal, src); + + // Canonicalize the target if it exists so a symlinked file that points + // outside the boundary is caught; fall back to the lexical path otherwise. + let resolved; + try { + resolved = fs.realpathSync(candidate); + } catch { + resolved = candidate; + } + + const rel = path.relative(baseReal, resolved); + if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) { + throw new Error(`input file must be inside ${inputDir}`); + } + if (!resolved.endsWith(".json")) { + throw new Error("input file must have a .json extension"); + } + return resolved; +} + function yamlStr(value) { const needsQuote = value.includes(": ") || @@ -75,9 +111,11 @@ function main() { process.exit(1); } - const resolvedSrc = path.resolve(src); - if (!resolvedSrc.endsWith(".json")) { - console.error("Error: input file must have a .json extension"); + let resolvedSrc; + try { + resolvedSrc = resolveInputPath(src); + } catch (err) { + console.error(`Error: ${err.message}`); process.exit(1); } @@ -113,4 +151,8 @@ function main() { console.log(`synced ${wrote} chapters, removed ${removed}`); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { resolveInputPath, render, yamlStr }; diff --git a/tests/sync-chapters.test.js b/tests/sync-chapters.test.js new file mode 100644 index 00000000..75c18242 --- /dev/null +++ b/tests/sync-chapters.test.js @@ -0,0 +1,87 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { resolveInputPath } = require("../scripts/sync-chapters"); + +// Each case builds a throwaway input directory so we exercise the containment +// boundary without touching the real /tmp. resolveInputPath takes the boundary +// as an argument for exactly this reason. +function withScratchDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-chapters-")); + // Canonicalize so assertions compare against the same path realpath returns + // (macOS resolves the tmp symlink to /private/...). + const realDir = fs.realpathSync(dir); + try { + fn(realDir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("resolveInputPath accepts a .json file inside the input directory", () => { + withScratchDir((dir) => { + fs.writeFileSync(path.join(dir, "chapters.json"), "{}"); + const resolved = resolveInputPath("chapters.json", dir); + assert.equal(resolved, path.join(dir, "chapters.json")); + }); +}); + +test("resolveInputPath accepts an absolute path inside the input directory", () => { + withScratchDir((dir) => { + const abs = path.join(dir, "chapters.json"); + fs.writeFileSync(abs, "{}"); + assert.equal(resolveInputPath(abs, dir), abs); + }); +}); + +test("resolveInputPath rejects ../ traversal out of the input directory", () => { + withScratchDir((dir) => { + assert.throws(() => resolveInputPath("../escape.json", dir), /inside/); + }); +}); + +test("resolveInputPath rejects an absolute path outside the input directory", () => { + withScratchDir((dir) => { + assert.throws(() => resolveInputPath("/etc/passwd.json", dir), /inside/); + }); +}); + +test("resolveInputPath rejects a lexical escape that climbs above the directory", () => { + withScratchDir((dir) => { + assert.throws( + () => resolveInputPath("subdir/../../escape.json", dir), + /inside/, + ); + }); +}); + +test("resolveInputPath rejects a symlink inside the dir pointing outside", () => { + withScratchDir((dir) => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "sync-outside-")); + const target = path.join(outside, "secret.json"); + fs.writeFileSync(target, "{}"); + const link = path.join(dir, "link.json"); + try { + fs.symlinkSync(target, link); + } catch { + // Some environments disallow symlink creation; nothing to assert there. + fs.rmSync(outside, { recursive: true, force: true }); + return; + } + try { + assert.throws(() => resolveInputPath("link.json", dir), /inside/); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + }); +}); + +test("resolveInputPath rejects a non-.json file inside the directory", () => { + withScratchDir((dir) => { + fs.writeFileSync(path.join(dir, "chapters.txt"), "{}"); + assert.throws(() => resolveInputPath("chapters.txt", dir), /\.json/); + }); +});