| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { cacheTtl, camoHeaders, camoKey, camoKeys, camoPath, checkImageUrl, fetchCamoImage, hmacSha256, imageProxy, MAX_CAMO_URL, verifyCamo } from "../../src/lib/camo.js"; |
| 3 | import { renderMarkdown } from "../../src/web/markdown.js"; |
| 4 | import { b64url } from "../../src/lib/util.js"; |
| 5 | |
| 6 | const env = { PUBLIC_URL: "https://diffinery.com", MASTER_KEY: "bWFzdGVyLWtleS1mb3ItdGVzdHMtb25seS0zMi1ieXRlcw==", ACCESS_AUD: "" }; |
| 7 | const devEnv = { PUBLIC_URL: "http://127.0.0.1:8787", MASTER_KEY: undefined, ACCESS_AUD: "" }; |
| 8 | const key = camoKey(env)!; |
| 9 | |
| 10 | const imgSrcs = (html: string) => [...html.matchAll(/<img src="([^"]*)"/g)].map((m) => m[1]!.replace(/&/g, "&")); |
| 11 | const render = (md: string, e: typeof env | undefined = env, base?: { owner: string; repo: string; ref: string; dir: string }) => renderMarkdown(md, { base, image: imageProxy(e) }); |
| 12 | |
| 13 | describe("camo signing", () => { |
| 14 | it("matches WebCrypto's HMAC-SHA256, for short and long keys", async () => { |
| 15 | for (const k of [new TextEncoder().encode("k"), key, new Uint8Array(200).fill(7)]) { |
| 16 | const msg = new TextEncoder().encode("https://example.com/a.png?x=1"); |
| 17 | const ck = await crypto.subtle.importKey("raw", k as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 18 | const expected = new Uint8Array(await crypto.subtle.sign("HMAC", ck, msg as BufferSource)); |
| 19 | expect(hmacSha256(k, msg)).toEqual(expected); |
| 20 | } |
| 21 | }); |
| 22 | |
| 23 | it("verifies its own paths and gives back the URL", () => { |
| 24 | const url = "https://img.example.com/badge.svg?label=build&x=ü"; |
| 25 | const [, , , sig, enc] = camoPath(key, url).split("/"); |
| 26 | expect(camoPath(key, url).startsWith("/-/camo/")).toBe(true); |
| 27 | expect(verifyCamo(key, sig!, enc!)).toBe(url); |
| 28 | }); |
| 29 | |
| 30 | it("keeps verifying paths signed with the previous key while MASTER_KEY rotates", () => { |
| 31 | const url = "https://img.example.com/a.png"; |
| 32 | const [, , , oldSig, enc] = camoPath(key, url).split("/"); |
| 33 | const rotated = { ...env, MASTER_KEY: "bmV3LW1hc3Rlci1rZXktZm9yLXRlc3RzLW9ubHktMzI=", MASTER_KEY_PREV: env.MASTER_KEY }; |
| 34 | const keys = camoKeys(rotated); |
| 35 | expect(keys).toHaveLength(2); |
| 36 | // New paths are signed with the new key; old ones still verify. |
| 37 | expect(camoKey(rotated)).toEqual(keys[0]); |
| 38 | expect(camoPath(keys[0]!, url)).not.toBe(camoPath(key, url)); |
| 39 | expect(verifyCamo(keys, oldSig!, enc!)).toBe(url); |
| 40 | const [, , , newSig] = camoPath(keys[0]!, url).split("/"); |
| 41 | expect(verifyCamo(keys, newSig!, enc!)).toBe(url); |
| 42 | // Once MASTER_KEY_PREV is removed, the old signature is refused. |
| 43 | expect(verifyCamo(camoKeys({ ...rotated, MASTER_KEY_PREV: undefined }), oldSig!, enc!)).toBeNull(); |
| 44 | // MASTER_KEY_PREV alone signs nothing. |
| 45 | expect(camoKeys({ ...env, MASTER_KEY: undefined, MASTER_KEY_PREV: env.MASTER_KEY })).toHaveLength(0); |
| 46 | }); |
| 47 | |
| 48 | it("refuses unsigned, tampered, re-signed and malformed paths", () => { |
| 49 | const [, , , sig, enc] = camoPath(key, "https://img.example.com/a.png").split("/"); |
| 50 | const other = b64url(new TextEncoder().encode("https://evil.example/a.png")); |
| 51 | expect(verifyCamo(key, sig!, other)).toBeNull(); |
| 52 | expect(verifyCamo(key, `${sig!.slice(0, -1)}${sig!.endsWith("A") ? "B" : "A"}`, enc!)).toBeNull(); |
| 53 | expect(verifyCamo(key, "", enc!)).toBeNull(); |
| 54 | expect(verifyCamo(key, "x".repeat(43), enc!)).toBeNull(); |
| 55 | expect(verifyCamo(key, `${sig}=`, enc!)).toBeNull(); |
| 56 | expect(verifyCamo(key, sig!, `${enc}%2F`)).toBeNull(); |
| 57 | // A key from another MASTER_KEY, or the same secret under another label, doesn't verify. |
| 58 | const otherKey = camoKey({ ...env, MASTER_KEY: "b3RoZXI=" })!; |
| 59 | expect(verifyCamo(otherKey, sig!, enc!)).toBeNull(); |
| 60 | expect(verifyCamo(new TextEncoder().encode(`diffinery-unsubscribe-v1:${env.MASTER_KEY}`), sig!, enc!)).toBeNull(); |
| 61 | }); |
| 62 | |
| 63 | it("has no key without MASTER_KEY, except in local development", () => { |
| 64 | expect(camoKey({ ...env, MASTER_KEY: undefined })).toBeNull(); |
| 65 | expect(camoKey(devEnv)).not.toBeNull(); |
| 66 | expect(camoKey({ ...devEnv, ACCESS_AUD: "aud" })).toBeNull(); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | describe("Markdown images", () => { |
| 71 | it("sends images from other sites through the proxy and keeps the link target", () => { |
| 72 | const html = render("[](https://ci.example.com/run/1)"); |
| 73 | const [src] = imgSrcs(html); |
| 74 | expect(src).toMatch(/^\/-\/camo\/[\w-]{43}\/[\w-]+$/); |
| 75 | const [, , , sig, enc] = src!.split("/"); |
| 76 | expect(verifyCamo(key, sig!, enc!)).toBe("https://ci.example.com/badge.svg?branch=main"); |
| 77 | expect(html).toContain('href="https://ci.example.com/run/1"'); |
| 78 | expect(html).not.toContain('src="https://ci.example.com'); |
| 79 | }); |
| 80 | |
| 81 | it("keeps same-origin and repository images as they are", () => { |
| 82 | const html = render("  ", env, { owner: "acme", repo: "site", ref: "main", dir: "" }); |
| 83 | expect(imgSrcs(html)).toEqual(["https://diffinery.com/acme/site/raw/main/logo.png", "/acme/site/raw/main/docs/shot.png", "/acme/site/raw/main/static/x.png"]); |
| 84 | expect(imgSrcs(render(""))).toEqual(["/static/x.png"]); |
| 85 | }); |
| 86 | |
| 87 | it("upgrades http images to https, as browsers do on an https page", () => { |
| 88 | const [src] = imgSrcs(render("")); |
| 89 | const [, , , sig, enc] = src!.split("/"); |
| 90 | expect(verifyCamo(key, sig!, enc!)).toBe("https://img.example.com/a.png"); |
| 91 | }); |
| 92 | |
| 93 | it("can't be talked past the proxy with whitespace, case or backslashes", () => { |
| 94 | for (const u of ["HTTPS://img.example.com/a.png", " https://img.example.com/a.png", "https://img.example.com\t/a.png", "ht\ttps://img.example.com/a.png", "https://IMG.example.com./a.png"]) { |
| 95 | const srcs = imgSrcs(render(``)); |
| 96 | expect(srcs, u).toHaveLength(1); |
| 97 | expect(srcs[0]!.startsWith("/-/camo/"), u).toBe(true); |
| 98 | } |
| 99 | // Scheme-relative and backslash forms are dropped by the renderer before they get here. |
| 100 | for (const u of ["//img.example.com/a.png", "https:\\\\img.example.com/a.png", "/\\img.example.com/a.png"]) expect(render(``), u).not.toContain("<img"); |
| 101 | }); |
| 102 | |
| 103 | it("shows the alt text for images it won't proxy", () => { |
| 104 | expect(render("")).not.toContain("<img"); |
| 105 | expect(render("")).not.toContain("<img"); |
| 106 | expect(render(`}.png)`)).not.toContain("<img"); |
| 107 | // No settings to sign with (rendered outside a request): no third-party image at all. |
| 108 | const html = renderMarkdown(" ", { image: imageProxy(undefined) }); |
| 109 | expect(imgSrcs(html)).toEqual(["/acme/site/raw/main/a.png"]); |
| 110 | expect(html).toContain("x"); |
| 111 | }); |
| 112 | |
| 113 | it("leaves renderMarkdown unchanged without the option (email and other callers)", () => { |
| 114 | expect(imgSrcs(renderMarkdown(""))).toEqual(["https://img.example.com/a.png"]); |
| 115 | }); |
| 116 | }); |
| 117 | |
| 118 | describe("camo fetch rules", () => { |
| 119 | type Call = { url: string; init: RequestInit }; |
| 120 | function fake(responses: Array<Response | (() => Response)>) { |
| 121 | const calls: Call[] = []; |
| 122 | const f = (async (url: string, init: RequestInit) => { |
| 123 | calls.push({ url, init }); |
| 124 | const r = responses.shift(); |
| 125 | if (!r) throw new Error("no more responses"); |
| 126 | return typeof r === "function" ? r() : r; |
| 127 | }) as typeof fetch; |
| 128 | return { f, calls }; |
| 129 | } |
| 130 | const png = (body: Uint8Array | string = "PNGDATA", headers: Record<string, string> = {}) => new Response(body, { headers: { "content-type": "image/png", ...headers } }); |
| 131 | |
| 132 | it("fetches an image with no cookies or credentials, only its own headers", async () => { |
| 133 | const { f, calls } = fake([png("abc", { "cache-control": "max-age=600", etag: '"e1"' })]); |
| 134 | const img = await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f }); |
| 135 | expect(img).not.toBeNull(); |
| 136 | expect(new TextDecoder().decode(img!.body)).toBe("abc"); |
| 137 | expect(img!.type).toBe("image/png"); |
| 138 | expect(img!.ttl).toBe(600); |
| 139 | const h = new Headers(calls[0]!.init.headers); |
| 140 | expect([...h.keys()].sort()).toEqual(["accept", "user-agent"]); |
| 141 | expect(calls[0]!.init.redirect).toBe("manual"); |
| 142 | expect(calls[0]!.init.signal).toBeDefined(); |
| 143 | }); |
| 144 | |
| 145 | it("refuses private, internal and Diffinery's own hosts, and plain http", async () => { |
| 146 | for (const u of ["https://127.0.0.1/a.png", "https://10.1.2.3/a.png", "https://[::1]/a.png", "https://169.254.169.254/latest", "https://localhost/a.png", "https://db.internal/a.png", "https://diffinery.com/a.png", "https://x.diffinery.com/a.png", "http://img.example.com/a.png", "https://user:pw@img.example.com/a.png"]) { |
| 147 | const { f, calls } = fake([png()]); |
| 148 | expect(await fetchCamoImage(u, { env, fetch: f }), u).toBeNull(); |
| 149 | expect(calls).toEqual([]); |
| 150 | } |
| 151 | }); |
| 152 | |
| 153 | it("follows a few redirects, checking every hop", async () => { |
| 154 | const redirect = (to: string) => new Response(null, { status: 302, headers: { location: to } }); |
| 155 | let { f, calls } = fake([redirect("https://cdn.example.com/b.png"), redirect("/c.png"), png()]); |
| 156 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).not.toBeNull(); |
| 157 | expect(calls.map((c) => c.url)).toEqual(["https://img.example.com/a.png", "https://cdn.example.com/b.png", "https://cdn.example.com/c.png"]); |
| 158 | |
| 159 | ({ f, calls } = fake([redirect("https://192.168.1.1/a.png"), png()])); |
| 160 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); |
| 161 | expect(calls).toHaveLength(1); |
| 162 | |
| 163 | ({ f, calls } = fake([redirect("http://img.example.com/a.png"), png()])); |
| 164 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); |
| 165 | |
| 166 | ({ f, calls } = fake(Array.from({ length: 10 }, () => () => redirect("https://img.example.com/loop.png")))); |
| 167 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); |
| 168 | expect(calls).toHaveLength(5); |
| 169 | }); |
| 170 | |
| 171 | it("serves only image types it knows", async () => { |
| 172 | for (const t of ["text/html", "application/javascript", "image/x-icon", "", "text/xml", "application/octet-stream"]) { |
| 173 | const { f } = fake([new Response("<script>alert(1)</script>", { headers: t ? { "content-type": t } : {} })]); |
| 174 | expect(await fetchCamoImage("https://img.example.com/a", { env, fetch: f }), t).toBeNull(); |
| 175 | } |
| 176 | for (const [t, want] of [["image/svg+xml; charset=utf-8", "image/svg+xml"], ["IMAGE/JPEG", "image/jpeg"], ["image/jpg", "image/jpeg"], ["image/webp", "image/webp"], ["image/avif", "image/avif"], ["image/gif", "image/gif"]]) { |
| 177 | const { f } = fake([new Response("x", { headers: { "content-type": t! } })]); |
| 178 | expect((await fetchCamoImage("https://img.example.com/a", { env, fetch: f }))?.type).toBe(want); |
| 179 | } |
| 180 | }); |
| 181 | |
| 182 | it("refuses error statuses and network failures", async () => { |
| 183 | for (const r of [new Response("no", { status: 404, headers: { "content-type": "image/png" } }), new Response("x", { status: 206, headers: { "content-type": "image/png" } })]) { |
| 184 | const { f } = fake([r]); |
| 185 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); |
| 186 | } |
| 187 | const { f } = fake([]); |
| 188 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); |
| 189 | }); |
| 190 | |
| 191 | it("caps the size while reading, whatever Content-Length says", async () => { |
| 192 | const big = new Uint8Array(2048); |
| 193 | let { f } = fake([png(big)]); |
| 194 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 })).toBeNull(); |
| 195 | // A stream with no length, or a lying one, is cut off once it passes the cap. |
| 196 | let pulled = 0; |
| 197 | const endless = () => |
| 198 | new Response( |
| 199 | new ReadableStream<Uint8Array>({ |
| 200 | pull(ctl) { |
| 201 | pulled++; |
| 202 | ctl.enqueue(new Uint8Array(512)); |
| 203 | }, |
| 204 | }), |
| 205 | { headers: { "content-type": "image/png", "content-length": "10" } }, |
| 206 | ); |
| 207 | ({ f } = fake([endless])); |
| 208 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 })).toBeNull(); |
| 209 | expect(pulled).toBeLessThan(10); |
| 210 | ({ f } = fake([png(new Uint8Array(1024))])); |
| 211 | expect((await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 }))?.body.byteLength).toBe(1024); |
| 212 | }); |
| 213 | |
| 214 | it("gives up after the timeout", async () => { |
| 215 | const f = ((_u: string, init: RequestInit) => |
| 216 | new Promise<Response>((_, reject) => init.signal!.addEventListener("abort", () => reject(init.signal!.reason)))) as typeof fetch; |
| 217 | const t = Date.now(); |
| 218 | expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, timeoutMs: 50 })).toBeNull(); |
| 219 | expect(Date.now() - t).toBeLessThan(2000); |
| 220 | }); |
| 221 | |
| 222 | it("allows loopback and http only in local development, never the Worker itself", () => { |
| 223 | expect(checkImageUrl("http://127.0.0.1:9999/a.png", devEnv)?.href).toBe("http://127.0.0.1:9999/a.png"); |
| 224 | expect(checkImageUrl("http://127.0.0.1:8787/a.png", devEnv)).toBeNull(); |
| 225 | expect(checkImageUrl("http://127.0.0.1:9999/a.png", env)).toBeNull(); |
| 226 | }); |
| 227 | |
| 228 | it("caches within bounds and serves SVG so it can't run scripts", () => { |
| 229 | expect(cacheTtl(null)).toBe(86400); |
| 230 | expect(cacheTtl("max-age=10")).toBe(300); |
| 231 | expect(cacheTtl("public, max-age=3600")).toBe(3600); |
| 232 | expect(cacheTtl("max-age=99999999")).toBe(7 * 86400); |
| 233 | expect(cacheTtl("no-cache")).toBe(300); |
| 234 | const h = camoHeaders({ type: "image/svg+xml", ttl: 600, etag: null, lastModified: null, body: new Uint8Array(3) }); |
| 235 | expect(h.get("content-security-policy")).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox"); |
| 236 | expect(h.get("x-content-type-options")).toBe("nosniff"); |
| 237 | expect(h.get("cache-control")).toBe("public, max-age=600"); |
| 238 | expect(h.get("content-length")).toBe("3"); |
| 239 | expect(h.has("set-cookie")).toBe(false); |
| 240 | }); |
| 241 | }); |