60 lines · 2.8 KB
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 <img> and protocol-relative sources are proxied too", () => {
    const html = renderMarkdown('<img src="//tracker.example/p.png"><img src="http://a.example/b.jpg">', context);
    expect(camoTargets(html)).toEqual(["https://tracker.example/p.png", "http://a.example/b.jpg"]);
  });

  test("relative, attachment and data images stay same-origin", () => {
    const html = renderMarkdown(
      '![a](./img.png) ![b](/attachments/abc) <img src="data:image/png;base64,AAAA">',
      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('<a id="__TANSTACK_ROUTER_STATE__" name="submit">x</a><h2 id="top">T</h2>', context);