import { describe, expect, mock, test } from "bun:test"; mock.module("cloudflare:workers", () => ({ env: { SECRETS_KEY: "test-secret", PUBLIC_URL: "https://forge.example.com" }, })); const { renderMarkdown } = await import("./markdown"); const { verifyCamoPath } = await import("./camo-sign"); const context = { owner: "acme", repo: "app", ref: "main", basePath: "docs" }; function camoTargets(html: string): string[] { return [...html.matchAll(/src="(\/camo\/[^"]+)"/g)].map((match) => verifyCamoPath(match[1]) ?? "INVALID"); } describe("renderMarkdown hardening", () => { test("external markdown images are rewritten to signed camo URLs", () => { const html = renderMarkdown("![x](https://evil.example/pixel.gif)", context); expect(html).not.toContain("https://evil.example/pixel.gif"); expect(camoTargets(html)).toEqual(["https://evil.example/pixel.gif"]); }); test("raw and protocol-relative sources are proxied too", () => { const html = renderMarkdown('', context); expect(camoTargets(html)).toEqual(["https://tracker.example/p.png", "http://a.example/b.jpg"]); }); test("slashless and backslash external images are proxied, not loaded directly", () => { const html = renderMarkdown("![a](http:evil.example/a.gif) ![b](https:/evil.example/b.gif)", context); expect(html).not.toMatch(/src="https?:/); expect(camoTargets(html)).toEqual(["http://evil.example/a.gif", "https://evil.example/b.gif"]); const bare = renderMarkdown("![c](/\\evil.example/c.gif)", null); expect(bare).not.toContain('src="/\\evil'); }); test("entity-encoded raw image sources are decoded before the camo decision", () => { const html = renderMarkdown( '', null, ); expect(html).not.toContain('evil.example/x.gif"'); expect(html).not.toMatch(/src="(?:https?:)?\/\/evil/); expect(camoTargets(html)).toEqual(["https://evil.example/x.gif", "https://evil.example/y.gif"]); }); test("absolute images on the forge itself load directly instead of disappearing", () => { const html = renderMarkdown("![logo](https://forge.example.com/acme/app/raw/main/logo.png)", context); expect(html).toContain('src="https://forge.example.com/acme/app/raw/main/logo.png"'); }); test("raw external anchors always get rel, even with rel= text in another attribute", () => { const html = renderMarkdown('x', context); expect(html).toContain('rel="nofollow noopener noreferrer"'); }); test("relative, attachment and data images stay same-origin", () => { const html = renderMarkdown( '![a](./img.png) ![b](/attachments/abc) ', context, ); expect(html).toContain('src="/acme/app/raw/main/docs/img.png"'); expect(html).toContain('src="/attachments/abc"'); expect(html).toContain('src="data:image/png;base64,AAAA"'); expect(html).not.toContain("/camo/"); }); test("user-supplied ids and names are prefixed to prevent DOM clobbering", () => { const html = renderMarkdown('x

T

', context); expect(html).toContain('id="user-content-__TANSTACK_ROUTER_STATE__"'); expect(html).toContain('name="user-content-submit"'); expect(html).toContain('id="user-content-top"'); expect(renderMarkdown("[go](#top)", context)).toContain('href="#user-content-top"'); }); test("external link attributes are escaped and still get rel", () => { const html = renderMarkdown('link', context); expect(html).toContain('rel="nofollow noopener noreferrer"'); expect(html).not.toMatch(/href="[^"]*<[^"]*"/); expect(renderMarkdown("[l](https://x.example)", context)).toContain('rel="nofollow noopener noreferrer"'); }); test("dangerous schemes are still dropped", () => { expect(renderMarkdown('x', context)).not.toContain("javascript:"); expect(renderMarkdown("[x](javascript:alert(1))", context)).not.toContain("href="); expect(renderMarkdown('', context)).not.toContain("svg+xml"); }); }); describe("renderMarkdown mentions", () => { const mentions = new Map([ ["oldname", { id: "acct-1", name: "NewName" }], ["acme", { id: "acct-2", name: "acme" }], ]); test("a former handle renders under the account's current name", () => { const html = renderMarkdown("thanks @oldname", context, mentions); expect(html).toContain('href="/NewName"'); expect(html).toContain(">@NewName"); expect(html).not.toContain("@oldname"); }); test("handles that name nobody stay plain text once resolved", () => { const html = renderMarkdown("cc @nobody", context, mentions); expect(html).not.toContain(" { expect(renderMarkdown("@acme/core", context, mentions)).toContain('data-ref="acme/core"'); }); test("mentions in code are left alone", () => { expect(renderMarkdown("`@oldname`", context, mentions)).toContain("@oldname"); }); });