Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

241 lines · 13.7 KBRaw History
1import { describe, expect, it } from "vitest";
2import { cacheTtl, camoHeaders, camoKey, camoKeys, camoPath, checkImageUrl, fetchCamoImage, hmacSha256, imageProxy, MAX_CAMO_URL, verifyCamo } from "../../src/lib/camo.js";
3import { renderMarkdown } from "../../src/web/markdown.js";
4import { b64url } from "../../src/lib/util.js";
5
6const env = { PUBLIC_URL: "https://diffinery.com", MASTER_KEY: "bWFzdGVyLWtleS1mb3ItdGVzdHMtb25seS0zMi1ieXRlcw==", ACCESS_AUD: "" };
7const devEnv = { PUBLIC_URL: "http://127.0.0.1:8787", MASTER_KEY: undefined, ACCESS_AUD: "" };
8const key = camoKey(env)!;
9
10const imgSrcs = (html: string) => [...html.matchAll(/<img src="([^"]*)"/g)].map((m) => m[1]!.replace(/&amp;/g, "&"));
11const render = (md: string, e: typeof env | undefined = env, base?: { owner: string; repo: string; ref: string; dir: string }) => renderMarkdown(md, { base, image: imageProxy(e) });
12
13describe("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
70describe("Markdown images", () => {
71 it("sends images from other sites through the proxy and keeps the link target", () => {
72 const html = render("[![build](https://ci.example.com/badge.svg?branch=main)](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("![a](https://diffinery.com/acme/site/raw/main/logo.png) ![b](docs/shot.png) ![c](/static/x.png)", 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("![d](/static/x.png)"))).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("![x](http://img.example.com/a.png)"));
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(`![x](<${u}>)`));
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(`![x](<${u}>)`), u).not.toContain("<img");
101 });
102
103 it("shows the alt text for images it won't proxy", () => {
104 expect(render("![inside](https://10.0.0.1/a.png)")).not.toContain("<img");
105 expect(render("![self](https://api.diffinery.com/a.png)")).not.toContain("<img");
106 expect(render(`![long](https://img.example.com/${"a".repeat(MAX_CAMO_URL)}.png)`)).not.toContain("<img");
107 // No settings to sign with (rendered outside a request): no third-party image at all.
108 const html = renderMarkdown("![x](https://img.example.com/a.png) ![y](/acme/site/raw/main/a.png)", { 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("![x](https://img.example.com/a.png)"))).toEqual(["https://img.example.com/a.png"]);
115 });
116});
117
118describe("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});