Files
Michael Ramos f564448650 fix(annotate): block symlink escape in HTML asset serving + cleanups (#927)
* fix(annotate): resolve symlinks before HTML asset containment check

The /api/html-assets route and the share-payload inliner checked path
containment lexically, so an in-directory symlink pointing outside the
HTML's folder (e.g. evil.css -> ~/.ssh/id_rsa) passed the check and was
served or base64-inlined into the share payload. In remote mode the
inliner auto-fires at startup, so this could upload symlinked local
files to the paste service with no user action.

Resolve symlinks with realpathSync on both the asset and the root before
the relative-path check, in all three runtime copies (Bun route handler,
shared node inliner, Pi route handler). Non-existent assets fall back to
the lexical path and 404 on read. Adds regression tests for both sinks.

* test(pi): build rich git state in sandbox-pi.sh for review diff modes

Expand the Pi sandbox harness to create multiple commits, a feature
branch, and a rename+delete+modify commit so /plannotator-review can
exercise every diff mode (uncommitted, staged, branch, merge-base).

* docs: fix broken verification link in READMEs

The READMEs pointed at a non-existent anchor
(installation/#verifying-your-install); the verification guide is a
standalone reference page. Point to /docs/reference/verifying-your-install/
(and split the hook README's link so version pinning -> installation,
verification -> the reference page).
2026-06-16 19:55:47 -07:00

108 lines
5.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHtmlAssetRegistry, inlineHtmlLocalAssets } from "./html-assets";
describe("annotate raw HTML assets", () => {
test("rewrites raw HTML support assets and serves them from the source directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-html-assets-"));
const htmlPath = join(dir, "page.html");
const cssPath = join(dir, "style.css");
const imagePath = join(dir, "logo.png");
const html = '<!doctype html><html><head><link rel="stylesheet" href="./style.css"></head><body><img src="./logo.png"></body></html>';
writeFileSync(htmlPath, html, "utf-8");
writeFileSync(cssPath, "body { color: red; }", "utf-8");
writeFileSync(imagePath, "png-bytes", "utf-8");
const assets = createHtmlAssetRegistry();
const rawHtml = assets.rewriteHtml(html, htmlPath);
expect(rawHtml).toContain("/api/html-assets/");
const cssUrl = rawHtml.match(/href="([^"]+style\.css)"/)?.[1];
const imageUrl = rawHtml.match(/src="([^"]+logo\.png)"/)?.[1];
expect(cssUrl).toBeTruthy();
expect(imageUrl).toBeTruthy();
const cssRequestUrl = new URL(cssUrl!, "http://localhost");
const cssResponse = await assets.handle(new Request(String(cssRequestUrl)), cssRequestUrl);
expect(cssResponse?.status).toBe(200);
expect(cssResponse?.headers.get("content-type")).toContain("text/css");
expect(cssResponse?.headers.get("access-control-allow-origin")).toBe("*");
expect(await cssResponse?.text()).toBe("body { color: red; }");
const imageRequestUrl = new URL(imageUrl!, "http://localhost");
const imageResponse = await assets.handle(new Request(String(imageRequestUrl)), imageRequestUrl);
expect(imageResponse?.status).toBe(200);
expect(imageResponse?.headers.get("content-type")).toBe("image/png");
expect(await imageResponse?.text()).toBe("png-bytes");
});
test("inlines raw HTML support assets for portable share payloads", () => {
const dir = mkdtempSync(join(tmpdir(), "plannotator-html-share-"));
const htmlPath = join(dir, "page.html");
const cssDir = join(dir, "styles");
const imageDir = join(dir, "images");
mkdirSync(cssDir);
mkdirSync(imageDir);
writeFileSync(join(imageDir, "bg.png"), Buffer.from([1, 2, 3]));
writeFileSync(join(cssDir, "style.css"), 'body { background: url("../images/bg.png"); }', "utf-8");
const html = '<!doctype html><html><head><link rel="stylesheet" href="./styles/style.css?v=1"></head><body><img src="./images/bg.png?cache=1"></body></html>';
writeFileSync(htmlPath, html, "utf-8");
const shareHtml = inlineHtmlLocalAssets(html, htmlPath);
expect(shareHtml).not.toContain("/api/html-assets/");
expect(shareHtml).toContain('href="data:text/css;charset=utf-8;base64,');
expect(shareHtml).toContain('src="data:image/png;base64,AQID"');
expect(shareHtml).not.toContain("base64,AQID?cache=1");
const cssBase64 = shareHtml.match(/href="data:text\/css;charset=utf-8;base64,([^"]+)"/)?.[1];
expect(cssBase64).toBeTruthy();
const css = Buffer.from(cssBase64!, "base64").toString("utf-8");
expect(css).toContain('url("data:image/png;base64,AQID")');
});
test("does not serve a symlinked asset that escapes the source directory", async () => {
// Attacker bundle: a symlink inside the HTML's dir pointing at a secret outside it.
const base = mkdtempSync(join(tmpdir(), "plannotator-html-symlink-"));
const htmlDir = join(base, "site");
mkdirSync(htmlDir);
const secretPath = join(base, "secret.css");
writeFileSync(secretPath, "SECRET_OUTSIDE_CONTENT", "utf-8");
symlinkSync(secretPath, join(htmlDir, "evil.css"));
const htmlPath = join(htmlDir, "page.html");
const html = '<!doctype html><html><head><link rel="stylesheet" href="./evil.css"></head><body></body></html>';
writeFileSync(htmlPath, html, "utf-8");
const assets = createHtmlAssetRegistry();
const rawHtml = assets.rewriteHtml(html, htmlPath);
const cssUrl = rawHtml.match(/href="([^"]+evil\.css)"/)?.[1];
expect(cssUrl).toBeTruthy();
const requestUrl = new URL(cssUrl!, "http://localhost");
const response = await assets.handle(new Request(String(requestUrl)), requestUrl);
expect(response?.status).toBe(403);
expect(await response?.text()).not.toContain("SECRET_OUTSIDE_CONTENT");
});
test("does not inline a symlinked asset that escapes the source directory", () => {
const base = mkdtempSync(join(tmpdir(), "plannotator-html-symlink-inline-"));
const htmlDir = join(base, "site");
mkdirSync(htmlDir);
const secretPath = join(base, "secret.css");
writeFileSync(secretPath, "SECRET_OUTSIDE_CONTENT", "utf-8");
symlinkSync(secretPath, join(htmlDir, "evil.css"));
const htmlPath = join(htmlDir, "page.html");
const html = '<!doctype html><html><head><link rel="stylesheet" href="./evil.css"></head><body></body></html>';
writeFileSync(htmlPath, html, "utf-8");
const shareHtml = inlineHtmlLocalAssets(html, htmlPath);
// The symlinked secret must not be base64-embedded into the portable share.
expect(shareHtml).not.toContain("base64");
expect(Buffer.from(shareHtml).toString("utf-8")).not.toContain("SECRET_OUTSIDE_CONTENT");
expect(shareHtml).not.toContain(Buffer.from("SECRET_OUTSIDE_CONTENT").toString("base64"));
});
});