From 8e20085ed0573084b6000ea54d25d4ee14e2606e Mon Sep 17 00:00:00 2001 From: scanner Date: Fri, 16 Oct 2026 03:36:51 -0400 Subject: [PATCH] test: cover sync-hive-docs path-traversal guard via exported resolveSyncTarget Signed-off-by: scanner --- scripts/sync-hive-docs.test.ts | 43 +++++++++++++++++++++++++++++++++- scripts/sync-hive-docs.ts | 14 +++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/scripts/sync-hive-docs.test.ts b/scripts/sync-hive-docs.test.ts index f2516f5..d7593d7 100644 --- a/scripts/sync-hive-docs.test.ts +++ b/scripts/sync-hive-docs.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { rewriteLinkTarget, rewriteLinks } from "./sync-hive-docs"; +import { + resolveSyncTarget, + rewriteLinkTarget, + rewriteLinks, +} from "./sync-hive-docs"; // The source file being synced, expressed as its real repo path. Links inside // the content are resolved relative to this file's directory. @@ -203,3 +207,40 @@ describe("sync-hive-docs.ts — sync-root prefix drift guard", () => { expect(SOURCE).not.toContain("v2/docs"); }); }); + +describe("resolveSyncTarget", () => { + const root = "/site/docs/content/hive"; + + it("joins a plain target under the root", () => { + expect(resolveSyncTarget(root, "architecture.md")).toBe( + "/site/docs/content/hive/architecture.md" + ); + }); + + it("joins a nested target under the root", () => { + expect(resolveSyncTarget(root, "adr/0001.md")).toBe( + "/site/docs/content/hive/adr/0001.md" + ); + }); + + it.each(["../escape.md", "adr/../../escape.md"])( + "refuses %s, which escapes the root", + target => { + expect(() => resolveSyncTarget(root, target)).toThrow( + `refusing to write outside Hive docs root: ${target}` + ); + } + ); + + it("refuses a sibling directory that shares the root prefix", () => { + expect(() => resolveSyncTarget(root, "../hive-evil/x.md")).toThrow( + "refusing to write outside Hive docs root: ../hive-evil/x.md" + ); + }); + + it.each(["", "."])("refuses the root itself (%j)", target => { + expect(() => resolveSyncTarget(root, target)).toThrow( + "refusing to write outside Hive docs root" + ); + }); +}); diff --git a/scripts/sync-hive-docs.ts b/scripts/sync-hive-docs.ts index 3801ee4..dfbf50c 100644 --- a/scripts/sync-hive-docs.ts +++ b/scripts/sync-hive-docs.ts @@ -220,14 +220,20 @@ async function fetchText(url: string): Promise { return response.text(); } +/** Resolve a sync target under docsRoot, refusing anything that escapes it. */ +export function resolveSyncTarget(root: string, target: string): string { + const targetPath = path.join(root, target); + if (!targetPath.startsWith(root + path.sep)) { + throw new Error(`refusing to write outside Hive docs root: ${target}`); + } + return targetPath; +} + async function main() { for (const file of files) { const target = file.target || file.source; const sourceURL = `${rawBase}/${file.source}`; - const targetPath = path.join(docsRoot, target); - if (!targetPath.startsWith(docsRoot + path.sep)) { - throw new Error(`refusing to write outside Hive docs root: ${target}`); - } + const targetPath = resolveSyncTarget(docsRoot, target); const content = await fetchText(sourceURL); // Rewrite GitHub-relative links so they resolve on the docs site. const overlaid = applyLocalHiveOverlays(content, file.source);