diff --git a/apps/pi-extension/server/reference.test.ts b/apps/pi-extension/server/reference.test.ts new file mode 100644 index 000000000..4faa68271 --- /dev/null +++ b/apps/pi-extension/server/reference.test.ts @@ -0,0 +1,175 @@ +/** + * /api/doc containment on the Node transport. Each runtime resolves and gates + * paths in its own handler, so both carry the full set of escape vectors. + */ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { handleDocExistsRequest, handleDocRequest } from "./reference.ts"; +import { requestUrl } from "./helpers.ts"; + +const tempDirs: string[] = []; +let currentRoots: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeTempFile(root: string, relativePath: string, content: string): string { + const full = join(root, relativePath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + return full; +} + +function makeEscapeFixture(): { root: string; outside: string } { + const root = makeTempDir("pi-symlink-root-"); + const outside = makeTempDir("pi-symlink-outside-"); + writeTempFile(outside, "secret.md", "SECRET-MD\n"); + writeTempFile(outside, "secret.html", "

SECRET-HTML

"); + writeTempFile(outside, "secret.ts", "// SECRET-TS\n"); + writeTempFile(outside, "deep.md", "SECRET-DEEP\n"); + writeTempFile(root, "sibling.md", "sibling\n"); + symlinkSync(join(outside, "secret.md"), join(root, "link.md")); + symlinkSync(join(outside, "secret.html"), join(root, "link.html")); + symlinkSync(join(outside, "secret.ts"), join(root, "link.ts")); + symlinkSync(outside, join(root, "linkdir")); + currentRoots = [root]; + return { root, outside }; +} + +describe("pi /api/doc containment", () => { + let server: Server; + let base = ""; + + beforeAll(async () => { + server = createServer(async (req, res) => { + const url = requestUrl(req); + if (url.pathname === "/api/doc") { + await handleDocRequest(res, url, { rootPaths: currentRoots }); + return; + } + if (url.pathname === "/api/doc/exists") { + await handleDocExistsRequest(res, req, { rootPaths: currentRoots }); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no port"); + base = `http://127.0.0.1:${address.port}`; + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + const getDoc = (path: string, baseDir?: string) => { + const url = new URL(`${base}/api/doc`); + url.searchParams.set("path", path); + if (baseDir) url.searchParams.set("base", baseDir); + return fetch(url); + }; + + const escapeVectors: { branch: string; path: (root: string) => string; withBase?: boolean }[] = [ + { branch: "base-relative document", path: () => "link.md", withBase: true }, + { branch: "markdown resolver, bare name", path: () => "link.md" }, + { branch: "markdown resolver, absolute path", path: (root) => join(root, "link.md") }, + { branch: "raw HTML", path: () => "link.html" }, + { branch: "code file", path: () => "link.ts" }, + { branch: "symlinked directory", path: () => "linkdir/deep.md" }, + ]; + + for (const vector of escapeVectors) { + test(`denies an escaping symlink via the ${vector.branch} branch`, async () => { + const { root } = makeEscapeFixture(); + + const res = await getDoc(vector.path(root), vector.withBase ? root : undefined); + + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("SECRET"); + }); + } + + test("ordinary in-root siblings are still served", async () => { + makeEscapeFixture(); + + const res = await getDoc("sibling.md"); + const data = await res.json() as { markdown?: string }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe("sibling\n"); + }); + + test("doc/exists reports an escaping symlink as missing, not found", async () => { + makeEscapeFixture(); + + const res = await fetch(`${base}/api/doc/exists`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths: ["link.ts"] }), + }); + const data = await res.json() as { results: Record }; + + expect(data.results["link.ts"]).toEqual({ status: "missing" }); + }); + + test("refuses a base directory that reaches outside the root through a symlink", async () => { + const { root } = makeEscapeFixture(); + + const res = await getDoc("deep.md", join(root, "linkdir")); + + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("SECRET"); + }); + + test("answers 403 for an escaping absolute path whether or not it exists", async () => { + const { outside } = makeEscapeFixture(); + + const existing = await getDoc(join(outside, "secret.md")); + const absent = await getDoc(join(outside, "absent.md")); + + expect(existing.status).toBe(403); + expect(absent.status).toBe(403); + }); + + test("serves a bare filename from a root given as a symlink", async () => { + const real = makeTempDir("pi-symroot-real-"); + const parent = makeTempDir("pi-symroot-parent-"); + const link = join(parent, "docs"); + writeTempFile(real, "note.md", "note\n"); + symlinkSync(real, link); + currentRoots = [link]; + + const res = await getDoc("note.md"); + const data = await res.json() as { markdown?: string }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe("note\n"); + }); + + test("rejects an oversized .html with 413 whether or not a base is given", async () => { + const root = makeTempDir("pi-html-cap-"); + currentRoots = [root]; + writeFileSync(join(root, "huge.html"), `

${"x".repeat(2 * 1024 * 1024 + 1)}

`); + + const withoutBase = await getDoc("huge.html"); + const withBase = await getDoc("huge.html", root); + + expect(withoutBase.status).toBe(413); + expect(withBase.status).toBe(413); + expect((await withBase.json() as { error?: string }).error).toBe("File too large (max 2MB)"); + }); +}); diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index fb690b2b8..4407c74cc 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -30,18 +30,27 @@ import { } from "../generated/workspace-status.ts"; import { detectObsidianVaults } from "../generated/integrations-common.ts"; import { - isAbsoluteUserPath, - isCodeFilePath, - resolveCodeFile, - resolveMarkdownFile, resolveUserPath, - isWithinProjectRoot, warmFileListCache, getAnnotatableDocRegex, MAX_ANNOTATABLE_FILE_BYTES, isAnnotatableTextPath, } from "../generated/resolve-file.ts"; -import { parseCodePath } from "../generated/code-file.ts"; +import { + DOC_ACCESS_DENIED, + DOC_TOO_LARGE, + docResolutionError, + getAllowedRootPaths, + getTrustedBaseDir, + isPathAllowed, + relativizeToAllowedRoots, + resolveAllowedDocPath, + resolveCodeFileInRoots, + resolveDocTarget, + type DocErrorPayload, + type ResolveAllowedDocPathResult, +} from "../generated/doc-resolve.ts"; +import { parseCodePath, type ParsedCodePath } from "../generated/code-file.ts"; import { htmlToMarkdown } from "../generated/html-to-markdown.ts"; import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "../generated/source-save.ts"; import { @@ -114,124 +123,11 @@ interface HandleDocExistsOptions { rootPaths?: string[]; } -type RouteResolveResult = - | { kind: "found"; path: string } - | { kind: "not_found"; input: string } - | { kind: "ambiguous"; input: string; matches: string[] } - | { kind: "unavailable"; input: string }; - -function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] { - const rawRoots = options?.rootPaths?.length - ? options.rootPaths - : [options?.rootPath ?? process.cwd()]; - const roots: string[] = []; - for (const root of rawRoots) { - if (typeof root !== "string" || root.length === 0) continue; - const resolved = resolveUserPath(root); - if (!roots.includes(resolved)) roots.push(resolved); - } - return roots.length > 0 ? roots : [resolveUserPath(process.cwd())]; -} - -function isWithinAllowedRoots(candidate: string, roots: string[]): boolean { - return roots.some((root) => isWithinProjectRoot(candidate, root)); -} - -function getTrustedBaseDir(base: string | null, roots: string[]): string | null { - if (!base) return null; - const resolvedBase = resolveUserPath(base); - return isWithinAllowedRoots(resolvedBase, roots) ? resolvedBase : null; -} - -export type ResolveAllowedDocPathResult = - | { kind: "resolved"; path: string } - | { kind: "denied" }; - -/** - * Resolve a client-supplied path the same way /api/doc's base-relative and - * absolute branches do (see `getTrustedBaseDir` / `isWithinAllowedRoots` - * above), for callers that need the canonical contained path without - * reading the file — namely the annotate version endpoints, which derive a - * history slug from the resolved path rather than trusting a client-supplied - * slug (a client slug would be a path-traversal vector: the history dir - * lookup joins it into a filesystem path unsanitized). Mirrors - * packages/server/reference-handlers.ts. - */ -export function resolveAllowedDocPath( - requestedPath: string, - base: string | null, - options?: { rootPaths?: string[] }, -): ResolveAllowedDocPathResult { - const allowedRoots = getAllowedRootPaths(options); - const resolvedBase = getTrustedBaseDir(base, allowedRoots); - const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined); - return isWithinAllowedRoots(candidate, allowedRoots) - ? { kind: "resolved", path: candidate } - : { kind: "denied" }; -} - -function relativizeToAllowedRoots(path: string, roots: string[]): string { - for (const root of roots) { - const prefix = `${root}/`; - if (path.startsWith(prefix)) return path.slice(prefix.length); - if (path === root) return "."; - } - return path; -} - -async function resolveCodeFileFromAllowedRoots( - input: string, - roots: string[], - baseDir: string | null, -): Promise { - const found = new Set(); - const ambiguous = new Set(); - let unavailable = false; +// Re-exported for the annotate version endpoints, which import it from here. +export { resolveAllowedDocPath, type ResolveAllowedDocPathResult }; - for (const root of roots) { - const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined; - const result = await resolveCodeFile(input, root, rootBase); - if (result.kind === "found") { - if (isWithinProjectRoot(result.path, root)) found.add(result.path); - } else if (result.kind === "ambiguous") { - for (const match of result.matches) { - ambiguous.add(match); - } - } else if (result.kind === "unavailable") { - unavailable = true; - } - } - - if (found.size === 1) return { kind: "found", path: [...found][0] }; - if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] }; - if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] }; - if (unavailable) return { kind: "unavailable", input }; - return { kind: "not_found", input }; -} - -function resolveMarkdownFileFromAllowedRoots(input: string, roots: string[]): RouteResolveResult { - const found = new Set(); - const ambiguous = new Set(); - let unavailable = false; - - for (const root of roots) { - const result = resolveMarkdownFile(input, root); - if (result.kind === "found") { - if (isWithinProjectRoot(result.path, root)) found.add(result.path); - } else if (result.kind === "ambiguous") { - for (const match of result.matches) { - ambiguous.add(match); - } - } else if (result.kind === "unavailable") { - unavailable = true; - } - } - - if (found.size === 1) return { kind: "found", path: [...found][0] }; - if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] }; - if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] }; - if (unavailable) return { kind: "unavailable", input }; - return { kind: "not_found", input }; +function sendDocError(res: Res, payload: DocErrorPayload): void { + json(res, payload.body, payload.status); } type DocOptionsResult = T & { @@ -357,7 +253,53 @@ function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange return getAnnotatableDocRegex().test(relativePath) && !isFileBrowserExcludedPath(relativePath); } -/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */ +/** The render decision is a pure function of resolved path plus `?convert=1`. */ +function readDocument(res: Res, path: string, convert: boolean, options: HandleDocOptions): void { + try { + if (statSync(path).size > MAX_ANNOTATABLE_FILE_BYTES) { + sendDocError(res, DOC_TOO_LARGE); + return; + } + const snapshot = readSourceFileSnapshot(path); + if (/\.html?$/i.test(path)) { + if (convert) { + jsonDoc(res, { markdown: htmlToMarkdown(snapshot.text), filepath: path, isConverted: true, renderAs: "markdown" }, options); + } else { + jsonDoc(res, { rawHtml: snapshot.text, renderAs: "html", filepath: path }, options); + } + return; + } + jsonDoc(res, { markdown: snapshot.text, filepath: path, renderAs: "markdown" }, options, undefined, snapshot); + } catch { + json(res, { error: "Failed to read file" }, 500); + } +} + +async function readCodeFile(res: Res, path: string, input: string, parsed: ParsedCodePath): Promise { + try { + if (statSync(path).size > MAX_ANNOTATABLE_FILE_BYTES) { + sendDocError(res, DOC_TOO_LARGE); + return; + } + const contents = readFileSync(path, "utf-8"); + const displayName = path.split("/").pop() || path; + let prerenderedHTML: string | undefined; + try { + const result = await preloadFile({ + file: { name: displayName, contents }, + options: { disableFileHeader: true }, + }); + prerenderedHTML = result.prerenderedHTML; + } catch { + // Fall back to client-side rendering + } + json(res, { codeFile: true, contents, filepath: path, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); + } catch { + json(res, { error: `File not found: ${input}` }, 404); + } +} + +/** Uses the shared doc resolver for parity with the Bun server. */ export async function handleDocRequest(res: Res, url: URL, options: HandleDocOptions = {}): Promise { const requestedPath = url.searchParams.get("path"); if (!requestedPath) { @@ -371,183 +313,36 @@ export async function handleDocRequest(res: Res, url: URL, options: HandleDocOpt void warmFileListCache(root, "code"); } - // Try resolving relative to base directory first (used by annotate mode). - const base = url.searchParams.get("base"); - const resolvedBase = getTrustedBaseDir(base, allowedRoots); + // A base is only honored when it is itself inside an allowed root. + const resolvedBase = getTrustedBaseDir(url.searchParams.get("base"), allowedRoots); const convert = url.searchParams.get("convert") === "1"; // `?doc=1` (set by the file browser) forces annotatable plain-text rendering // for extensions that overlap CODE_FILE_REGEX (.yaml, .json, .toml, .ini, // .xml). Without it, those paths keep the syntax-highlighted code-file // popout response, so code-file links inside documents are unaffected. const forceDoc = url.searchParams.get("doc") === "1"; - const docExtensions = getAnnotatableDocRegex(); - const wantsDocRender = (path: string) => - docExtensions.test(path) && (forceDoc || !isCodeFilePath(path)); - if ( - resolvedBase && - !isAbsoluteUserPath(requestedPath) && - wantsDocRender(requestedPath) - ) { - const fromBase = resolveUserPath(requestedPath, resolvedBase); - if (!isWithinAllowedRoots(fromBase, allowedRoots)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - try { - if (existsSync(fromBase)) { - if (statSync(fromBase).size > MAX_ANNOTATABLE_FILE_BYTES) { - json(res, { error: "File too large (max 2MB)" }, 413); - return; - } - const snapshot = readSourceFileSnapshot(fromBase); - const raw = snapshot.text; - const isHtml = /\.html?$/i.test(requestedPath); - if (isHtml && !convert) { - jsonDoc(res, { rawHtml: raw, renderAs: "html", filepath: fromBase }, options); - return; - } - const markdown = isHtml ? htmlToMarkdown(raw) : raw; - jsonDoc( - res, - { markdown, filepath: fromBase, isConverted: isHtml, renderAs: "markdown" }, - options, - undefined, - isHtml ? undefined : snapshot, - ); - return; - } - } catch { - /* fall through to standard resolution */ - } - } - - // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx) - const projectRoot = allowedRoots[0]; - if (/\.html?$/i.test(requestedPath)) { - const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot); - if (!isWithinAllowedRoots(resolvedHtml, allowedRoots)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - try { - if (existsSync(resolvedHtml)) { - const html = readFileSync(resolvedHtml, "utf-8"); - if (!convert) { - jsonDoc(res, { rawHtml: html, renderAs: "html", filepath: resolvedHtml }, options); - return; - } - jsonDoc(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true, renderAs: "markdown" }, options); - return; - } - } catch { /* fall through to 404 */ } - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - - // Code files: try literal resolve first; on miss, fall back to smart resolver. - // Skipped when the client asked for doc rendering (`?doc=1`) on an - // annotatable plain-text path — those fall through to the markdown - // resolution below and render like .txt. - if (isCodeFilePath(requestedPath) && !(forceDoc && isAnnotatableTextPath(requestedPath))) { - const parsed = parseCodePath(requestedPath); - const cleanPath = parsed.filePath; - const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); - const literalAllowed = isWithinAllowedRoots(literalPath, allowedRoots); - - let resolvedCode: string | null = null; - if (literalAllowed && existsSync(literalPath)) { - resolvedCode = literalPath; - } - - if (!resolvedCode) { - if (isAbsoluteUserPath(cleanPath) && !isWithinAllowedRoots(resolveUserPath(cleanPath), allowedRoots)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - const result = await resolveCodeFileFromAllowedRoots(cleanPath, allowedRoots, resolvedBase); - if (result.kind === "found") { - resolvedCode = result.path; - } else if (result.kind === "ambiguous") { - const relative = result.matches.map((m: string) => relativizeToAllowedRoots(m, allowedRoots)); - json(res, { error: `Ambiguous path '${requestedPath}'`, matches: relative }, 400); - return; - } else if (result.kind === "unavailable") { - json(res, { error: `Cannot scan project: ${requestedPath}`, reason: "unavailable" }, 503); - return; - } else { - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - if (!isWithinAllowedRoots(resolvedCode, allowedRoots)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - } - try { - const stat = statSync(resolvedCode); - if (stat.size > MAX_ANNOTATABLE_FILE_BYTES) { - json(res, { error: "File too large (max 2MB)" }, 413); - return; - } - const contents = readFileSync(resolvedCode, "utf-8"); - const displayName = resolvedCode.split("/").pop() || resolvedCode; - let prerenderedHTML: string | undefined; - try { - const result = await preloadFile({ - file: { name: displayName, contents }, - options: { disableFileHeader: true }, - }); - prerenderedHTML = result.prerenderedHTML; - } catch { - // Fall back to client-side rendering - } - json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); - return; - } catch { - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - } - - if (isAbsoluteUserPath(requestedPath) && !isWithinAllowedRoots(resolveUserPath(requestedPath), allowedRoots)) { - json(res, { error: "Access denied: path is outside project root" }, 403); + const resolution = await resolveDocTarget(requestedPath, { + base: resolvedBase, + forceDoc, + roots: allowedRoots, + }); + // The one authorization site. A named path is gated whether or not it + // exists, so an escaping name is denied rather than reported absent. + const named = resolution.kind === "file" || resolution.kind === "not_found" ? resolution.path : undefined; + if (named !== undefined && !isPathAllowed(named, allowedRoots)) { + sendDocError(res, DOC_ACCESS_DENIED); return; } - const result = resolveMarkdownFileFromAllowedRoots(requestedPath, allowedRoots); - - if (result.kind === "ambiguous") { - json( - res, - { - error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, - matches: result.matches.map((m: string) => relativizeToAllowedRoots(m, allowedRoots)), - }, - 400, - ); + if (resolution.kind !== "file") { + sendDocError(res, docResolutionError(resolution, allowedRoots)); return; } - - if (result.kind === "unavailable") { - json(res, { error: `Cannot scan project: ${result.input}`, reason: "unavailable" }, 503); + if (resolution.render === "code") { + await readCodeFile(res, resolution.path, requestedPath, resolution.code); return; } - - if (result.kind === "not_found") { - json(res, { error: `File not found: ${result.input}` }, 404); - return; - } - - try { - if (statSync(result.path).size > MAX_ANNOTATABLE_FILE_BYTES) { - json(res, { error: "File too large (max 2MB)" }, 413); - return; - } - const snapshot = readSourceFileSnapshot(result.path); - jsonDoc(res, { markdown: snapshot.text, filepath: result.path, renderAs: "markdown" }, options, undefined, snapshot); - } catch { - json(res, { error: "Failed to read file" }, 500); - } + readDocument(res, resolution.path, convert, options); } /** @@ -580,14 +375,9 @@ export async function handleDocExistsRequest(res: Res, req: IncomingMessage, opt await Promise.all( (paths as string[]).map(async (p) => { - const cleanP = parseCodePath(p).filePath; - if (isAbsoluteUserPath(cleanP) && !isWithinAllowedRoots(resolveUserPath(cleanP), allowedRoots)) { - results[p] = { status: "missing" }; - return; - } - const r = await resolveCodeFileFromAllowedRoots(cleanP, allowedRoots, baseDir); + const r = await resolveCodeFileInRoots(parseCodePath(p).filePath, allowedRoots, baseDir); if (r.kind === "found") { - results[p] = isWithinAllowedRoots(r.path, allowedRoots) + results[p] = isPathAllowed(r.path, allowedRoots) ? { status: "found", resolved: r.path } : { status: "missing" }; } else if (r.kind === "ambiguous") { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 9c3f9b901..b3f8c739f 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -29,7 +29,7 @@ for f in config-types storage-types workspace-status-types; do done # Everything else in the original flat list stays sourced from packages/shared. -for f in prompts review-core generated-files feedback-archive cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale live-proxy-core live-probe live-proxy-node; do +for f in prompts review-core generated-files feedback-archive cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file doc-resolve file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale live-proxy-core live-probe live-proxy-node; do src="../../packages/shared/$f.ts" # Shared modules that import browser-safe siblings from @plannotator/core # (e.g. guide-store → core/guide-format): generated/ is flat and vendors the diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts index 4fb41a250..f9c0f3dd5 100644 --- a/packages/server/reference-handlers.test.ts +++ b/packages/server/reference-handlers.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -352,3 +352,142 @@ describe("annotatable document size cap", () => { expect(data.error).toBe("File too large (max 2MB)"); }); }); + +// Each branch resolves its own path, so a symlink escaping the root has to be +// denied on all six. A single-vector test passes against a fix that missed one. +describe("symlink containment", () => { + function makeEscapeFixture(): { root: string; outside: string } { + const root = makeTempDir("plannotator-symlink-root-"); + const outside = makeTempDir("plannotator-symlink-outside-"); + writeTempFile(outside, "secret.md", "SECRET-MD\n"); + writeTempFile(outside, "secret.html", "

SECRET-HTML

"); + writeTempFile(outside, "secret.ts", "// SECRET-TS\n"); + writeTempFile(outside, "deep.md", "SECRET-DEEP\n"); + writeTempFile(root, "sibling.md", "sibling\n"); + symlinkSync(join(outside, "secret.md"), join(root, "link.md")); + symlinkSync(join(outside, "secret.html"), join(root, "link.html")); + symlinkSync(join(outside, "secret.ts"), join(root, "link.ts")); + symlinkSync(outside, join(root, "linkdir")); + return { root, outside }; + } + + const escapeVectors: { branch: string; path: (root: string) => string; withBase?: boolean }[] = [ + { branch: "base-relative document", path: () => "link.md", withBase: true }, + { branch: "markdown resolver, bare name", path: () => "link.md" }, + { branch: "markdown resolver, absolute path", path: (root) => join(root, "link.md") }, + { branch: "raw HTML", path: () => "link.html" }, + { branch: "code file", path: () => "link.ts" }, + { branch: "symlinked directory", path: () => "linkdir/deep.md" }, + ]; + + for (const vector of escapeVectors) { + test(`denies an escaping symlink via the ${vector.branch} branch`, async () => { + const { root } = makeEscapeFixture(); + + const res = await getDoc(vector.path(root), { + rootPaths: [root], + base: vector.withBase ? root : undefined, + }); + + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("SECRET"); + }); + } + + test("ordinary in-root siblings are still served", async () => { + const { root } = makeEscapeFixture(); + + const res = await getDoc("sibling.md", { rootPaths: [root] }); + const data = await res.json() as { markdown?: string }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe("sibling\n"); + }); + + test("doc/exists reports an escaping symlink as missing, not found", async () => { + const { root } = makeEscapeFixture(); + + const data = await postDocExists({ paths: ["link.ts"] }, { rootPath: root }); + + expect(data.results["link.ts"]).toEqual({ status: "missing" }); + }); + + test("refuses a base directory that reaches outside the root through a symlink", async () => { + const { root } = makeEscapeFixture(); + + const res = await getDoc("deep.md", { rootPaths: [root], base: join(root, "linkdir") }); + + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("SECRET"); + }); + + test("answers 403 for an escaping absolute path whether or not it exists", async () => { + const { root, outside } = makeEscapeFixture(); + + const existing = await getDoc(join(outside, "secret.md"), { rootPaths: [root] }); + const absent = await getDoc(join(outside, "absent.md"), { rootPaths: [root] }); + + expect(existing.status).toBe(403); + expect(absent.status).toBe(403); + }); +}); + +// A root addressed through a symlink is searched under both its spellings, so +// the one file it finds twice must stay one match. +describe("symlinked root", () => { + test("serves a bare filename from a root given as a symlink", async () => { + const real = makeTempDir("plannotator-symroot-real-"); + const parent = makeTempDir("plannotator-symroot-parent-"); + const link = join(parent, "docs"); + writeTempFile(real, "note.md", "note\n"); + symlinkSync(real, link); + + const res = await getDoc("note.md", { rootPaths: [link] }); + const data = await res.json() as { markdown?: string }; + + expect(res.status).toBe(200); + expect(data.markdown).toBe("note\n"); + }); +}); + +describe("ambiguous resolution", () => { + test.each<{ name: string; file: string; request: string; error: string }>([ + { + name: "code paths report the code shape", + file: "dup.ts", + request: "dup.ts", + error: "Ambiguous path 'dup.ts'", + }, + { + name: "document names report the count shape", + file: "dup.md", + request: "dup.md", + error: "Ambiguous filename 'dup.md': found 2 matches", + }, + ])("$name", async ({ file, request, error }) => { + const root = makeTempDir("plannotator-ambiguous-"); + writeTempFile(root, join("a", file), "a"); + writeTempFile(root, join("b", file), "b"); + + const res = await getDoc(request, { rootPaths: [root] }); + const data = await res.json() as { error?: string; matches?: string[] }; + + expect(res.status).toBe(400); + expect(data.error).toBe(error); + expect(data.matches?.slice().sort()).toEqual([join("a", file), join("b", file)]); + }); +}); + +describe("raw HTML size cap", () => { + test("rejects an oversized .html with 413 whether or not a base is given", async () => { + const root = makeTempDir("plannotator-html-cap-"); + writeFileSync(join(root, "huge.html"), `

${"x".repeat(2 * 1024 * 1024 + 1)}

`); + + const withoutBase = await getDoc("huge.html", { rootPaths: [root] }); + const withBase = await getDoc("huge.html", { rootPaths: [root], base: root }); + + expect(withoutBase.status).toBe(413); + expect(withBase.status).toBe(413); + expect((await withBase.json() as { error?: string }).error).toBe("File too large (max 2MB)"); + }); +}); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 02549b801..ab4663d7e 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -15,21 +15,30 @@ import { getWorkspaceStatusRelativePaths, type WorkspaceFileChange, } from "@plannotator/shared/workspace-status"; -import { parseCodePath } from "@plannotator/shared/code-file"; +import { parseCodePath, type ParsedCodePath } from "@plannotator/shared/code-file"; import { detectObsidianVaults } from "./integrations"; import { - isAbsoluteUserPath, - isCodeFilePath, - resolveCodeFile, - resolveMarkdownFile, resolveUserPath, - isWithinProjectRoot, getFileBrowserMaxFiles, warmFileListCache, getAnnotatableDocRegex, MAX_ANNOTATABLE_FILE_BYTES, isAnnotatableTextPath, } from "@plannotator/shared/resolve-file"; +import { + DOC_ACCESS_DENIED, + DOC_TOO_LARGE, + docResolutionError, + getAllowedRootPaths, + getTrustedBaseDir, + isPathAllowed, + relativizeToAllowedRoots, + resolveAllowedDocPath, + resolveCodeFileInRoots, + resolveDocTarget, + type DocErrorPayload, + type ResolveAllowedDocPathResult, +} from "@plannotator/shared/doc-resolve"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "@plannotator/shared/source-save"; import { @@ -100,123 +109,11 @@ interface HandleDocExistsOptions { rootPaths?: string[]; } -type RouteResolveResult = - | { kind: "found"; path: string } - | { kind: "not_found"; input: string } - | { kind: "ambiguous"; input: string; matches: string[] } - | { kind: "unavailable"; input: string }; - -function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] { - const rawRoots = options?.rootPaths?.length - ? options.rootPaths - : [options?.rootPath ?? process.cwd()]; - const roots: string[] = []; - for (const root of rawRoots) { - if (typeof root !== "string" || root.length === 0) continue; - const resolved = resolveUserPath(root); - if (!roots.includes(resolved)) roots.push(resolved); - } - return roots.length > 0 ? roots : [resolveUserPath(process.cwd())]; -} - -function isWithinAllowedRoots(candidate: string, roots: string[]): boolean { - return roots.some((root) => isWithinProjectRoot(candidate, root)); -} - -function getTrustedBaseDir(base: string | null, roots: string[]): string | null { - if (!base) return null; - const resolvedBase = resolveUserPath(base); - return isWithinAllowedRoots(resolvedBase, roots) ? resolvedBase : null; -} - -export type ResolveAllowedDocPathResult = - | { kind: "resolved"; path: string } - | { kind: "denied" }; - -/** - * Resolve a client-supplied path the same way /api/doc's base-relative and - * absolute branches do (see `getTrustedBaseDir` / `isWithinAllowedRoots` - * above), for callers that need the canonical contained path without - * reading the file — namely the annotate version endpoints, which derive a - * history slug from the resolved path rather than trusting a client-supplied - * slug (a client slug would be a path-traversal vector: `getHistoryDir` - * joins it into a filesystem path unsanitized). - */ -export function resolveAllowedDocPath( - requestedPath: string, - base: string | null, - options?: { rootPaths?: string[] }, -): ResolveAllowedDocPathResult { - const allowedRoots = getAllowedRootPaths(options); - const resolvedBase = getTrustedBaseDir(base, allowedRoots); - const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined); - return isWithinAllowedRoots(candidate, allowedRoots) - ? { kind: "resolved", path: candidate } - : { kind: "denied" }; -} - -function relativizeToAllowedRoots(path: string, roots: string[]): string { - for (const root of roots) { - const prefix = `${root}/`; - if (path.startsWith(prefix)) return path.slice(prefix.length); - if (path === root) return "."; - } - return path; -} - -async function resolveCodeFileFromAllowedRoots( - input: string, - roots: string[], - baseDir: string | null, -): Promise { - const found = new Set(); - const ambiguous = new Set(); - let unavailable = false; - - for (const root of roots) { - const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined; - const result = await resolveCodeFile(input, root, rootBase); - if (result.kind === "found") { - if (isWithinProjectRoot(result.path, root)) found.add(result.path); - } else if (result.kind === "ambiguous") { - for (const match of result.matches) { - ambiguous.add(match); - } - } else if (result.kind === "unavailable") { - unavailable = true; - } - } +// Re-exported for the annotate version endpoints, which import it from here. +export { resolveAllowedDocPath, type ResolveAllowedDocPathResult }; - if (found.size === 1) return { kind: "found", path: [...found][0] }; - if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] }; - if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] }; - if (unavailable) return { kind: "unavailable", input }; - return { kind: "not_found", input }; -} - -function resolveMarkdownFileFromAllowedRoots(input: string, roots: string[]): RouteResolveResult { - const found = new Set(); - const ambiguous = new Set(); - let unavailable = false; - - for (const root of roots) { - const result = resolveMarkdownFile(input, root); - if (result.kind === "found") { - if (isWithinProjectRoot(result.path, root)) found.add(result.path); - } else if (result.kind === "ambiguous") { - for (const match of result.matches) { - ambiguous.add(match); - } - } else if (result.kind === "unavailable") { - unavailable = true; - } - } - - if (found.size === 1) return { kind: "found", path: [...found][0] }; - if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] }; - if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] }; - if (unavailable) return { kind: "unavailable", input }; - return { kind: "not_found", input }; +function errorResponse(payload: DocErrorPayload): Response { + return Response.json(payload.body, { status: payload.status }); } type DocOptionsResult = T & { @@ -306,6 +203,48 @@ function docJson(data: Record, options?: HandleDocOptions, sour return Response.json(applyDocOptions(data, options, sourceSnapshot)); } +/** The render decision is a pure function of resolved path plus `?convert=1`. */ +async function readDocument(path: string, convert: boolean, options: HandleDocOptions): Promise { + if (Bun.file(path).size > MAX_ANNOTATABLE_FILE_BYTES) { + return errorResponse(DOC_TOO_LARGE); + } + try { + const snapshot = readSourceFileSnapshot(path); + if (/\.html?$/i.test(path)) { + return convert + ? docJson({ markdown: htmlToMarkdown(snapshot.text), filepath: path, isConverted: true, renderAs: "markdown" }, options) + : docJson({ rawHtml: snapshot.text, renderAs: "html", filepath: path }, options); + } + return docJson({ markdown: snapshot.text, filepath: path, renderAs: "markdown" }, options, snapshot); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} + +async function readCodeFile(path: string, input: string, parsed: ParsedCodePath): Promise { + try { + const file = Bun.file(path); + if (file.size > MAX_ANNOTATABLE_FILE_BYTES) { + return errorResponse(DOC_TOO_LARGE); + } + const contents = await file.text(); + const displayName = path.split("/").pop() || path; + let prerenderedHTML: string | undefined; + try { + const result = await preloadFile({ + file: { name: displayName, contents }, + options: { disableFileHeader: true }, + }); + prerenderedHTML = result.prerenderedHTML; + } catch { + // Fall back to client-side rendering + } + return Response.json({ codeFile: true, contents, filepath: path, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); + } catch { + return Response.json({ error: `File not found: ${input}` }, { status: 404 }); + } +} + /** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */ export async function handleDoc(req: Request, options: HandleDocOptions = {}): Promise { const url = new URL(req.url); @@ -321,10 +260,8 @@ export async function handleDoc(req: Request, options: HandleDocOptions = {}): P void warmFileListCache(root, "code"); } - // If a base directory is provided, try resolving relative to it first - // (used by annotate mode to resolve paths relative to the source file). - const base = url.searchParams.get("base"); - const resolvedBase = getTrustedBaseDir(base, allowedRoots); + // A base is only honored when it is itself inside an allowed root. + const resolvedBase = getTrustedBaseDir(url.searchParams.get("base"), allowedRoots); // HTML renders raw by default; `?convert=1` (set by the frontend when the session's // --markdown preference is on) forces Turndown conversion instead. const convert = url.searchParams.get("convert") === "1"; @@ -333,166 +270,24 @@ export async function handleDoc(req: Request, options: HandleDocOptions = {}): P // .xml). Without it, those paths keep the syntax-highlighted code-file // popout response, so code-file links inside documents are unaffected. const forceDoc = url.searchParams.get("doc") === "1"; - const docExtensions = getAnnotatableDocRegex(); - const wantsDocRender = (path: string) => - docExtensions.test(path) && (forceDoc || !isCodeFilePath(path)); - if ( - resolvedBase && - !isAbsoluteUserPath(requestedPath) && - wantsDocRender(requestedPath) - ) { - const fromBase = resolveUserPath(requestedPath, resolvedBase); - if (!isWithinAllowedRoots(fromBase, allowedRoots)) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - try { - const file = Bun.file(fromBase); - if (await file.exists()) { - if (file.size > MAX_ANNOTATABLE_FILE_BYTES) { - return Response.json({ error: "File too large (max 2MB)" }, { status: 413 }); - } - const snapshot = readSourceFileSnapshot(fromBase); - const raw = snapshot.text; - const isHtml = /\.html?$/i.test(requestedPath); - if (isHtml && !convert) { - return docJson({ rawHtml: raw, renderAs: "html", filepath: fromBase }, options); - } - const markdown = isHtml ? htmlToMarkdown(raw) : raw; - return docJson( - { markdown, filepath: fromBase, isConverted: isHtml, renderAs: "markdown" }, - options, - isHtml ? undefined : snapshot, - ); - } - } catch { - /* fall through to standard resolution */ - } - } - - // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx) - const projectRoot = allowedRoots[0]; - if (/\.html?$/i.test(requestedPath)) { - const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot); - if (!isWithinAllowedRoots(resolvedHtml, allowedRoots)) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - try { - const file = Bun.file(resolvedHtml); - if (await file.exists()) { - const html = await file.text(); - if (!convert) { - return docJson({ rawHtml: html, renderAs: "html", filepath: resolvedHtml }, options); - } - const markdown = htmlToMarkdown(html); - return docJson({ markdown, filepath: resolvedHtml, isConverted: true, renderAs: "markdown" }, options); - } - } catch { /* fall through */ } - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - - // Code files: try literal resolve first; on miss, fall back to the smart - // resolver which walks the project for case-insensitive / suffix matches. - // Skipped when the client asked for doc rendering (`?doc=1`) on an - // annotatable plain-text path — those fall through to the markdown - // resolution below and render like .txt. - if (isCodeFilePath(requestedPath) && !(forceDoc && isAnnotatableTextPath(requestedPath))) { - const parsed = parseCodePath(requestedPath); - const cleanPath = parsed.filePath; - const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); - const literalAllowed = isWithinAllowedRoots(literalPath, allowedRoots); - - let resolvedCode: string | null = null; - if (literalAllowed) { - try { - const file = Bun.file(literalPath); - if (await file.exists()) resolvedCode = literalPath; - } catch { /* fall through */ } - } - - if (!resolvedCode) { - if (isAbsoluteUserPath(cleanPath) && !isWithinAllowedRoots(resolveUserPath(cleanPath), allowedRoots)) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - const result = await resolveCodeFileFromAllowedRoots(cleanPath, allowedRoots, resolvedBase); - if (result.kind === "found") { - resolvedCode = result.path; - } else if (result.kind === "ambiguous") { - const relative = result.matches.map((m) => relativizeToAllowedRoots(m, allowedRoots)); - return Response.json( - { error: `Ambiguous path '${requestedPath}'`, matches: relative }, - { status: 400 }, - ); - } else if (result.kind === "unavailable") { - return Response.json({ error: `Cannot scan project: ${requestedPath}`, reason: "unavailable" }, { status: 503 }); - } else { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - if (!isWithinAllowedRoots(resolvedCode, allowedRoots)) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - } - - try { - const file = Bun.file(resolvedCode); - if (file.size > MAX_ANNOTATABLE_FILE_BYTES) { - return Response.json({ error: "File too large (max 2MB)" }, { status: 413 }); - } - const contents = await file.text(); - const displayName = resolvedCode.split("/").pop() || resolvedCode; - let prerenderedHTML: string | undefined; - try { - const result = await preloadFile({ - file: { name: displayName, contents }, - options: { disableFileHeader: true }, - }); - prerenderedHTML = result.prerenderedHTML; - } catch { - // Fall back to client-side rendering - } - return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); - } catch { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - } - - if (isAbsoluteUserPath(requestedPath) && !isWithinAllowedRoots(resolveUserPath(requestedPath), allowedRoots)) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - const result = resolveMarkdownFileFromAllowedRoots(requestedPath, allowedRoots); - - if (result.kind === "ambiguous") { - return Response.json( - { - error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, - matches: result.matches.map((m) => relativizeToAllowedRoots(m, allowedRoots)), - }, - { status: 400 }, - ); - } - - if (result.kind === "unavailable") { - return Response.json( - { error: `Cannot scan project: ${result.input}`, reason: "unavailable" }, - { status: 503 }, - ); - } - if (result.kind === "not_found") { - return Response.json( - { error: `File not found: ${result.input}` }, - { status: 404 }, - ); - } - - try { - if (Bun.file(result.path).size > MAX_ANNOTATABLE_FILE_BYTES) { - return Response.json({ error: "File too large (max 2MB)" }, { status: 413 }); - } - const snapshot = readSourceFileSnapshot(result.path); - return docJson({ markdown: snapshot.text, filepath: result.path, renderAs: "markdown" }, options, snapshot); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + const resolution = await resolveDocTarget(requestedPath, { + base: resolvedBase, + forceDoc, + roots: allowedRoots, + }); + // The one authorization site. A named path is gated whether or not it + // exists, so an escaping name is denied rather than reported absent. + const named = resolution.kind === "file" || resolution.kind === "not_found" ? resolution.path : undefined; + if (named !== undefined && !isPathAllowed(named, allowedRoots)) { + return errorResponse(DOC_ACCESS_DENIED); + } + if (resolution.kind !== "file") { + return errorResponse(docResolutionError(resolution, allowedRoots)); + } + return resolution.render === "code" + ? readCodeFile(resolution.path, requestedPath, resolution.code) + : readDocument(resolution.path, convert, options); } /** @@ -529,14 +324,9 @@ export async function handleDocExists(req: Request, options?: HandleDocExistsOpt await Promise.all( (paths as string[]).map(async (p) => { - const cleanP = parseCodePath(p).filePath; - if (isAbsoluteUserPath(cleanP) && !isWithinAllowedRoots(resolveUserPath(cleanP), allowedRoots)) { - results[p] = { status: "missing" }; - return; - } - const r = await resolveCodeFileFromAllowedRoots(cleanP, allowedRoots, baseDir); + const r = await resolveCodeFileInRoots(parseCodePath(p).filePath, allowedRoots, baseDir); if (r.kind === "found") { - results[p] = isWithinAllowedRoots(r.path, allowedRoots) + results[p] = isPathAllowed(r.path, allowedRoots) ? { status: "found", resolved: r.path } : { status: "missing" }; } else if (r.kind === "ambiguous") { diff --git a/packages/shared/doc-resolve.test.ts b/packages/shared/doc-resolve.test.ts new file mode 100644 index 000000000..213c6c4cc --- /dev/null +++ b/packages/shared/doc-resolve.test.ts @@ -0,0 +1,74 @@ +/** + * The `/api/doc` containment gate. Normalization has to be two-sided: macOS + * `tmpdir()` is a symlink, so + * realpath containment against a non-realpathed root would deny legitimate + * reads for anyone whose root sits under one. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getAllowedRootPaths, isPathAllowed } from "./doc-resolve"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("isPathAllowed", () => { + test("serves a file inside the root and denies one outside it", () => { + const root = makeTempDir("plannotator-gate-root-"); + const outside = makeTempDir("plannotator-gate-outside-"); + writeFileSync(join(root, "note.md"), "note\n"); + writeFileSync(join(outside, "secret.md"), "secret\n"); + const roots = getAllowedRootPaths({ rootPaths: [root] }); + + expect(isPathAllowed(join(root, "note.md"), roots)).toBe(true); + expect(isPathAllowed(join(outside, "secret.md"), roots)).toBe(false); + }); + + test("denies a symlink that escapes the root, and a file reached through a symlinked directory", () => { + const root = makeTempDir("plannotator-gate-link-root-"); + const outside = makeTempDir("plannotator-gate-link-outside-"); + writeFileSync(join(outside, "secret.md"), "secret\n"); + symlinkSync(join(outside, "secret.md"), join(root, "link.md")); + symlinkSync(outside, join(root, "linkdir")); + const roots = getAllowedRootPaths({ rootPaths: [root] }); + + expect(isPathAllowed(join(root, "link.md"), roots)).toBe(false); + expect(isPathAllowed(join(root, "linkdir", "secret.md"), roots)).toBe(false); + }); + + test("allows a symlinked root under either spelling", () => { + const realFolder = makeTempDir("plannotator-gate-real-"); + const linkParent = makeTempDir("plannotator-gate-linkparent-"); + const linkFolder = join(linkParent, "docs"); + writeFileSync(join(realFolder, "note.md"), "note\n"); + symlinkSync(realFolder, linkFolder); + const roots = getAllowedRootPaths({ rootPaths: [linkFolder] }); + + expect(isPathAllowed(join(linkFolder, "note.md"), roots)).toBe(true); + expect(isPathAllowed(realpathSync(join(realFolder, "note.md")), roots)).toBe(true); + }); + + test("judges a path whose leaf does not exist by its deepest existing ancestor", () => { + const root = makeTempDir("plannotator-gate-missing-root-"); + const outside = makeTempDir("plannotator-gate-missing-outside-"); + symlinkSync(outside, join(root, "linkdir")); + mkdirSync(join(root, "real"), { recursive: true }); + const roots = getAllowedRootPaths({ rootPaths: [root] }); + + expect(isPathAllowed(join(root, "real", "absent.md"), roots)).toBe(true); + expect(isPathAllowed(join(root, "absent", "deeper", "absent.md"), roots)).toBe(true); + expect(isPathAllowed(join(root, "linkdir", "absent.md"), roots)).toBe(false); + }); +}); diff --git a/packages/shared/doc-resolve.ts b/packages/shared/doc-resolve.ts new file mode 100644 index 000000000..d241e737c --- /dev/null +++ b/packages/shared/doc-resolve.ts @@ -0,0 +1,338 @@ +/** + * Document resolution and containment for `/api/doc` and `/api/doc/exists`, + * shared by the Bun and node:http servers. + * + * Resolving which file a path names is separated from reading it so + * authorization sits on one seam: every reachable path comes from + * `resolveDocTarget`, and `isPathAllowed` alone decides whether it is served. + * Resolution may `stat`; it never reads contents. + */ + +import { realpathSync, statSync } from "fs"; +import { basename, dirname, join } from "path"; +import { parseCodePath, type ParsedCodePath } from "./code-file"; +import { + getAnnotatableDocRegex, + isAbsoluteUserPath, + isAnnotatableTextPath, + isCodeFilePath, + isWithinProjectRoot, + resolveCodeFile, + resolveMarkdownFile, + resolveUserPath, +} from "./resolve-file"; + +/** How a resolved file is rendered: an annotatable document, or a code-file popout. */ +export type DocRender = "document" | "code"; + +export type DocResolution = + | { kind: "file"; path: string; render: "document" } + | { kind: "file"; path: string; render: "code"; code: ParsedCodePath } + // `path` is set when the input named one path that does not exist; it is + // gated like a found path so an escaping name is denied, not reported absent. + | { kind: "not_found"; input: string; path?: string } + | { kind: "ambiguous"; input: string; matches: string[]; render: DocRender } + | { kind: "unavailable"; input: string }; + +export interface DocResolveOptions { + /** Base directory for relative paths, already vetted by `getTrustedBaseDir`. */ + base?: string | null; + /** `?doc=1`. Renders annotatable plain text for extensions that overlap code files. */ + forceDoc?: boolean; + roots: string[]; +} + +export interface DocErrorPayload { + status: number; + body: Record; +} + +export const DOC_ACCESS_DENIED: DocErrorPayload = { + status: 403, + body: { error: "Access denied: path is outside project root" }, +}; + +export const DOC_TOO_LARGE: DocErrorPayload = { + status: 413, + body: { error: "File too large (max 2MB)" }, +}; + +export function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] { + const rawRoots = options?.rootPaths?.length + ? options.rootPaths + : [options?.rootPath ?? process.cwd()]; + const roots: string[] = []; + const addRoot = (root: string) => { + const resolved = resolveUserPath(root); + if (!resolved) return; + if (!roots.includes(resolved)) roots.push(resolved); + // A root reachable through a symlink contributes both spellings, or a + // request naming either one fails the half it does not match. + const real = realpathAllowingMissingLeaf(resolved); + if (real && !roots.includes(real)) roots.push(real); + }; + for (const root of rawRoots) { + if (typeof root !== "string" || root.length === 0) continue; + addRoot(root); + } + if (roots.length === 0) addRoot(process.cwd()); + return roots; +} + +/** + * Realpath the deepest existing ancestor and re-join the rest, so a candidate + * whose leaf need not exist still resolves through symlinked parents. Any + * failure other than a missing entry fails closed. + */ +function realpathAllowingMissingLeaf(candidate: string): string | null { + if (!candidate) return null; + let current = candidate; + const missing: string[] = []; + for (;;) { + try { + const real = realpathSync(current); + return missing.length > 0 ? join(real, ...missing) : real; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return null; + const parent = dirname(current); + if (parent === current) return null; + missing.unshift(basename(current)); + current = parent; + } + } +} + +/** + * The containment gate: a path must sit inside an allowed root both as written + * and after symlink resolution. Lexical containment alone reads a symlink + * planted under a root through to whatever it points at. + * + * Path-based, so it does not survive a local writer swapping the path between + * this check and the reader's open. + */ +export function isPathAllowed(candidate: string, roots: string[]): boolean { + if (!candidate) return false; + if (!roots.some((root) => isWithinProjectRoot(candidate, root))) return false; + const real = realpathAllowingMissingLeaf(candidate); + if (!real) return false; + return roots.some((root) => isWithinProjectRoot(real, root)); +} + +export function getTrustedBaseDir(base: string | null | undefined, roots: string[]): string | null { + if (!base) return null; + const resolvedBase = resolveUserPath(base); + return isPathAllowed(resolvedBase, roots) ? resolvedBase : null; +} + +export type ResolveAllowedDocPathResult = + | { kind: "resolved"; path: string } + | { kind: "denied" }; + +/** + * Resolve a path through the same gate `/api/doc` uses, for callers needing the + * canonical contained path without reading the file. The annotate version + * endpoints derive a history slug from it, since the history dir lookup joins + * the slug into a path unsanitized. + */ +export function resolveAllowedDocPath( + requestedPath: string, + base: string | null, + options?: { rootPaths?: string[] }, +): ResolveAllowedDocPathResult { + const allowedRoots = getAllowedRootPaths(options); + const resolvedBase = getTrustedBaseDir(base, allowedRoots); + const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined); + return isPathAllowed(candidate, allowedRoots) + ? { kind: "resolved", path: candidate } + : { kind: "denied" }; +} + +export function relativizeToAllowedRoots(path: string, roots: string[]): string { + for (const root of roots) { + const prefix = `${root}/`; + if (path.startsWith(prefix)) return path.slice(prefix.length); + if (path === root) return "."; + } + return path; +} + +function isReadableFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +// A symlinked root is searched under both spellings; the same file found twice +// that way is one match, not an ambiguity. +function matchKey(path: string): string { + if (!isAbsoluteUserPath(path)) return path; + return realpathAllowingMissingLeaf(path) ?? path; +} + +export type RootSearchResult = + | { kind: "found"; path: string } + | { kind: "not_found" } + | { kind: "ambiguous"; matches: string[] } + | { kind: "unavailable" }; + +function collectRootSearch( + found: Map, + ambiguous: Map, + results: readonly { root: string; result: ReturnType }[], +): boolean { + let unavailable = false; + for (const { root, result } of results) { + if (result.kind === "found") { + if (!isWithinProjectRoot(result.path, root)) continue; + const key = matchKey(result.path); + if (!found.has(key)) found.set(key, result.path); + } else if (result.kind === "ambiguous") { + for (const match of result.matches) { + const key = matchKey(match); + if (!ambiguous.has(key)) ambiguous.set(key, match); + } + } else if (result.kind === "unavailable") { + unavailable = true; + } + } + return unavailable; +} + +function summarizeRootSearch( + found: Map, + ambiguous: Map, + unavailable: boolean, +): RootSearchResult { + if (found.size === 1) return { kind: "found", path: [...found.values()][0] }; + if (found.size > 1) return { kind: "ambiguous", matches: [...found.values()] }; + if (ambiguous.size > 0) return { kind: "ambiguous", matches: [...ambiguous.values()] }; + if (unavailable) return { kind: "unavailable" }; + return { kind: "not_found" }; +} + +export async function resolveCodeFileInRoots( + input: string, + roots: string[], + baseDir: string | null, +): Promise { + const results = await Promise.all( + roots.map(async (root) => { + const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined; + return { root, result: await resolveCodeFile(input, root, rootBase) }; + }), + ); + const found = new Map(); + const ambiguous = new Map(); + const unavailable = collectRootSearch(found, ambiguous, results); + return summarizeRootSearch(found, ambiguous, unavailable); +} + +function searchMarkdownFile(input: string, roots: string[]): RootSearchResult { + const results = roots.map((root) => ({ root, result: resolveMarkdownFile(input, root) })); + const found = new Map(); + const ambiguous = new Map(); + const unavailable = collectRootSearch(found, ambiguous, results); + return summarizeRootSearch(found, ambiguous, unavailable); +} + +/** The returned path is not authorized here. */ +export async function resolveDocTarget( + requestedPath: string, + options: DocResolveOptions, +): Promise { + const { roots } = options; + const base = options.base ?? null; + const projectRoot = roots[0]; + const docExtensions = getAnnotatableDocRegex(); + const wantsDocRender = (path: string) => + docExtensions.test(path) && (options.forceDoc || !isCodeFilePath(path)); + + // Relative to the source document's own directory (annotate sibling links). + if (base && !isAbsoluteUserPath(requestedPath) && wantsDocRender(requestedPath)) { + const fromBase = resolveUserPath(requestedPath, base); + // An escaping name means only itself, so it never falls through. + if (!roots.some((root) => isWithinProjectRoot(fromBase, root))) { + return { kind: "not_found", input: requestedPath, path: fromBase }; + } + if (isReadableFile(fromBase)) { + return { kind: "file", path: fromBase, render: "document" }; + } + } + + // resolveMarkdownFile only handles plain text, so HTML resolves directly. + if (/\.html?$/i.test(requestedPath)) { + const resolvedHtml = resolveUserPath(requestedPath, base || projectRoot); + return isReadableFile(resolvedHtml) + ? { kind: "file", path: resolvedHtml, render: "document" } + : { kind: "not_found", input: requestedPath, path: resolvedHtml }; + } + + // Literal path first; on a miss the smart resolver walks the roots for + // case-insensitive and suffix matches. + if (isCodeFilePath(requestedPath) && !(options.forceDoc && isAnnotatableTextPath(requestedPath))) { + const parsed = parseCodePath(requestedPath); + const cleanPath = parsed.filePath; + const literalPath = resolveUserPath(cleanPath, base || projectRoot); + if (roots.some((root) => isWithinProjectRoot(literalPath, root)) && isReadableFile(literalPath)) { + return { kind: "file", path: literalPath, render: "code", code: parsed }; + } + if (isAbsoluteUserPath(cleanPath)) { + const absolutePath = resolveUserPath(cleanPath); + return isReadableFile(absolutePath) + ? { kind: "file", path: absolutePath, render: "code", code: parsed } + : { kind: "not_found", input: requestedPath, path: absolutePath }; + } + const search = await resolveCodeFileInRoots(cleanPath, roots, base); + if (search.kind === "found") return { kind: "file", path: search.path, render: "code", code: parsed }; + if (search.kind === "ambiguous") { + return { kind: "ambiguous", input: requestedPath, matches: search.matches, render: "code" }; + } + if (search.kind === "unavailable") return { kind: "unavailable", input: requestedPath }; + return { kind: "not_found", input: requestedPath }; + } + + // An absolute path names one file, so it skips the fuzzy search. + if (isAbsoluteUserPath(requestedPath)) { + const trimmed = requestedPath.trim(); + const absolutePath = resolveUserPath(trimmed); + return isAnnotatableTextPath(trimmed) && isReadableFile(absolutePath) + ? { kind: "file", path: absolutePath, render: "document" } + : { kind: "not_found", input: requestedPath, path: absolutePath }; + } + + const search = searchMarkdownFile(requestedPath, roots); + if (search.kind === "found") return { kind: "file", path: search.path, render: "document" }; + if (search.kind === "ambiguous") { + return { kind: "ambiguous", input: requestedPath, matches: search.matches, render: "document" }; + } + if (search.kind === "unavailable") return { kind: "unavailable", input: requestedPath }; + return { kind: "not_found", input: requestedPath }; +} + +/** The response both transports send for a non-`file` resolution. */ +export function docResolutionError( + resolution: Exclude, + roots: string[], +): DocErrorPayload { + if (resolution.kind === "ambiguous") { + const matches = resolution.matches.map((match) => relativizeToAllowedRoots(match, roots)); + return resolution.render === "code" + ? { status: 400, body: { error: `Ambiguous path '${resolution.input}'`, matches } } + : { + status: 400, + body: { + error: `Ambiguous filename '${resolution.input}': found ${resolution.matches.length} matches`, + matches, + }, + }; + } + if (resolution.kind === "unavailable") { + return { + status: 503, + body: { error: `Cannot scan project: ${resolution.input}`, reason: "unavailable" }, + }; + } + return { status: 404, body: { error: `File not found: ${resolution.input}` } }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index f9d05f5b2..f6422486c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -37,6 +37,7 @@ "./favicon": "./favicon.ts", "./code-file": "./code-file.ts", "./resolve-file": "./resolve-file.ts", + "./doc-resolve": "./doc-resolve.ts", "./annotate-reference-roots-node": "./annotate-reference-roots-node.ts", "./extract-code-paths": "./extract-code-paths.ts", "./external-annotation": "./external-annotation.ts", diff --git a/packages/shared/storage.test.ts b/packages/shared/storage.test.ts new file mode 100644 index 000000000..7eccce326 --- /dev/null +++ b/packages/shared/storage.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { readFileSync } from "node:fs"; + +// Imported statically and BEFORE any PLANNOTATOR_DATA_DIR is set: this module's +// import-time side effects used to freeze the data directory, so a later env +// change was silently ignored while every other consumer resolved it live. +import { getHistoryDir, saveToHistory } from "./storage"; + +import { createTestEnvironment } from "../../tests/helpers/environment"; + +const env = createTestEnvironment(["PLANNOTATOR_DATA_DIR"], "plannotator-storage-"); + +afterEach(() => env.restore()); + +describe("storage data directory", () => { + test("resolves PLANNOTATOR_DATA_DIR set after import", () => { + env.reset(); + const dataDir = env.makeTempDir(); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + + expect(getHistoryDir("proj", "slug")).toBe(`${dataDir}/history/proj/slug`); + + const saved = saveToHistory("proj", "slug", "# Plan\n"); + expect(saved.path).toBe(`${dataDir}/history/proj/slug/001.md`); + expect(readFileSync(saved.path, "utf-8")).toBe("# Plan\n"); + }); + + test("follows a later change to PLANNOTATOR_DATA_DIR", () => { + env.reset(); + const first = env.makeTempDir(); + process.env.PLANNOTATOR_DATA_DIR = first; + expect(getHistoryDir("proj", "slug")).toBe(`${first}/history/proj/slug`); + + const second = env.makeTempDir(); + process.env.PLANNOTATOR_DATA_DIR = second; + expect(getHistoryDir("proj", "slug")).toBe(`${second}/history/proj/slug`); + }); +}); diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index cc4e12f66..5c3662043 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -13,8 +13,6 @@ import { sanitizeTag } from "./project"; import { resolveUserPath } from "./resolve-file"; import { getPlannotatorDataDir } from "./data-dir"; -const DATA_DIR = getPlannotatorDataDir(); - /** * Get the plan storage directory, creating it if needed. * Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility. @@ -26,7 +24,7 @@ export function getPlanDir(customPath?: string | null): string { if (customPath?.trim()) { planDir = resolveUserPath(customPath); } else { - planDir = join(DATA_DIR, "plans"); + planDir = join(getPlannotatorDataDir(), "plans"); } mkdirSync(planDir, { recursive: true }); @@ -195,7 +193,7 @@ export function readArchivedPlan(filename: string, customPath?: string | null): * Not affected by the customPath setting (that only affects decision saves). */ export function getHistoryDir(project: string, slug: string): string { - const historyDir = join(DATA_DIR, "history", project, slug); + const historyDir = join(getPlannotatorDataDir(), "history", project, slug); mkdirSync(historyDir, { recursive: true }); return historyDir; } @@ -294,7 +292,7 @@ export function getPlanVersion( slug: string, version: number ): string | null { - const historyDir = join(DATA_DIR, "history", project, slug); + const historyDir = join(getPlannotatorDataDir(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); @@ -314,7 +312,7 @@ export function getPlanVersionPath( slug: string, version: number ): string | null { - const historyDir = join(DATA_DIR, "history", project, slug); + const historyDir = join(getPlannotatorDataDir(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); return existsSync(filePath) ? filePath : null; @@ -325,7 +323,7 @@ export function getPlanVersionPath( * Returns 0 if the directory doesn't exist. */ export function getVersionCount(project: string, slug: string): number { - const historyDir = join(DATA_DIR, "history", project, slug); + const historyDir = join(getPlannotatorDataDir(), "history", project, slug); try { const entries = readdirSync(historyDir); return entries.filter((e) => /^\d+\.md$/.test(e)).length; @@ -342,7 +340,7 @@ export function listVersions( project: string, slug: string ): Array<{ version: number; timestamp: string }> { - const historyDir = join(DATA_DIR, "history", project, slug); + const historyDir = join(getPlannotatorDataDir(), "history", project, slug); try { const entries = readdirSync(historyDir); const versions: Array<{ version: number; timestamp: string }> = []; @@ -372,7 +370,7 @@ export function listVersions( export function listProjectPlans( project: string ): Array<{ slug: string; versions: number; lastModified: string }> { - const projectDir = join(DATA_DIR, "history", project); + const projectDir = join(getPlannotatorDataDir(), "history", project); try { const entries = readdirSync(projectDir, { withFileTypes: true }); const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];