Images from other sites in Markdown load through Diffinery's image proxy, and pages load images only from Diffinery (#207)
* Serve images from other sites in Markdown through Diffinery's image proxy * Load page images only from Diffinery: drop https: from the CSP's img-src * Test the image proxy end to end
- CI / Typecheck and bundleFailed
- CI / Unit tests (1/2)Failed
- CI / Unit tests (2/2)Failed
- End-to-end / End-to-end (agent reviews, tasks, Today)Failed
- End-to-end / End-to-end (git, PRs, Actions)Failed
- End-to-end / End-to-end (large pushes)Failed
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)Failed
- End-to-end / End-to-end (security)Failed
10 files changed+649−11
Files (10)
- apps/diffinery/docs/repositories/browsing-code.md+1−0
- apps/diffinery/docs/security/overview.md+16−1
- apps/diffinery/src/app.ts+8−2
- apps/diffinery/src/docs/web.tsx+5−3
- apps/diffinery/src/http/camo.ts+49−0
- apps/diffinery/src/lib/camo.ts+255−0
- apps/diffinery/src/web/markdown.ts+8−1
- apps/diffinery/src/web/ui.tsx+11−3
- apps/diffinery/test/e2e/security.test.mjs+73−1
- apps/diffinery/test/unit/camo.test.ts+223−0
modifiedapps/diffinery/docs/repositories/browsing-code.md+1−0View file
| @@ -129,6 +129,7 @@ | ||
| 129 | 129 | - Raw HTML is always escaped and shown as text, never rendered. `<details>`, `<img>`, `<br>` and similar tags appear literally. HTML comments (`<!-- ... -->`) are hidden, as on GitHub, except inside code. |
| 130 | 130 | - Links and images may use `http://`, `https://`, a `#fragment`, or a relative path. Other schemes (`javascript:`, `data:`, `mailto:` and so on) are dropped. |
| 131 | 131 | - Relative links resolve against the document's folder at the same ref: links open the file or folder page, images load from the raw URL. Links that climb above the repository root are dropped. |
| 132 | +- Images from other sites are shown through Diffinery's image proxy, so the other site can't see who views the page. See [Images from other sites](/docs/security/overview#images-from-other-sites). | |
| 132 | 133 | - Documents larger than 512 KB are shown as plain text. |
| 133 | 134 | |
| 134 | 135 | ## Edit files in the browser |
modifiedapps/diffinery/docs/security/overview.md+16−1View file
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | | Browser sessions | Random session cookie, stored hashed, `HttpOnly`, `Secure`, `SameSite=Lax`, 30 days sliding | |
| 14 | 14 | | Cross-site requests | Double-submit CSRF token on every form, plus refusal of cross-site state-changing requests | |
| 15 | 15 | | Headers | HSTS, nonce-based Content Security Policy, `X-Frame-Options: DENY`, `nosniff` | |
| 16 | +| Images in Markdown | Images from other sites are served through Diffinery's own image proxy, so those sites never see who views a page | | |
| 16 | 17 | | Anonymous access | Read-only: anonymous callers can read public repositories but every change gets `401` | |
| 17 | 18 | | Machines and agents | Tokens (`dfy_...`), stored hashed, with scopes, repository restrictions and expiry; `read` tokens never write | |
| 18 | 19 | | Agents | Accounts owned by a person or organization, with server-enforced limits | |
| @@ -103,12 +104,26 @@ | ||
| 103 | 104 | |
| 104 | 105 | ```text |
| 105 | 106 | default-src 'self'; script-src 'nonce-...'; style-src 'self' 'unsafe-inline'; |
| 106 | -img-src 'self' https: data: blob:; connect-src 'self' wss://diffinery.com ws://diffinery.com; | |
| 107 | +img-src 'self' data: blob:; connect-src 'self' wss://diffinery.com ws://diffinery.com; | |
| 107 | 108 | font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none' |
| 108 | 109 | ``` |
| 109 | 110 | |
| 110 | 111 | Only scripts that carry the page's nonce run, so injected markup cannot execute scripts. Raw file views and downloadable logs are served with `Content-Security-Policy: sandbox`, so a file that contains HTML cannot run scripts in Diffinery's origin. |
| 111 | 112 | |
| 113 | +Pages load images only from Diffinery itself. Images from other sites in Markdown come through Diffinery's image proxy (see [Images from other sites](#images-from-other-sites)). | |
| 114 | + | |
| 115 | +## Images from other sites | |
| 116 | + | |
| 117 | +An image in Markdown (an issue, pull request, comment, review, README, Markdown file, release notes, job summary or agent task) that points at another site would tell that site the IP address, browser and time of everyone who views the page, even in a private repository. So Diffinery fetches those images itself and serves them from its own address, as GitHub does: | |
| 118 | + | |
| 119 | +- The page shows the image from `/-/camo/<signature>/<encoded URL>`. Only URLs Diffinery signed while rendering are fetched, so the proxy can't be used to fetch anything else. | |
| 120 | +- Diffinery fetches the image over HTTPS without your cookies, IP address or browser details. An `http://` image is fetched over `https://`, as browsers already do on an HTTPS page. | |
| 121 | +- Only public hosts are fetched: never Diffinery itself, `localhost` or private, link-local or internal addresses, also after a redirect (at most 4 are followed). | |
| 122 | +- The answer must be a PNG, JPEG, GIF, WebP, AVIF or SVG image of at most 10 MB, within 10 seconds. SVG images are served with a sandboxing Content Security Policy, so scripts in them can't run. | |
| 123 | +- Images are cached for as long as the other site allows (between 5 minutes and 7 days; a day if it doesn't say), so the other site sees only an occasional request from Diffinery. An image that can't be fetched shows as broken. | |
| 124 | + | |
| 125 | +Links in Markdown still go to their original addresses, and images from the repository itself (relative paths) or from Diffinery load directly. | |
| 126 | + | |
| 112 | 127 | ## Tokens and scopes |
| 113 | 128 | |
| 114 | 129 | Everything that is not a browser (git, the [CLI](/docs/reference/cli), scripts, MCP clients, agents) authenticates with a token in the `Authorization` header (`Bearer`, `token` or HTTP Basic with the token as the password). |
modifiedapps/diffinery/src/app.ts+8−2View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | +import { contextStorage } from "hono/context-storage"; | |
| 2 | 3 | import { getCookie } from "hono/cookie"; |
| 3 | 4 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 4 | 5 | import type { Env } from "./env.js"; |
| @@ -18,6 +19,7 @@ | ||
| 18 | 19 | import { actionsRuntime } from "./http/actions-runtime.js"; |
| 19 | 20 | import { api, wellKnown } from "./http/api.js"; |
| 20 | 21 | import { avatars } from "./http/avatars.js"; |
| 22 | +import { camo } from "./http/camo.js"; | |
| 21 | 23 | import { mailApi } from "./mail/api.js"; |
| 22 | 24 | import { files } from "./http/files.js"; |
| 23 | 25 | import { webhookPayload } from "./lib/webhook-payload.js"; |
| @@ -52,6 +54,9 @@ | ||
| 52 | 54 | // The model proxy for agent tasks: its own task-scoped token, not an account credential (agentic/proxy.ts). |
| 53 | 55 | app.all("/-/model/:provider/*", (c) => modelProxy(c)); |
| 54 | 56 | |
| 57 | +// The request's context, for code that renders without it in hand (the Markdown component signs image proxy URLs with its settings). | |
| 58 | +app.use("*", contextStorage()); | |
| 59 | + | |
| 55 | 60 | // Security headers on every response, then identity: tokens, Access JWT, or anonymous. |
| 56 | 61 | app.use("*", async (c, next) => { |
| 57 | 62 | // Browser sessions ride on cookies, so refuse state-changing requests that come from another site. |
| @@ -76,8 +81,8 @@ | ||
| 76 | 81 | c.set("dir", dir); |
| 77 | 82 | // The Actions runtime services authenticate with their own signed tokens and URLs (http/actions-runtime.ts). |
| 78 | 83 | const runtime = c.req.path.startsWith("/twirp/") || c.req.path.startsWith("/_runtime/"); |
| 79 | - // Avatar images are public and on every page many times over: skip the session lookup for them. | |
| 80 | - const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/"); | |
| 84 | + // Avatar and proxied Markdown images are public and on every page many times over: skip the session lookup for them. | |
| 85 | + const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && (c.req.path.startsWith("/api/avatars/") || c.req.path.startsWith("/-/camo/")); | |
| 81 | 86 | c.set("principal", runtime || avatar || oauthCall ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir)); |
| 82 | 87 | const limited = await overLimit(c.env, bucketsFor(c.req.method, c.req.path, c.get("principal"), c.req.header("cf-connecting-ip") ?? "unknown")); |
| 83 | 88 | if (limited) c.res = tooMany(c, limited); |
| @@ -101,6 +106,7 @@ | ||
| 101 | 106 | app.get("/healthz", (c) => c.json({ ok: true })); |
| 102 | 107 | |
| 103 | 108 | app.route("/", avatars); |
| 109 | +app.route("/", camo); | |
| 104 | 110 | app.route("/", cliLogin); |
| 105 | 111 | app.route("/", oauthApi); |
| 106 | 112 | app.route("/", wellKnown); |
modifiedapps/diffinery/src/docs/web.tsx+5−3View file
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | import { raw } from "hono/html"; |
| 10 | 10 | import type { AppContext } from "../auth/principal.js"; |
| 11 | +import type { Env } from "../env.js"; | |
| 12 | +import { imageProxy } from "../lib/camo.js"; | |
| 11 | 13 | import { fenceLangs, renderMarkdown } from "../web/markdown.js"; |
| 12 | 14 | import { fenceHighlighter } from "../web/highlight.js"; |
| 13 | 15 | import { ICONS } from "../web/assets.js"; |
| @@ -22,11 +24,11 @@ | ||
| 22 | 24 | const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" }; |
| 23 | 25 | |
| 24 | 26 | const htmlCache = new Map<string, string>(); |
| 25 | -async function pageHtml(p: Page, origin: string): Promise<string> { | |
| 27 | +async function pageHtml(p: Page, origin: string, env: Env): Promise<string> { | |
| 26 | 28 | const key = `${origin} ${p.slug}`; |
| 27 | 29 | let html = htmlCache.get(key); |
| 28 | 30 | if (html === undefined) { |
| 29 | - html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)) }); | |
| 31 | + html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)), image: imageProxy(env) }); | |
| 30 | 32 | htmlCache.set(key, html); |
| 31 | 33 | } |
| 32 | 34 | return html; |
| @@ -78,7 +80,7 @@ | ||
| 78 | 80 | |
| 79 | 81 | async function docsPage(c: AppContext, p: Page | null, status = 200): Promise<Response> { |
| 80 | 82 | const o = origin(c); |
| 81 | - const html = p ? await pageHtml(p, o) : ""; | |
| 83 | + const html = p ? await pageHtml(p, o, c.env) : ""; | |
| 82 | 84 | const slug = p?.slug ?? ""; |
| 83 | 85 | const i = SLUGS.indexOf(slug); |
| 84 | 86 | const prev = i > 0 ? getPage(SLUGS[i - 1]!, o) : null; |
addedapps/diffinery/src/http/camo.ts+49−0View file
| @@ -1,0 +1,49 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { camoHeaders, camoKey, fetchCamoImage, verifyCamo } from "../lib/camo.js"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * GET /-/camo/<signature>/<url, base64url>: an image from another site, fetched by Diffinery so the | |
| 8 | + * viewer's browser never contacts that site (lib/camo.ts). Unsigned, tampered or unfetchable URLs | |
| 9 | + * get a quick 404. Answers are kept in Cloudflare's cache, so repeated views don't fetch again; | |
| 10 | + * failures are kept briefly too, so a dead image doesn't wait on its host for every viewer. | |
| 11 | + */ | |
| 12 | +export const camo = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 13 | + | |
| 14 | +/** Seconds a failed fetch is remembered. */ | |
| 15 | +const FAILURE_TTL = 120; | |
| 16 | + | |
| 17 | +const notFound = (cacheable: boolean) => | |
| 18 | + new Response("image not available", { | |
| 19 | + status: 404, | |
| 20 | + headers: { | |
| 21 | + "content-type": "text/plain; charset=utf-8", | |
| 22 | + "cache-control": cacheable ? `public, max-age=${FAILURE_TTL}` : "no-store", | |
| 23 | + "cross-origin-resource-policy": "same-origin", | |
| 24 | + }, | |
| 25 | + }); | |
| 26 | + | |
| 27 | +camo.get("/-/camo/:sig/:url", async (c) => { | |
| 28 | + const key = camoKey(c.env); | |
| 29 | + const url = key ? verifyCamo(key, c.req.param("sig"), c.req.param("url")) : null; | |
| 30 | + if (!url) return notFound(false); | |
| 31 | + | |
| 32 | + const cache = typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default; | |
| 33 | + // Keyed by our own URL (no query, no headers), so the viewer's cookies never reach the cache key. | |
| 34 | + const cacheKey = new Request(new URL(c.req.path, c.req.url).href, { method: "GET" }); | |
| 35 | + const hit = await cache?.match(cacheKey).catch(() => undefined); | |
| 36 | + if (hit) return new Response(hit.body, hit); | |
| 37 | + | |
| 38 | + const img = await fetchCamoImage(url, { env: c.env }); | |
| 39 | + const res = img ? new Response(img.body, { status: 200, headers: camoHeaders(img) }) : notFound(true); | |
| 40 | + if (cache) { | |
| 41 | + const put = cache.put(cacheKey, res.clone()).catch((e) => console.error("camo cache put failed", e)); | |
| 42 | + try { | |
| 43 | + c.executionCtx.waitUntil(put); | |
| 44 | + } catch { | |
| 45 | + await put; | |
| 46 | + } | |
| 47 | + } | |
| 48 | + return res; | |
| 49 | +}); |
addedapps/diffinery/src/lib/camo.ts+255−0View file
| @@ -1,0 +1,255 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { localOnly } from "../auth/dev.js"; | |
| 3 | +import { checkPublicUrl } from "../imports/options.js"; | |
| 4 | +import { Sha256 } from "./sha256.js"; | |
| 5 | +import { b64url, fromB64url, safeEqual } from "./util.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Diffinery's image proxy for Markdown, like GitHub's camo. An image in an issue, comment, | |
| 9 | + * README or release from another site would otherwise tell that site the IP address, browser | |
| 10 | + * and viewing time of everyone who reads it, private repositories included. The renderer | |
| 11 | + * rewrites such images to | |
| 12 | + * | |
| 13 | + * /-/camo/<signature>/<url, base64url> | |
| 14 | + * | |
| 15 | + * and the Worker fetches them (http/camo.ts), so viewers' browsers only talk to Diffinery. | |
| 16 | + * Only URLs Diffinery signed are fetched, so the route isn't an open proxy. | |
| 17 | + */ | |
| 18 | + | |
| 19 | +type KeyEnv = Pick<Env, "MASTER_KEY" | "ACCESS_AUD" | "PUBLIC_URL">; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * The signing key, from MASTER_KEY under its own label. Kept in this one function so it can move | |
| 23 | + * to a shared derivation helper (and accept a previous key while MASTER_KEY rotates) in one place. | |
| 24 | + */ | |
| 25 | +export function camoKey(env: KeyEnv): Uint8Array | null { | |
| 26 | + // Local development has no MASTER_KEY unless one is passed; production always does. | |
| 27 | + const secret = env.MASTER_KEY ?? (localOnly(env) ? "diffinery-dev-camo" : null); | |
| 28 | + return secret ? new TextEncoder().encode(`diffinery-camo-v1:${secret}`) : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const hexBytes = (hex: string): Uint8Array => Uint8Array.from(hex.match(/../g)!, (h) => parseInt(h, 16)); | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * HMAC-SHA256, synchronous so the (synchronous) Markdown renderer can sign while it renders. | |
| 35 | + * Gives the same result as WebCrypto's HMAC with the same key (checked in the unit tests). | |
| 36 | + */ | |
| 37 | +export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array { | |
| 38 | + const k = key.length > 64 ? hexBytes(new Sha256().update(key).hex()) : key; | |
| 39 | + const ipad = new Uint8Array(64); | |
| 40 | + const opad = new Uint8Array(64); | |
| 41 | + ipad.set(k); | |
| 42 | + opad.set(k); | |
| 43 | + for (let i = 0; i < 64; i++) { | |
| 44 | + ipad[i]! ^= 0x36; | |
| 45 | + opad[i]! ^= 0x5c; | |
| 46 | + } | |
| 47 | + const inner = hexBytes(new Sha256().update(ipad).update(message).hex()); | |
| 48 | + return hexBytes(new Sha256().update(opad).update(inner).hex()); | |
| 49 | +} | |
| 50 | + | |
| 51 | +const sign = (key: Uint8Array, url: string): string => b64url(hmacSha256(key, new TextEncoder().encode(url))); | |
| 52 | + | |
| 53 | +/** Longest image URL the proxy takes; longer ones are shown as their alt text. */ | |
| 54 | +export const MAX_CAMO_URL = 2048; | |
| 55 | +export const CAMO_PREFIX = "/-/camo/"; | |
| 56 | + | |
| 57 | +/** The proxy path for `url` (absolute, already checked by the caller). */ | |
| 58 | +export function camoPath(key: Uint8Array, url: string): string { | |
| 59 | + return `${CAMO_PREFIX}${sign(key, url)}/${b64url(new TextEncoder().encode(url))}`; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** The URL a proxy path's two segments stand for, or null when the signature doesn't match. */ | |
| 63 | +export function verifyCamo(key: Uint8Array, sig: string, encoded: string): string | null { | |
| 64 | + if (sig.length > 64 || encoded.length > Math.ceil((MAX_CAMO_URL * 4) / 3) + 4) return null; | |
| 65 | + if (!/^[\w-]+$/.test(sig) || !/^[\w-]+$/.test(encoded)) return null; | |
| 66 | + let url: string; | |
| 67 | + try { | |
| 68 | + url = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(fromB64url(encoded)); | |
| 69 | + } catch { | |
| 70 | + return null; | |
| 71 | + } | |
| 72 | + return safeEqual(sig, sign(key, url)) ? url : null; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * Where a Markdown image's src should point, for the renderer's `image` option: same-origin and | |
| 77 | + * root-relative URLs (repository files, avatars, Diffinery's own assets) stay as written; images | |
| 78 | + * from anywhere else go through the proxy. Null means "show the alt text instead": no key to | |
| 79 | + * sign with, or a URL the proxy wouldn't fetch anyway. | |
| 80 | + * | |
| 81 | + * http:// images are signed as https://, as browsers already upgrade them on an https page. | |
| 82 | + */ | |
| 83 | +export function imageProxy(env: KeyEnv | undefined): (url: string) => string | null { | |
| 84 | + // Without a configuration (no request in progress) relative URLs still resolve to "here", a | |
| 85 | + // host that can never exist (RFC 2606), so a URL written for it can't load anything either. | |
| 86 | + const own = ownOrigin(env) ?? "https://diffinery.invalid"; | |
| 87 | + const key = env ? camoKey(env) : null; | |
| 88 | + const dev = !!env && localOnly(env); | |
| 89 | + return (src) => { | |
| 90 | + let u: URL; | |
| 91 | + try { | |
| 92 | + // The browser's own parser, so stray tabs or backslashes resolve here as they would there. | |
| 93 | + u = new URL(src, own); | |
| 94 | + } catch { | |
| 95 | + return null; | |
| 96 | + } | |
| 97 | + if (u.origin === own) return src; | |
| 98 | + if (!key || (u.protocol !== "https:" && u.protocol !== "http:")) return null; | |
| 99 | + if (u.protocol === "http:" && !dev) u.protocol = "https:"; | |
| 100 | + const href = u.href; | |
| 101 | + if (href.length > MAX_CAMO_URL || !checkImageUrl(href, env!)) return null; | |
| 102 | + return camoPath(key, href); | |
| 103 | + }; | |
| 104 | +} | |
| 105 | + | |
| 106 | +function ownOrigin(env: KeyEnv | undefined): string | null { | |
| 107 | + try { | |
| 108 | + return env?.PUBLIC_URL ? new URL(env.PUBLIC_URL).origin : null; | |
| 109 | + } catch { | |
| 110 | + return null; | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** | |
| 115 | + * Whether the proxy may fetch `raw`: https on a public host that isn't Diffinery's own. Local | |
| 116 | + * development (PUBLIC_URL on loopback) also allows http and loopback, for the end-to-end tests, | |
| 117 | + * but still not this Worker itself. | |
| 118 | + */ | |
| 119 | +export function checkImageUrl(raw: string, env: KeyEnv): URL | null { | |
| 120 | + const dev = localOnly(env); | |
| 121 | + try { | |
| 122 | + const own = new URL(env.PUBLIC_URL); | |
| 123 | + const u = checkPublicUrl(raw, dev, "image URL", dev ? undefined : own.hostname); | |
| 124 | + if (u.host === own.host) return null; | |
| 125 | + return u; | |
| 126 | + } catch { | |
| 127 | + return null; | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +// ---------------------------------------------------------------- fetching | |
| 132 | + | |
| 133 | +/** Content types the proxy serves; anything else is refused. */ | |
| 134 | +export const CAMO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/svg+xml"]); | |
| 135 | +const TYPE_ALIASES: Record<string, string> = { "image/jpg": "image/jpeg", "image/pjpeg": "image/jpeg" }; | |
| 136 | +export const MAX_CAMO_BYTES = 10 * 1024 * 1024; | |
| 137 | +export const MAX_CAMO_REDIRECTS = 4; | |
| 138 | +export const CAMO_TIMEOUT_MS = 10_000; | |
| 139 | +/** How long a proxied image is cached: the origin's max-age, within these bounds; a day when it gives none. */ | |
| 140 | +const MIN_TTL = 300; | |
| 141 | +const MAX_TTL = 7 * 86400; | |
| 142 | +const DEFAULT_TTL = 86400; | |
| 143 | + | |
| 144 | +export interface CamoImage { | |
| 145 | + body: Uint8Array; | |
| 146 | + type: string; | |
| 147 | + /** Seconds to cache it for. */ | |
| 148 | + ttl: number; | |
| 149 | + etag: string | null; | |
| 150 | + lastModified: string | null; | |
| 151 | +} | |
| 152 | + | |
| 153 | +export interface CamoFetchDeps { | |
| 154 | + env: KeyEnv; | |
| 155 | + fetch?: typeof fetch; | |
| 156 | + timeoutMs?: number; | |
| 157 | + maxBytes?: number; | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** | |
| 161 | + * Fetch an image for the proxy, or null if it can't or mustn't be served: a host that isn't | |
| 162 | + * public (checked again at every redirect), a status other than 200, a type that isn't an image | |
| 163 | + * we serve, more than `maxBytes` (counted while reading, whatever Content-Length says), or no | |
| 164 | + * answer within the timeout. Nothing of the viewer's (cookies, IP, user agent) is sent. | |
| 165 | + */ | |
| 166 | +export async function fetchCamoImage(url: string, deps: CamoFetchDeps): Promise<CamoImage | null> { | |
| 167 | + const doFetch = deps.fetch ?? fetch; | |
| 168 | + const maxBytes = deps.maxBytes ?? MAX_CAMO_BYTES; | |
| 169 | + const signal = AbortSignal.timeout(deps.timeoutMs ?? CAMO_TIMEOUT_MS); | |
| 170 | + let target = url; | |
| 171 | + try { | |
| 172 | + for (let hop = 0; hop <= MAX_CAMO_REDIRECTS; hop++) { | |
| 173 | + const u = checkImageUrl(target, deps.env); | |
| 174 | + if (!u) return null; | |
| 175 | + const res = await doFetch(u.href, { | |
| 176 | + method: "GET", | |
| 177 | + headers: { accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8", "user-agent": "Diffinery-Camo/1 (image proxy)" }, | |
| 178 | + redirect: "manual", | |
| 179 | + signal, | |
| 180 | + }); | |
| 181 | + if ([301, 302, 303, 307, 308].includes(res.status)) { | |
| 182 | + await res.body?.cancel(); | |
| 183 | + const loc = res.headers.get("location"); | |
| 184 | + if (!loc) return null; | |
| 185 | + target = new URL(loc, u).href; | |
| 186 | + continue; | |
| 187 | + } | |
| 188 | + if (res.status !== 200 || !res.body) { | |
| 189 | + await res.body?.cancel(); | |
| 190 | + return null; | |
| 191 | + } | |
| 192 | + const raw = (res.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); | |
| 193 | + const type = TYPE_ALIASES[raw] ?? raw; | |
| 194 | + const declared = Number(res.headers.get("content-length") ?? "0"); | |
| 195 | + if (!CAMO_TYPES.has(type) || declared > maxBytes) { | |
| 196 | + await res.body.cancel(); | |
| 197 | + return null; | |
| 198 | + } | |
| 199 | + const body = await readCapped(res.body, maxBytes); | |
| 200 | + if (!body) return null; | |
| 201 | + return { body, type, ttl: cacheTtl(res.headers.get("cache-control")), etag: res.headers.get("etag"), lastModified: res.headers.get("last-modified") }; | |
| 202 | + } | |
| 203 | + return null; | |
| 204 | + } catch { | |
| 205 | + return null; | |
| 206 | + } | |
| 207 | +} | |
| 208 | + | |
| 209 | +async function readCapped(stream: ReadableStream<Uint8Array>, max: number): Promise<Uint8Array | null> { | |
| 210 | + const reader = stream.getReader(); | |
| 211 | + const chunks: Uint8Array[] = []; | |
| 212 | + let total = 0; | |
| 213 | + for (;;) { | |
| 214 | + const { done, value } = await reader.read(); | |
| 215 | + if (done) break; | |
| 216 | + total += value.byteLength; | |
| 217 | + if (total > max) { | |
| 218 | + await reader.cancel(); | |
| 219 | + return null; | |
| 220 | + } | |
| 221 | + chunks.push(value); | |
| 222 | + } | |
| 223 | + const out = new Uint8Array(total); | |
| 224 | + let at = 0; | |
| 225 | + for (const c of chunks) { | |
| 226 | + out.set(c, at); | |
| 227 | + at += c.byteLength; | |
| 228 | + } | |
| 229 | + return out; | |
| 230 | +} | |
| 231 | + | |
| 232 | +/** Seconds to cache an image, from the origin's Cache-Control. */ | |
| 233 | +export function cacheTtl(cacheControl: string | null): number { | |
| 234 | + const cc = (cacheControl ?? "").toLowerCase(); | |
| 235 | + if (/\b(no-store|no-cache|private)\b/.test(cc)) return MIN_TTL; | |
| 236 | + const m = /\bs-maxage=(\d+)/.exec(cc) ?? /\bmax-age=(\d+)/.exec(cc); | |
| 237 | + if (!m) return DEFAULT_TTL; | |
| 238 | + return Math.min(MAX_TTL, Math.max(MIN_TTL, Number(m[1]))); | |
| 239 | +} | |
| 240 | + | |
| 241 | +/** Headers for a proxied image: an SVG can't run scripts, and nothing is sniffed. */ | |
| 242 | +export function camoHeaders(img: Pick<CamoImage, "type" | "ttl" | "etag" | "lastModified" | "body">): Headers { | |
| 243 | + const h = new Headers({ | |
| 244 | + "content-type": img.type, | |
| 245 | + "content-length": String(img.body.byteLength), | |
| 246 | + "cache-control": `public, max-age=${img.ttl}`, | |
| 247 | + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", | |
| 248 | + "x-content-type-options": "nosniff", | |
| 249 | + // Only Diffinery's own pages show these. | |
| 250 | + "cross-origin-resource-policy": "same-origin", | |
| 251 | + }); | |
| 252 | + if (img.etag) h.set("etag", img.etag); | |
| 253 | + if (img.lastModified) h.set("last-modified", img.lastModified); | |
| 254 | + return h; | |
| 255 | +} |
modifiedapps/diffinery/src/web/markdown.ts+8−1View file
| @@ -25,6 +25,12 @@ | ||
| 25 | 25 | highlight?: (code: string, lang: string) => string | null; |
| 26 | 26 | /** Prefix for heading ids and fragment links (default "h-", which keeps user content clear of the page's own ids). */ |
| 27 | 27 | idPrefix?: string; |
| 28 | + /** | |
| 29 | + * Where an image's src (absolute, or relative to this site) should point; null shows its alt | |
| 30 | + * text instead. The web pages send images from other sites through Diffinery's image proxy | |
| 31 | + * this way (lib/camo.ts). Links keep their original targets. | |
| 32 | + */ | |
| 33 | + image?: (src: string) => string | null; | |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | 36 | const MAX_SOURCE = 512 * 1024; |
| @@ -317,7 +323,8 @@ | ||
| 317 | 323 | if (ch === "!" && src[i + 1] === "[") { |
| 318 | 324 | const m = parseLink(src, i + 1); |
| 319 | 325 | if (m) { |
| 320 | - const url = safeUrl(m.url, "image", opts); | |
| 326 | + const safe = safeUrl(m.url, "image", opts); | |
| 327 | + const url = safe && opts.image ? opts.image(safe) : safe; | |
| 321 | 328 | const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, "")); |
| 322 | 329 | if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`; |
| 323 | 330 | else out += alt; |
modifiedapps/diffinery/src/web/ui.tsx+11−3View file
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import type { Hono } from "hono"; |
| 3 | 3 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 4 | +import { tryGetContext } from "hono/context-storage"; | |
| 4 | 5 | import { html, raw } from "hono/html"; |
| 5 | 6 | import type { Child, FC, PropsWithChildren } from "hono/jsx"; |
| 6 | 7 | import type { Account, Repo } from "../lib/directory.js"; |
| @@ -19,6 +20,7 @@ | ||
| 19 | 20 | import { SeoHead, SITE } from "./seo.js"; |
| 20 | 21 | import { avatarPath } from "../http/avatars.js"; |
| 21 | 22 | import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js"; |
| 23 | +import { imageProxy } from "../lib/camo.js"; | |
| 22 | 24 | |
| 23 | 25 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| 24 | 26 | export type Stub = ReturnType<typeof repoStub>; |
| @@ -153,9 +155,14 @@ | ||
| 153 | 155 | ); |
| 154 | 156 | }; |
| 155 | 157 | |
| 158 | +/** | |
| 159 | + * User-written Markdown. Images from other sites go through Diffinery's image proxy (lib/camo.ts), | |
| 160 | + * so they can't learn who reads the page; outside a request (no settings to sign with) they show | |
| 161 | + * as their alt text. | |
| 162 | + */ | |
| 156 | 163 | export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = async ({ text, base, empty }) => |
| 157 | 164 | text.trim() ? ( |
| 158 | - <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)) }))}</div> | |
| 165 | + <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)), image: imageProxy(tryGetContext<{ Bindings: Env }>()?.env) }))}</div> | |
| 159 | 166 | ) : ( |
| 160 | 167 | <p class="muted m0"> |
| 161 | 168 | <em>{empty ?? "No description provided."}</em> |
| @@ -383,8 +390,9 @@ | ||
| 383 | 390 | `script-src 'nonce-${nonce}'${turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}`, |
| 384 | 391 | ...(turnstile ? [`frame-src ${TURNSTILE_ORIGIN}`] : []), |
| 385 | 392 | "style-src 'self' 'unsafe-inline'", |
| 386 | - // blob: previews a picture before it is uploaded. | |
| 387 | - "img-src 'self' https: data: blob:", | |
| 393 | + // Images from other sites in Markdown come through the image proxy (/-/camo/, lib/camo.ts), so | |
| 394 | + // no page loads one directly. blob: previews a picture before it is uploaded. | |
| 395 | + "img-src 'self' data: blob:", | |
| 388 | 396 | `connect-src 'self' wss://${host} ws://${host}`, |
| 389 | 397 | "font-src 'self'", |
| 390 | 398 | "object-src 'none'", |
modifiedapps/diffinery/test/e2e/security.test.mjs+73−1View file
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | // Agents land changes on protected branches only through auto-merge (or the |
| 22 | 22 | // merge queue) where a person allowed it and a required check gates it, never by bypass. |
| 23 | 23 | // Site administrators' kill switches stop Actions and sandboxes for a repository, an |
| 24 | -// account or (hosted compute) the whole instance. | |
| 24 | +// account or (hosted compute) the whole instance. Images from other sites in Markdown | |
| 25 | +// load through Diffinery's signed image proxy, never straight from the other site. | |
| 25 | 26 | import { spawn, spawnSync, execFileSync } from "node:child_process"; |
| 26 | 27 | import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; |
| 27 | 28 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| @@ -30,6 +31,7 @@ | ||
| 30 | 31 | import { join, dirname, resolve } from "node:path"; |
| 31 | 32 | import { fileURLToPath } from "node:url"; |
| 32 | 33 | import { createServer } from "node:net"; |
| 34 | +import { createServer as createHttpServer } from "node:http"; | |
| 33 | 35 | import { test, before, after } from "node:test"; |
| 34 | 36 | import assert from "node:assert/strict"; |
| 35 | 37 | |
| @@ -2719,3 +2721,73 @@ | ||
| 2719 | 2721 | for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully); |
| 2720 | 2722 | } |
| 2721 | 2723 | }); |
| 2724 | + | |
| 2725 | +test("security: Markdown images from other sites load through Diffinery's signed image proxy", async () => { | |
| 2726 | + // A stand-in for another site, which records what it is sent. | |
| 2727 | + const seen = []; | |
| 2728 | + const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea7d6a4c50000000049454e44ae426082", "hex"); | |
| 2729 | + const site = createHttpServer((req, res) => { | |
| 2730 | + seen.push({ url: req.url, headers: req.headers }); | |
| 2731 | + if (req.url === "/pixel.png") return res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" }).end(PNG); | |
| 2732 | + if (req.url === "/moved.png") return res.writeHead(302, { location: "/pixel.png" }).end(); | |
| 2733 | + if (req.url === "/page.html") return res.writeHead(200, { "content-type": "text/html" }).end("<script>alert(1)</script>"); | |
| 2734 | + if (req.url === "/drawing.svg") return res.writeHead(200, { "content-type": "image/svg+xml" }).end('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'); | |
| 2735 | + res.writeHead(404).end(); | |
| 2736 | + }); | |
| 2737 | + await new Promise((r) => site.listen(0, "127.0.0.1", r)); | |
| 2738 | + const other = `http://127.0.0.1:${site.address().port}`; | |
| 2739 | + try { | |
| 2740 | + await repoWithReadme("pictures"); | |
| 2741 | + const body = [``, `[](${other}/page.html)`, ``, ``, ""].join("\n\n"); | |
| 2742 | + const issue = await ok("POST", `/repos/${OWNER}/pictures/issues`, { title: "pictures", body }); | |
| 2743 | + const page = await fetch(`${base}/${OWNER}/pictures/issues/${issue.number}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } }); | |
| 2744 | + assert.equal(page.status, 200); | |
| 2745 | + const csp = page.headers.get("content-security-policy") ?? ""; | |
| 2746 | + assert.match(csp, /img-src 'self' data: blob:(;|$)/); | |
| 2747 | + assert.doesNotMatch(csp, /img-src[^;]*https:/); | |
| 2748 | + const html = await page.text(); | |
| 2749 | + const srcs = [...html.matchAll(/<img src="([^"]*)" alt="(pixel|moved|page|drawing|own)"/g)].map((m) => [m[2], m[1].replaceAll("&", "&")]); | |
| 2750 | + const src = Object.fromEntries(srcs); | |
| 2751 | + assert.deepEqual(Object.keys(src).sort(), ["drawing", "moved", "own", "page", "pixel"]); | |
| 2752 | + for (const name of ["pixel", "moved", "page", "drawing"]) assert.match(src[name], /^\/-\/camo\/[\w-]{43}\/[\w-]+$/, name); | |
| 2753 | + assert.equal(src.own, `/${OWNER}/pictures/raw/main/static/none.png`); | |
| 2754 | + assert.ok(!html.includes(`src="${other}`), "no image loads from the other site directly"); | |
| 2755 | + // Links keep their original target. | |
| 2756 | + assert.ok(html.includes(`href="${other}/page.html"`)); | |
| 2757 | + assert.equal(seen.length, 0, "rendering fetches nothing"); | |
| 2758 | + | |
| 2759 | + // The proxy fetches the image without the viewer's cookies, and answers from its cache next time. | |
| 2760 | + const get = (path, init = {}) => fetch(`${base}${path}`, init); | |
| 2761 | + const first = await get(src.pixel, { headers: { cookie: `__Host-dfy_session=${S.olive}`, "user-agent": "viewer-browser/1" } }); | |
| 2762 | + assert.equal(first.status, 200); | |
| 2763 | + assert.equal(first.headers.get("content-type"), "image/png"); | |
| 2764 | + assert.equal(first.headers.get("x-content-type-options"), "nosniff"); | |
| 2765 | + assert.match(first.headers.get("cache-control") ?? "", /^public, max-age=3600$/); | |
| 2766 | + assert.equal(first.headers.get("set-cookie"), null); | |
| 2767 | + assert.deepEqual(Buffer.from(await first.arrayBuffer()), PNG); | |
| 2768 | + assert.equal(seen.length, 1); | |
| 2769 | + assert.equal(seen[0].headers.cookie, undefined); | |
| 2770 | + assert.match(seen[0].headers["user-agent"] ?? "", /^Diffinery-Camo/); | |
| 2771 | + assert.equal((await get(src.pixel)).status, 200); | |
| 2772 | + assert.equal(seen.filter((r) => r.url === "/pixel.png").length, 1, "the second view is served from the cache"); | |
| 2773 | + | |
| 2774 | + // Redirects are followed; HTML is refused; SVG can't run scripts. | |
| 2775 | + assert.equal((await get(src.moved)).status, 200); | |
| 2776 | + assert.equal((await get(src.page)).status, 404); | |
| 2777 | + const svg = await get(src.drawing); | |
| 2778 | + assert.equal(svg.status, 200); | |
| 2779 | + assert.equal(svg.headers.get("content-type"), "image/svg+xml"); | |
| 2780 | + assert.equal(svg.headers.get("content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; sandbox"); | |
| 2781 | + | |
| 2782 | + // Only URLs Diffinery signed: a tampered signature or URL, or one Diffinery never signed, is a 404 that fetches nothing. | |
| 2783 | + const before = seen.length; | |
| 2784 | + const [, , , sig, enc] = src.pixel.split("/"); | |
| 2785 | + const evil = Buffer.from(`${other}/page.html?x`).toString("base64url"); | |
| 2786 | + for (const path of [`/-/camo/${sig}/${evil}`, `/-/camo/${sig.slice(0, -2)}AA/${enc}`, `/-/camo/x/${enc}`, `/-/camo/${sig}`, `/-/camo/${"A".repeat(43)}/${Buffer.from(base).toString("base64url")}`]) { | |
| 2787 | + assert.equal((await get(path)).status, 404, path); | |
| 2788 | + } | |
| 2789 | + assert.equal(seen.length, before); | |
| 2790 | + } finally { | |
| 2791 | + site.close(); | |
| 2792 | + } | |
| 2793 | +}); |
addedapps/diffinery/test/unit/camo.test.ts+223−0View file
| @@ -1,0 +1,223 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { cacheTtl, camoHeaders, camoKey, 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("refuses unsigned, tampered, re-signed and malformed paths", () => { | |
| 31 | + const [, , , sig, enc] = camoPath(key, "https://img.example.com/a.png").split("/"); | |
| 32 | + const other = b64url(new TextEncoder().encode("https://evil.example/a.png")); | |
| 33 | + expect(verifyCamo(key, sig!, other)).toBeNull(); | |
| 34 | + expect(verifyCamo(key, `${sig!.slice(0, -1)}${sig!.endsWith("A") ? "B" : "A"}`, enc!)).toBeNull(); | |
| 35 | + expect(verifyCamo(key, "", enc!)).toBeNull(); | |
| 36 | + expect(verifyCamo(key, "x".repeat(43), enc!)).toBeNull(); | |
| 37 | + expect(verifyCamo(key, `${sig}=`, enc!)).toBeNull(); | |
| 38 | + expect(verifyCamo(key, sig!, `${enc}%2F`)).toBeNull(); | |
| 39 | + // A key from another MASTER_KEY, or the same secret under another label, doesn't verify. | |
| 40 | + const otherKey = camoKey({ ...env, MASTER_KEY: "b3RoZXI=" })!; | |
| 41 | + expect(verifyCamo(otherKey, sig!, enc!)).toBeNull(); | |
| 42 | + expect(verifyCamo(new TextEncoder().encode(`diffinery-unsubscribe-v1:${env.MASTER_KEY}`), sig!, enc!)).toBeNull(); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("has no key without MASTER_KEY, except in local development", () => { | |
| 46 | + expect(camoKey({ ...env, MASTER_KEY: undefined })).toBeNull(); | |
| 47 | + expect(camoKey(devEnv)).not.toBeNull(); | |
| 48 | + expect(camoKey({ ...devEnv, ACCESS_AUD: "aud" })).toBeNull(); | |
| 49 | + }); | |
| 50 | +}); | |
| 51 | + | |
| 52 | +describe("Markdown images", () => { | |
| 53 | + it("sends images from other sites through the proxy and keeps the link target", () => { | |
| 54 | + const html = render("[](https://ci.example.com/run/1)"); | |
| 55 | + const [src] = imgSrcs(html); | |
| 56 | + expect(src).toMatch(/^\/-\/camo\/[\w-]{43}\/[\w-]+$/); | |
| 57 | + const [, , , sig, enc] = src!.split("/"); | |
| 58 | + expect(verifyCamo(key, sig!, enc!)).toBe("https://ci.example.com/badge.svg?branch=main"); | |
| 59 | + expect(html).toContain('href="https://ci.example.com/run/1"'); | |
| 60 | + expect(html).not.toContain('src="https://ci.example.com'); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("keeps same-origin and repository images as they are", () => { | |
| 64 | + const html = render("  ", env, { owner: "acme", repo: "site", ref: "main", dir: "" }); | |
| 65 | + 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"]); | |
| 66 | + expect(imgSrcs(render(""))).toEqual(["/static/x.png"]); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("upgrades http images to https, as browsers do on an https page", () => { | |
| 70 | + const [src] = imgSrcs(render("")); | |
| 71 | + const [, , , sig, enc] = src!.split("/"); | |
| 72 | + expect(verifyCamo(key, sig!, enc!)).toBe("https://img.example.com/a.png"); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("can't be talked past the proxy with whitespace, case or backslashes", () => { | |
| 76 | + 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"]) { | |
| 77 | + const srcs = imgSrcs(render(``)); | |
| 78 | + expect(srcs, u).toHaveLength(1); | |
| 79 | + expect(srcs[0]!.startsWith("/-/camo/"), u).toBe(true); | |
| 80 | + } | |
| 81 | + // Scheme-relative and backslash forms are dropped by the renderer before they get here. | |
| 82 | + 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"); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("shows the alt text for images it won't proxy", () => { | |
| 86 | + expect(render("")).not.toContain("<img"); | |
| 87 | + expect(render("")).not.toContain("<img"); | |
| 88 | + expect(render(`}.png)`)).not.toContain("<img"); | |
| 89 | + // No settings to sign with (rendered outside a request): no third-party image at all. | |
| 90 | + const html = renderMarkdown(" ", { image: imageProxy(undefined) }); | |
| 91 | + expect(imgSrcs(html)).toEqual(["/acme/site/raw/main/a.png"]); | |
| 92 | + expect(html).toContain("x"); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("leaves renderMarkdown unchanged without the option (email and other callers)", () => { | |
| 96 | + expect(imgSrcs(renderMarkdown(""))).toEqual(["https://img.example.com/a.png"]); | |
| 97 | + }); | |
| 98 | +}); | |
| 99 | + | |
| 100 | +describe("camo fetch rules", () => { | |
| 101 | + type Call = { url: string; init: RequestInit }; | |
| 102 | + function fake(responses: Array<Response | (() => Response)>) { | |
| 103 | + const calls: Call[] = []; | |
| 104 | + const f = (async (url: string, init: RequestInit) => { | |
| 105 | + calls.push({ url, init }); | |
| 106 | + const r = responses.shift(); | |
| 107 | + if (!r) throw new Error("no more responses"); | |
| 108 | + return typeof r === "function" ? r() : r; | |
| 109 | + }) as typeof fetch; | |
| 110 | + return { f, calls }; | |
| 111 | + } | |
| 112 | + const png = (body: Uint8Array | string = "PNGDATA", headers: Record<string, string> = {}) => new Response(body, { headers: { "content-type": "image/png", ...headers } }); | |
| 113 | + | |
| 114 | + it("fetches an image with no cookies or credentials, only its own headers", async () => { | |
| 115 | + const { f, calls } = fake([png("abc", { "cache-control": "max-age=600", etag: '"e1"' })]); | |
| 116 | + const img = await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f }); | |
| 117 | + expect(img).not.toBeNull(); | |
| 118 | + expect(new TextDecoder().decode(img!.body)).toBe("abc"); | |
| 119 | + expect(img!.type).toBe("image/png"); | |
| 120 | + expect(img!.ttl).toBe(600); | |
| 121 | + const h = new Headers(calls[0]!.init.headers); | |
| 122 | + expect([...h.keys()].sort()).toEqual(["accept", "user-agent"]); | |
| 123 | + expect(calls[0]!.init.redirect).toBe("manual"); | |
| 124 | + expect(calls[0]!.init.signal).toBeDefined(); | |
| 125 | + }); | |
| 126 | + | |
| 127 | + it("refuses private, internal and Diffinery's own hosts, and plain http", async () => { | |
| 128 | + 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"]) { | |
| 129 | + const { f, calls } = fake([png()]); | |
| 130 | + expect(await fetchCamoImage(u, { env, fetch: f }), u).toBeNull(); | |
| 131 | + expect(calls).toEqual([]); | |
| 132 | + } | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("follows a few redirects, checking every hop", async () => { | |
| 136 | + const redirect = (to: string) => new Response(null, { status: 302, headers: { location: to } }); | |
| 137 | + let { f, calls } = fake([redirect("https://cdn.example.com/b.png"), redirect("/c.png"), png()]); | |
| 138 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).not.toBeNull(); | |
| 139 | + 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"]); | |
| 140 | + | |
| 141 | + ({ f, calls } = fake([redirect("https://192.168.1.1/a.png"), png()])); | |
| 142 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); | |
| 143 | + expect(calls).toHaveLength(1); | |
| 144 | + | |
| 145 | + ({ f, calls } = fake([redirect("http://img.example.com/a.png"), png()])); | |
| 146 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); | |
| 147 | + | |
| 148 | + ({ f, calls } = fake(Array.from({ length: 10 }, () => () => redirect("https://img.example.com/loop.png")))); | |
| 149 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); | |
| 150 | + expect(calls).toHaveLength(5); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + it("serves only image types it knows", async () => { | |
| 154 | + for (const t of ["text/html", "application/javascript", "image/x-icon", "", "text/xml", "application/octet-stream"]) { | |
| 155 | + const { f } = fake([new Response("<script>alert(1)</script>", { headers: t ? { "content-type": t } : {} })]); | |
| 156 | + expect(await fetchCamoImage("https://img.example.com/a", { env, fetch: f }), t).toBeNull(); | |
| 157 | + } | |
| 158 | + 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"]]) { | |
| 159 | + const { f } = fake([new Response("x", { headers: { "content-type": t! } })]); | |
| 160 | + expect((await fetchCamoImage("https://img.example.com/a", { env, fetch: f }))?.type).toBe(want); | |
| 161 | + } | |
| 162 | + }); | |
| 163 | + | |
| 164 | + it("refuses error statuses and network failures", async () => { | |
| 165 | + for (const r of [new Response("no", { status: 404, headers: { "content-type": "image/png" } }), new Response("x", { status: 206, headers: { "content-type": "image/png" } })]) { | |
| 166 | + const { f } = fake([r]); | |
| 167 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); | |
| 168 | + } | |
| 169 | + const { f } = fake([]); | |
| 170 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f })).toBeNull(); | |
| 171 | + }); | |
| 172 | + | |
| 173 | + it("caps the size while reading, whatever Content-Length says", async () => { | |
| 174 | + const big = new Uint8Array(2048); | |
| 175 | + let { f } = fake([png(big)]); | |
| 176 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 })).toBeNull(); | |
| 177 | + // A stream with no length, or a lying one, is cut off once it passes the cap. | |
| 178 | + let pulled = 0; | |
| 179 | + const endless = () => | |
| 180 | + new Response( | |
| 181 | + new ReadableStream<Uint8Array>({ | |
| 182 | + pull(ctl) { | |
| 183 | + pulled++; | |
| 184 | + ctl.enqueue(new Uint8Array(512)); | |
| 185 | + }, | |
| 186 | + }), | |
| 187 | + { headers: { "content-type": "image/png", "content-length": "10" } }, | |
| 188 | + ); | |
| 189 | + ({ f } = fake([endless])); | |
| 190 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 })).toBeNull(); | |
| 191 | + expect(pulled).toBeLessThan(10); | |
| 192 | + ({ f } = fake([png(new Uint8Array(1024))])); | |
| 193 | + expect((await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, maxBytes: 1024 }))?.body.byteLength).toBe(1024); | |
| 194 | + }); | |
| 195 | + | |
| 196 | + it("gives up after the timeout", async () => { | |
| 197 | + const f = ((_u: string, init: RequestInit) => | |
| 198 | + new Promise<Response>((_, reject) => init.signal!.addEventListener("abort", () => reject(init.signal!.reason)))) as typeof fetch; | |
| 199 | + const t = Date.now(); | |
| 200 | + expect(await fetchCamoImage("https://img.example.com/a.png", { env, fetch: f, timeoutMs: 50 })).toBeNull(); | |
| 201 | + expect(Date.now() - t).toBeLessThan(2000); | |
| 202 | + }); | |
| 203 | + | |
| 204 | + it("allows loopback and http only in local development, never the Worker itself", () => { | |
| 205 | + expect(checkImageUrl("http://127.0.0.1:9999/a.png", devEnv)?.href).toBe("http://127.0.0.1:9999/a.png"); | |
| 206 | + expect(checkImageUrl("http://127.0.0.1:8787/a.png", devEnv)).toBeNull(); | |
| 207 | + expect(checkImageUrl("http://127.0.0.1:9999/a.png", env)).toBeNull(); | |
| 208 | + }); | |
| 209 | + | |
| 210 | + it("caches within bounds and serves SVG so it can't run scripts", () => { | |
| 211 | + expect(cacheTtl(null)).toBe(86400); | |
| 212 | + expect(cacheTtl("max-age=10")).toBe(300); | |
| 213 | + expect(cacheTtl("public, max-age=3600")).toBe(3600); | |
| 214 | + expect(cacheTtl("max-age=99999999")).toBe(7 * 86400); | |
| 215 | + expect(cacheTtl("no-cache")).toBe(300); | |
| 216 | + const h = camoHeaders({ type: "image/svg+xml", ttl: 600, etag: null, lastModified: null, body: new Uint8Array(3) }); | |
| 217 | + expect(h.get("content-security-policy")).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox"); | |
| 218 | + expect(h.get("x-content-type-options")).toBe("nosniff"); | |
| 219 | + expect(h.get("cache-control")).toBe("public, max-age=600"); | |
| 220 | + expect(h.get("content-length")).toBe("3"); | |
| 221 | + expect(h.has("set-cookie")).toBe(false); | |
| 222 | + }); | |
| 223 | +}); |