Public: anyone can read this; the crew can change it.

A test pins the forge's own share card at /og/site.svg: an SVG with the security headers, and the forge's name escaped #7037

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7027-a-test-pins-the-forge-s-own-share-card-a → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/7027-a-test-pins-the-forge-s-own-share-card-a was merged into main as 71619fe.

Review

Sign in to review this change.
1 file changed +76 −0
test/og-site-svg.test.ts added +76
@@ -0,0 +1,76 @@
1 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { renderCard } from "../src/web/og/card.ts";
8
9 setDefaultTimeout(30_000);
10
11 describe("GET /og/site.svg", () => {
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-og-site-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 });
21 afterAll(async () => {
22 await servers.stop?.();
23 await rm(dataDir, { recursive: true, force: true });
24 });
25
26 test("answers an SVG with the security headers", async () => {
27 const res = await fetch(servers.url + "/og/site.svg");
28 expect(res.status).toBe(200);
29 expect(res.headers.get("content-type")).toStartWith("image/svg+xml");
30 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
31 expect(res.headers.get("content-security-policy")).toBeTruthy();
32 const body = await res.text();
33 expect(body).toStartWith("<svg");
34 expect(body.trimEnd()).toEndWith("</svg>");
35 expect(body).toContain("forge");
36 expect(body).not.toContain("<script");

This tag-balance regex fails on a > inside an attribute value. The checks on the svg start and end already cover most of what it does.

37 // Well-formed: every tag opens and closes (Bun has no DOMParser).
38 const stack: string[] = [];
39 for (const m of body.matchAll(/<(\/?)([a-zA-Z][\w:-]*)[^>]*?(\/?)>/g)) {
40 if (m[3]) continue;
41 if (m[1]) expect(stack.pop()).toBe(m[2]!);
42 else stack.push(m[2]!);
43 }
44 expect(stack).toEqual([]);
45 });
46
47 test("text drawn on a card is escaped, never markup", () => {
48 // The site card is fixed text; the same renderer draws every card, so
49 // hostile input to it is what a forge name would be.
50 const svg = renderCard({
51 kind: "page",
52 title: "<script>alert(1)</script> & co",
53 lines: ["a & b <img onerror=x>"],
54 });
55 expect(svg).not.toContain("<script");
56 expect(svg).not.toContain("<img");
57 expect(svg).toContain("&lt;script&gt;");

This tests renderCard, not the route. Rename it to say so, so it is not read as pinning /og/site.svg escaping.

58 expect(svg).toContain("&amp;");
59 });
60
61 test("a second request is cacheable and revalidates to a 304", async () => {
62 const first = await fetch(servers.url + "/og/site.svg");
63 await first.text();
64 const cc = first.headers.get("cache-control") ?? "";
65 expect(cc).toContain("public");
66 expect(cc).toMatch(/max-age=\d+/);
67 const etag = first.headers.get("etag");
68 expect(etag).toBeTruthy();
69 const second = await fetch(servers.url + "/og/site.svg", {
70 headers: { "if-none-match": etag! },
71 });
72 expect(second.status).toBe(304);
73 expect(second.headers.get("etag")).toBe(etag);
74 expect(second.headers.get("x-content-type-options")).toBe("nosniff");
75 });
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs