mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
f564448650
* 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).
108 lines
5.4 KiB
TypeScript
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"));
|
|
});
|
|
});
|