diff --git a/src/api/routes.ts b/src/api/routes.ts index 680860d9f7..cbc57dc9c0 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -4,8 +4,9 @@ import { z } from "zod"; import { parsePositiveInt } from "../utils/json"; import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence"; import { completeGitHubWebOAuth, createSessionFromGitHubToken, getLiveSessionGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth"; -import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; +import { enforceRateLimit, enforceShotRenderGlobalCeiling, routeClassForPath } from "../auth/rate-limit"; import { handleShot } from "../review/visual/shot"; +import { verifyShotRenderToken } from "../review/visual/shot-render-token"; import { isScreenshotsEnabled } from "../review/visual-wire"; import { buildFindingTaxonomyDocument } from "../review/finding-taxonomy"; import { buildEnrichmentAnalyzersTaxonomyDocument } from "../review/enrichment-analyzers-taxonomy"; @@ -1377,12 +1378,35 @@ export function createApp() { // (*.workers.dev / *.pages.dev / PUBLIC_SITE_ORIGIN) AND the isSafeHttpUrl SSRF guard. Inert flag-OFF: with // LOOPOVER_REVIEW_SCREENSHOTS off nothing ever writes shots to R2, so ?key= 404s and ?url= still requires // an allowlisted public host. The route's own Cache-Control headers (per mode) are set inside handleShot; - // the rate-limit middleware classifies it as 'normal' (a sane public class) via routeClassForPath. + // the rate-limit middleware classifies it as 'expensive' (20 req/300s, the same class as every other + // headless-render/heavy-compute route) via routeClassForPath — it is the single most expensive + // unauthenticated operation this deployment exposes (a full headless-Chromium render per request), not a + // sane-default 'normal' route. // Flag-OFF = TRULY inert: when LOOPOVER_REVIEW_SCREENSHOTS is off nothing references this route (no comment // carries a /loopover/shot URL), so 404 it outright — that removes the on-demand `?url=` render surface // entirely until the feature is deliberately enabled, rather than relying on the host allowlist alone. - app.get("/loopover/shot", (c) => { + // + // TWO additional layers gate the render (`?url=`) mode specifically, on top of the per-identity rate limit + // above (#9044): (1) a signed, expiring token (shot-render-token.ts) that ORB itself mints when it embeds a + // `?url=` link in a review comment — a render only ever happens for a url ORB asked for, never an arbitrary + // caller-supplied one, checked BEFORE the render path so an invalid token never reaches handleShot's own + // host-allowlist/SSRF checks (which still apply underneath, defense-in-depth); and (2) a fixed-key GLOBAL + // render ceiling (enforceShotRenderGlobalCeiling), independent of caller identity, so rotating + // Cf-Connecting-Ip (the bypass this deployment's Node/tunnel topology made possible — see clientIp's own + // doc comment in auth/rate-limit.ts) can never manufacture more than the one shared bucket. Neither layer + // touches the `?key=`/placeholder modes, which never drive a render. + app.get("/loopover/shot", async (c) => { if (!isScreenshotsEnabled(c.env)) return c.notFound(); + const params = new URL(c.req.url).searchParams; + const isRenderMode = !params.get("placeholder") && !params.get("key") && Boolean(params.get("url")); + if (isRenderMode) { + const target = params.get("url")!; + if (!(await verifyShotRenderToken(c.env, target, params))) { + return c.json({ error: "missing_or_invalid_shot_token" }, 403); + } + const limited = await enforceShotRenderGlobalCeiling(c); + if (limited) return limited; + } return handleShot(c.req.raw, c.env, { ...(c.env.PUBLIC_SITE_ORIGIN ? { productionUrl: c.env.PUBLIC_SITE_ORIGIN } : {}), }); diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 2d7c12ad74..84b31067c5 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -57,9 +57,32 @@ export class RateLimiter extends DurableObject { } export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass: RateLimitClass): Promise { - if (!c.env.RATE_LIMITER) return null; - const config = CONFIG[routeClass]; const key = await rateLimitKey(c, routeClass); + return checkRateLimitBucket(c, key, CONFIG[routeClass], routeClass); +} + +// #9044: a SEPARATE, fixed-key (never identity-keyed) ceiling on /loopover/shot's on-demand render mode -- +// the one route on this deployment's single internet-facing path that drives a real headless-Chromium render +// per request. The per-identity `expensive` bucket above is necessary but not sufficient: an attacker who can +// get an arbitrary value into `Cf-Connecting-Ip` (or who simply has no header at all pre-clientIp-fix) gets a +// FRESH per-identity bucket on every request by rotating it, bypassing the per-identity cap entirely. A fixed +// key sidesteps that class of bypass completely -- there is only ever ONE bucket for this route's render mode, +// no matter what identity a caller can manufacture. Deliberately its OWN (lower) budget rather than reusing +// CONFIG.expensive: this caps the render primitive's TOTAL cost to the whole deployment, not any one caller's +// share of it, so it is set independently of the per-identity number. +const SHOT_RENDER_GLOBAL_CONFIG: RateLimitConfig = { limit: 30, windowSeconds: 60 }; +const SHOT_RENDER_GLOBAL_KEY = "global-ceiling:loopover-shot-render"; + +/** Enforce the fixed-key global ceiling on `/loopover/shot`'s on-demand render mode (see + * {@link SHOT_RENDER_GLOBAL_CONFIG}'s doc comment for why this exists alongside the per-identity `expensive` + * class). Callers should invoke this ONLY for the render (`?url=`) mode -- the R2-serve (`?key=`) mode is + * cheap and doesn't drive a render, so it stays covered by the ordinary per-identity check alone. */ +export async function enforceShotRenderGlobalCeiling(c: Context<{ Bindings: Env }>): Promise { + return checkRateLimitBucket(c, SHOT_RENDER_GLOBAL_KEY, SHOT_RENDER_GLOBAL_CONFIG, "expensive"); +} + +async function checkRateLimitBucket(c: Context<{ Bindings: Env }>, key: string, config: RateLimitConfig, routeClass: RateLimitClass): Promise { + if (!c.env.RATE_LIMITER) return null; let decisionResponse: Response; try { const id = c.env.RATE_LIMITER.idFromName(key); @@ -243,9 +266,47 @@ async function validateBearerForRateLimit(c: Context<{ Bindings: Env }>, token: return Boolean((await authenticatePrivateToken(c.env, token)) ?? (await authenticateInternalToken(c.env, token))); } +const LOOPBACK_PEER_IPS = new Set(["127.0.0.1", "::1"]); + +/** Rightmost hop of a (possibly multi-hop) `X-Forwarded-For` header -- the one nearest to THIS process, i.e. + * the one a single trusted local proxy itself appended, as opposed to any earlier value a client could have + * supplied. Only ever consulted from the loopback-trusted branch below. */ +function rightmostForwardedFor(header: string | undefined): string | undefined { + const hops = (header ?? "").split(","); + return normalizeIpAddress(hops[hops.length - 1]); +} + +/** + * The client identity a rate-limit bucket keys on (#9044). + * + * On Cloudflare Workers, `cf-connecting-ip` is populated by the Workers runtime itself and cannot be + * supplied by the caller -- unconditionally trusting it there is correct and stays exactly as it always has + * (`env.LOOPOVER_PEER_IP` is never set on Workers, so that branch is unreachable there). + * + * Self-host runs the SAME Hono app behind a loopback-bound Node process (`server.ts`), fronted by a + * Cloudflare Tunnel (cloudflared). `env.LOOPOVER_PEER_IP` (set per-request by server.ts from the real TCP + * peer address) tells us who actually connected to this process -- the one thing that can never be spoofed + * by a request header. On THIS topology, a loopback peer IS the trust boundary: nothing other than the local + * cloudflared process can ever reach a port bound to 127.0.0.1 only, so a loopback peer legitimizes trusting + * the header it forwarded (falling back to the rightmost X-Forwarded-For hop, then the peer itself, if the + * header is absent). A NON-loopback peer means this Node process is reachable some other way (e.g. exposed + * directly with no tunnel in front of it, a foreseeable alternate self-host topology) -- an untrusted + * connection whose headers could be anything, so the header is ignored entirely and the bucket keys on the + * peer's own real address instead. Either way, once real peer info exists, "unknown-ip" (a bucket shared by + * every anonymous caller worldwide -- the DoS this closes) is never reachable again. + */ function clientIp(c: Context<{ Bindings: Env }>): string { - // Only trust Cloudflare-populated client IPs. Proxy fallback headers can be supplied by clients in Workers. - return normalizeIpAddress(c.req.header("cf-connecting-ip")) ?? "unknown-ip"; + const peerIp = normalizeIpAddress(c.env.LOOPOVER_PEER_IP); + if (!peerIp) { + // Workers, or any caller (tests, an unwired self-host build) that never threads a peer IP in -- + // byte-identical to the pre-#9044 behavior. + return normalizeIpAddress(c.req.header("cf-connecting-ip")) ?? "unknown-ip"; + } + if (LOOPBACK_PEER_IPS.has(peerIp)) { + return normalizeIpAddress(c.req.header("cf-connecting-ip")) ?? rightmostForwardedFor(c.req.header("x-forwarded-for")) ?? peerIp; + } + // Untrusted topology: never honor a caller-suppliable header here. + return peerIp; } function normalizeIpAddress(value: string | undefined): string | undefined { diff --git a/src/env.d.ts b/src/env.d.ts index be7dd9189c..7b88315aac 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -75,6 +75,15 @@ declare global { }; PUBLIC_API_ORIGIN?: string; PUBLIC_SITE_ORIGIN?: string; + /** #9044: the genuine TCP peer address of whoever connected directly to this self-host Node process -- + * threaded in by server.ts from `@hono/node-server`'s own `HttpBindings.incoming.socket.remoteAddress` + * (the SECOND argument its `FetchCallback` type offers, which this repo's synthesized-env `serve()` call + * previously ignored entirely). UNSPOOFABLE, unlike any header. Absent on Cloudflare Workers (no Node + * socket exists there -- cf-connecting-ip is the only, and correct, signal) and absent in any test/harness + * that doesn't wire it, which is exactly the "no peer info" case clientIp() (src/auth/rate-limit.ts) falls + * back from -- see that function's own doc comment for the full trust-boundary reasoning. Never a wrangler + * binding/var (no cf-typegen entry needed): a plain runtime-only field server.ts sets per request. */ + LOOPOVER_PEER_IP?: string; /** Comma-separated extra origins (each `scheme://host[:port]`) allowed as a post-GitHub-OAuth `returnTo` * redirect target, alongside PUBLIC_SITE_ORIGIN. Lets the web login flow work correctly from more than * one live UI domain at once (e.g. during a brand/domain migration) without breaking PUBLIC_SITE_ORIGIN's diff --git a/src/review/content-lane/safe-url.ts b/src/review/content-lane/safe-url.ts index 190ae5df63..2cfdbcc179 100644 --- a/src/review/content-lane/safe-url.ts +++ b/src/review/content-lane/safe-url.ts @@ -85,7 +85,11 @@ function ipv6IsPrivateOrLocal(host: string): boolean { return false; } -function hostIsPrivateOrLocal(host: string): boolean { +/** Exported (unlike this module's other internals) so a caller that has independently resolved a hostname to + * a literal IP address (e.g. shot.ts's DNS-resolution pin, #9044) can run that resolved address through the + * exact same disallowed-range checks a literal-IP URL already gets via isSafeHttpUrl -- without duplicating + * the range tables here. Still pure: no I/O, just the same string-in/boolean-out check as always. */ +export function hostIsPrivateOrLocal(host: string): boolean { // Normalize once: lower-case, then strip the FQDN root dot(s) the parser keeps on named hosts // (`localhost.`) but not on IP literals — else `localhost.` / `foo.internal.` would read as public. const h = host.toLowerCase().replace(/\.+$/, ""); diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index a6e169ed91..d533cfe130 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -30,6 +30,7 @@ import { parseRepo, } from "./preview-url"; import { captureInteractionFrames, captureScrollFrames, captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, type InteractionAction, type ShotTheme, type Viewport } from "./shot"; +import { mintShotRenderToken } from "./shot-render-token"; import { compareCapturedScreenshots, isVisualDiffAvailable, type VisualDiffOutcome } from "./pixel-diff"; import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif"; import { detectAutoHoverInteractions, type ChangedCssFile } from "./interaction-detection"; @@ -366,9 +367,12 @@ async function capturePage( // Carries the theme (#3678) and, when set, the storage key (#4109) so a LATER on-demand fetch of this // exact URL (e.g. a failed/never-persisted render retried by GitHub's image proxy) still requests the // matching prefers-color-scheme/localStorage forcing, not the default — handleShot's Mode B reads these - // same &theme=/&themeStorageKey= params. Omitted when unset, unchanged from today. + // same &theme=/&themeStorageKey= params. Omitted when unset, unchanged from today. The trailing + // &exp=&sig= (#9044) is a signed, expiring token ORB mints for exactly this url -- routes.ts requires it + // before ever invoking the render path, so a live Chromium render only ever happens for a page ORB itself + // decided to capture. See shot-render-token.ts's own doc comment for the full threat model. const onDemand = shotBase - ? `${shotBase}/${NAMESPACE}/shot?url=${encodeURIComponent(page)}&w=${viewport.width}&h=${viewport.height}${theme ? `&theme=${theme}` : ""}${theme && themeStorageKey ? `&themeStorageKey=${encodeURIComponent(themeStorageKey)}` : ""}` + ? `${shotBase}/${NAMESPACE}/shot?url=${encodeURIComponent(page)}&w=${viewport.width}&h=${viewport.height}${theme ? `&theme=${theme}` : ""}${theme && themeStorageKey ? `&themeStorageKey=${encodeURIComponent(themeStorageKey)}` : ""}&${await mintShotRenderToken(env, page)}` : page; if (env.REVIEW_AUDIT) { diff --git a/src/review/visual/shot-render-token.ts b/src/review/visual/shot-render-token.ts new file mode 100644 index 0000000000..0045f777de --- /dev/null +++ b/src/review/visual/shot-render-token.ts @@ -0,0 +1,48 @@ +// Signed, expiring token gating /loopover/shot's on-demand `?url=` render mode (#9044). +// +// The route is public + unauthenticated by design (GitHub's camo image proxy fetches it with no bearer +// token -- see routes.ts), so its only defense against an attacker requesting a render of an ARBITRARY +// allowlisted-host URL (any path under *.workers.dev/*.pages.dev, which is free for anyone to register) was +// the host allowlist itself. This token ties a render request back to a URL ORB ITSELF decided to capture: +// capture.ts mints the token when it builds the on-demand fallback link for a review comment; routes.ts +// validates it before ever calling handleShot's render path. The existing host allowlist stays in place +// underneath this as defense-in-depth (belt and suspenders), not replaced by it. +// +// Reuses this repo's existing HMAC-SHA256 primitive (utils/crypto.ts hmacHex/timingSafeEqualHex -- the same +// one verifyGitHubSignature uses) and the always-required INTERNAL_JOB_TOKEN as the signing secret, so no new +// secret needs provisioning on any existing self-host install. +import { hmacHex, timingSafeEqualHex } from "../../utils/crypto"; + +// Generous enough that a normal reviewer opening a PR within about a day of the comment being posted still +// gets a live render; short enough to bound this public, unauthenticated render primitive's real replay +// window. A comment's embedded link is minted once and never re-signed, so this also bounds how long a +// stale/never-viewed on-demand link stays renderable at all -- an explicit tradeoff, not an oversight (see +// this repo's own PR discussion for #9044). +export const SHOT_RENDER_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +async function signShotRenderToken(env: Env, url: string, expiresAtMs: number): Promise { + return hmacHex(env.INTERNAL_JOB_TOKEN, `${url}:${expiresAtMs}`); +} + +/** Mint the `exp=&sig=` query fragment (no leading `&`/`?`) for `url`, to append to an + * on-demand `/loopover/shot?url=...` link. `url` must be the EXACT string the request's own `?url=` param + * will decode to (matching {@link verifyShotRenderToken}'s own `url` argument) -- any difference (a + * different query-param ordering, an extra trailing slash) fails validation, since the signature covers the + * url verbatim. */ +export async function mintShotRenderToken(env: Env, url: string): Promise { + const expiresAt = Date.now() + SHOT_RENDER_TOKEN_TTL_MS; + const sig = await signShotRenderToken(env, url, expiresAt); + return `exp=${expiresAt}&sig=${sig}`; +} + +/** Validate a render request's `exp`/`sig` params against the exact `url` it's requesting. False (fail + * closed, matching every other guard on this public route) on a missing/malformed/expired/tampered token. */ +export async function verifyShotRenderToken(env: Env, url: string, params: URLSearchParams): Promise { + const expParam = params.get("exp"); + const sig = params.get("sig"); + if (!expParam || !sig) return false; + const expiresAt = Number(expParam); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false; + const expected = await signShotRenderToken(env, url, expiresAt); + return timingSafeEqualHex(sig, expected); +} diff --git a/src/review/visual/shot.ts b/src/review/visual/shot.ts index 4f1bcbd3f2..1696112687 100644 --- a/src/review/visual/shot.ts +++ b/src/review/visual/shot.ts @@ -17,7 +17,7 @@ // Rendering uses the Cloudflare Browser Rendering *binding* (env.BROWSER) via @cloudflare/puppeteer — no // account API token. Returns null on any failure so callers degrade gracefully (the cell becomes a dash). import puppeteer from "@cloudflare/puppeteer"; -import { isSafeHttpUrl } from "../content-lane/safe-url"; +import { hostIsPrivateOrLocal, isSafeHttpUrl } from "../content-lane/safe-url"; export type Viewport = { width: number; height: number }; /** A `prefers-color-scheme` value the renderer can emulate before capture (#3678). */ @@ -153,6 +153,57 @@ function isAllowedHost(targetUrl: string, env: Env, productionUrl?: string): boo return false; } +// #9044 (LOW severity per the issue's own triage -- the eventual output is just a PNG the attacker already +// controls the source page of): bounds the best-effort DNS-resolution pin below so an unresponsive resolver +// can never hang a capture -- mirrors every other bounded probe in this file (SCREENSHOT_HEIGHT_PROBE_TIMEOUT_MS +// et al). +const DNS_PIN_LOOKUP_TIMEOUT_MS = 1_500; + +/** + * Best-effort DNS-resolution pin (#9044): `isSafeHttpUrl`'s SSRF guard is purely NAME-based, so a PUBLIC + * hostname whose A/AAAA record happens to resolve to a disallowed address (loopback / private / link-local, + * including cloud metadata 169.254.169.254) passes it even though the real TCP connection would land + * somewhere private. This resolves the hostname ONCE, up front, via Node's own resolver, and rejects it when + * the resolved address is itself disallowed. + * + * NOT airtight, and deliberately not pretending to be: this is a single point-in-time resolution on OUR + * side, not a pinned connection -- Chromium performs its OWN DNS resolution when it actually navigates, and + * neither Puppeteer's API nor the CDP request-interception this file already uses (`page.on('request', ...)`) + * exposes a way to force Chromium's socket to reuse the exact address validated here, so a DNS record that + * changes between this check and Chromium's real connect (TOCTOU) can still slip through. Fails OPEN (true) + * on any error, on a timeout, or on a runtime with no working `node:dns` (Cloudflare Workers has no real + * socket-level resolution to offer here) -- this is ONE MORE defense-in-depth layer alongside isSafeHttpUrl's + * name check and the per-request interception re-checks below, never the sole guard, so failing open here + * matches this whole file's existing fail-open-to-the-other-guards posture. + */ +async function isDnsResolutionSafe(url: string): Promise { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + // Unreachable via every public entry point: isSafeHttpUrl (checked by the same `||` guard, before this + // function ever runs) already calls `new URL()` itself and rejects an unparseable url first. Retained + // as defense-in-depth for a hypothetical future direct caller, mirroring safe-url.ts's own convention for + // guards that are provably unreachable today given the current call sites. + /* v8 ignore next -- @preserve unreachable: isSafeHttpUrl already rejects an unparseable url first */ + return true; + } + // A single try/catch for BOTH the dynamic import (fails only on a runtime with no working `node:dns`, e.g. + // Cloudflare Workers) and the lookup call itself (fails on an unresolvable/erroring hostname) -- both land + // on the exact same fail-open outcome, so there is no behavioral reason to distinguish them. + try { + const { lookup } = await import("node:dns/promises"); + const result = await Promise.race([ + lookup(hostname), + new Promise((resolve) => setTimeout(() => resolve(null), DNS_PIN_LOOKUP_TIMEOUT_MS)), + ]); + if (!result) return true; // timed out -- fail open, same posture as every other guard in this file + return !hostIsPrivateOrLocal(result.address); + } catch { + return true; // no working node:dns, or an unresolvable/erroring lookup -- fail open + } +} + const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; /** Reads a PNG's real width/height straight from its IHDR chunk -- Chromium's own rasterized output, not a @@ -254,7 +305,7 @@ export async function captureShot(env: Env, url: string, viewport: Viewport = VI // SSRF defense-in-depth: NEVER navigate the headless browser to a non-public host (loopback / link-local / // private / cloud-metadata 169.254.169.254 / etc.). Callers may resolve `url` from a deployment_status // webhook or a PR-comment preview link, so guard at this choke point regardless of how the URL was obtained. - if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url))) { + if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url)) || !(await isDnsResolutionSafe(url))) { console.log(JSON.stringify({ event: "render_screenshot_blocked", url: String(url).slice(0, 120) })); return { png: null, authWalled: false }; } @@ -364,7 +415,7 @@ async function waitForScrollSettle(): Promise { * (callers degrade gracefully, same contract as `captureShot` returning a null `png`). */ export async function captureScrollFrames(env: Env, url: string, viewport: Viewport = VIEWPORT, opts: CaptureShotOptions = {}): Promise<{ frames: Uint8Array[]; authWalled: boolean }> { - if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url))) { + if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url)) || !(await isDnsResolutionSafe(url))) { console.log(JSON.stringify({ event: "render_scroll_frames_blocked", url: String(url).slice(0, 120) })); return { frames: [], authWalled: false }; } @@ -515,7 +566,7 @@ export async function captureInteractionFrames( opts: CaptureShotOptions = {}, dragTo?: string | undefined, ): Promise<{ frames: Uint8Array[]; authWalled: boolean }> { - if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url))) { + if (!url || !isSafeHttpUrl(url) || (opts.isAllowedUrl && !opts.isAllowedUrl(url)) || !(await isDnsResolutionSafe(url))) { console.log(JSON.stringify({ event: "render_interaction_frames_blocked", url: String(url).slice(0, 120) })); return { frames: [], authWalled: false }; } diff --git a/src/server.ts b/src/server.ts index bb0d4297cc..582cfdcc75 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { existsSync, readdirSync, writeFileSync } from "node:fs"; import { delimiter, join } from "node:path"; import { randomUUID } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; -import { serve } from "@hono/node-server"; +import { serve, type Http2Bindings, type HttpBindings } from "@hono/node-server"; import packageJson from "../package.json"; import worker from "./index"; import { githubRestRateLimitRemainingSamples } from "./github/client"; @@ -998,7 +998,13 @@ async function main(): Promise { const port = Number(process.env.PORT ?? 8787); const server = serve( { - fetch: async (request: Request) => { + fetch: async (request: Request, httpBindings: HttpBindings | Http2Bindings) => { + // #9044: the genuine TCP peer address of whoever connected directly to THIS process -- the second + // argument @hono/node-server's own FetchCallback type has always offered, previously ignored here. + // UNSPOOFABLE, unlike any header (Cf-Connecting-Ip / X-Forwarded-For are both caller-suppliable once + // a request reaches a plain Node socket). Threaded into a PER-REQUEST env below rather than mutating + // the shared `env` object, which every concurrent request reads. + const peerIp = httpBindings?.incoming?.socket?.remoteAddress; const path = new URL(request.url).pathname; if (path === "/health") return new Response( @@ -1169,7 +1175,11 @@ async function main(): Promise { return finish(new Response(null, { status: 204 })); } } - const response = await worker.fetch(request, env, ctx); + // Per-request env (never a mutation of the shared `env`, which every concurrent request reads): + // only differs from it by LOOPOVER_PEER_IP, threaded through so clientIp() (auth/rate-limit.ts) + // can make the loopback-trust decision described on the peer IP env field's own doc comment. + const requestEnv = peerIp ? ({ ...env, LOOPOVER_PEER_IP: peerIp } as Env) : env; + const response = await worker.fetch(request, requestEnv, ctx); if (deliveryId && response.ok) { // Best-effort — never block the response on a cache write failure. void rememberWebhookDelivery(webhookCache!, deliveryId).catch( diff --git a/test/integration/shot-route.test.ts b/test/integration/shot-route.test.ts new file mode 100644 index 0000000000..4b395d1fd6 --- /dev/null +++ b/test/integration/shot-route.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; + +// The render pipeline's best-effort DNS-resolution pin (#9044, shot.ts) does a real `node:dns/promises` +// lookup before navigating -- mocked here (same convention as test/unit/visual-shot.test.ts) so these +// route-wiring tests resolve instantly to a safe public address instead of making a real network call (which +// would be slow/flaky in a network-restricted sandbox and risks this file's 15s test timeout across the +// many-iteration ceiling test below). +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn(async () => ({ address: "93.184.216.34", family: 4 })), +})); + +import { createApp } from "../../src/api/routes"; +import { RateLimiter } from "../../src/auth/rate-limit"; +import { mintShotRenderToken } from "../../src/review/visual/shot-render-token"; +import { createTestEnv } from "../helpers/d1"; + +function memoryDurableObjectState() { + const storage = new Map(); + return { + storage: { + async get(key: string) { + return storage.get(key); + }, + async put(key: string, value: unknown) { + storage.set(key, value); + }, + }, + }; +} + +/** A REAL RateLimiter DO (not a stub), keyed by name so every call for the SAME key routes to the SAME + * instance -- mirrors real DurableObjectNamespace idFromName/get semantics closely enough to exercise + * actual enforcement, not just a canned response. */ +function realRateLimiterNamespace(env: Env) { + const instances = new Map(); + return { + idFromName(name: string) { + return name; + }, + get(id: string) { + let instance = instances.get(id); + if (!instance) { + instance = new RateLimiter(memoryDurableObjectState() as unknown as DurableObjectState, env); + instances.set(id, instance); + } + // A real DurableObjectStub's .fetch(url, init) builds a Request and forwards it to the instance's own + // fetch(request) override, which takes a single Request argument, not (url, init). + return { fetch: (url: string, init?: RequestInit) => instance!.fetch(new Request(url, init)) }; + }, + }; +} + +const TARGET_URL = "https://preview.pages.dev/app"; + +describe("/loopover/shot route wiring (#9044 -- signed render token + global render ceiling)", () => { + it("returns 404 when LOOPOVER_REVIEW_SCREENSHOTS is off, before any token/ceiling check runs", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}`, {}, env); + expect(res.status).toBe(404); + }); + + it("rejects the render mode outright when no token is present", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}`, {}, env); + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "missing_or_invalid_shot_token" }); + }); + + it("rejects a tampered token", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const suffix = await mintShotRenderToken(env, TARGET_URL); + const tampered = suffix.replace(/sig=[0-9a-f]+/, `sig=${"a".repeat(64)}`); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${tampered}`, {}, env); + expect(res.status).toBe(403); + }); + + it("rejects an expired token", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-24T12:00:00.000Z")); + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const suffix = await mintShotRenderToken(env, TARGET_URL); + vi.setSystemTime(new Date("2026-06-25T12:00:01.000Z")); // past the 24h TTL + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${suffix}`, {}, env); + expect(res.status).toBe(403); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects a token minted for a different url than the one being requested", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const suffix = await mintShotRenderToken(env, "https://preview.pages.dev/some-other-page"); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${suffix}`, {}, env); + expect(res.status).toBe(403); + }); + + it("passes the token gate and reaches the pre-existing render pipeline for a validly minted token (degrades to 502 with no BROWSER binding configured -- proves it got past this layer, not that rendering itself succeeded)", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const suffix = await mintShotRenderToken(env, TARGET_URL); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${suffix}`, {}, env); + expect(res.status).toBe(502); + }); + + it("never requires a token for the ?key= (R2-serve) mode", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const res = await app.request(`/loopover/shot?key=${encodeURIComponent("loopover/shots/missing.png")}`, {}, env); + expect(res.status).toBe(404); // reached handleShot's R2 lookup (missing object) -- not blocked by a token check + }); + + it("never requires a token for a placeholder card", async () => { + const app = createApp(); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const res = await app.request("/loopover/shot?placeholder=loading", {}, env); + expect(res.status).toBe(200); + }); + + it("REGRESSION (#9044): the fixed-key global ceiling throttles the render mode even with a fresh, validly-minted token and a rotating Cf-Connecting-Ip on every request", async () => { + const baseEnv = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true" }); + const rateLimiter = realRateLimiterNamespace(baseEnv); + const env = createTestEnv({ LOOPOVER_REVIEW_SCREENSHOTS: "true", RATE_LIMITER: rateLimiter as unknown as DurableObjectNamespace }); + const app = createApp(); + + // SHOT_RENDER_GLOBAL_CONFIG's budget (30/60s) exhausted -- each iteration mints its OWN fresh token + // (so the token gate itself never blocks) and forges a DIFFERENT Cf-Connecting-Ip (so the ordinary + // per-identity `expensive` bucket never blocks either, since each identity gets its own fresh bucket). + for (let i = 0; i < 30; i++) { + const suffix = await mintShotRenderToken(env, TARGET_URL); + const res = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${suffix}`, { headers: { "cf-connecting-ip": `203.0.113.${i}` } }, env); + expect(res.status).toBe(502); // reached the render pipeline every time -- not yet throttled + } + // The 31st request, with yet another rotated identity and a perfectly valid fresh token, is STILL + // throttled: the fixed-key global ceiling doesn't care what identity or token the caller presents. + const suffix = await mintShotRenderToken(env, TARGET_URL); + const blocked = await app.request(`/loopover/shot?url=${encodeURIComponent(TARGET_URL)}&${suffix}`, { headers: { "cf-connecting-ip": "203.0.113.250" } }, env); + expect(blocked.status).toBe(429); + }); +}); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 3b28996b68..3dad072fd9 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth"; -import { enforceRateLimit, RateLimiter, routeClassForPath } from "../../src/auth/rate-limit"; -import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractCookieValue, isAuthorizedGitHubSessionLogin, isMcpActuationRepoAllowed, revokeSession, timingSafeEqual } from "../../src/auth/security"; +import { enforceRateLimit, enforceShotRenderGlobalCeiling, RateLimiter, routeClassForPath } from "../../src/auth/rate-limit"; +import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractCookieValue, hashToken, isAuthorizedGitHubSessionLogin, isMcpActuationRepoAllowed, revokeSession, timingSafeEqual } from "../../src/auth/security"; import { createApp } from "../../src/api/routes"; import { PRODUCT_USER_AGENT } from "../../src/github/client"; import { issueOrbEnrollment } from "../../src/orb/broker"; @@ -253,6 +253,90 @@ describe("private-beta auth and rate limiting", () => { expect(observedKeys[0]).toMatch(/^normal:\/v1\/public\/github\/repos\/:owner\/:repo\/stats:ip:/); }); + describe("clientIp trusted-proxy behavior (#9044 -- self-host behind a Cloudflare Tunnel to a plain Node process)", () => { + it("byte-identical to before when no peer IP is threaded in (Cloudflare Workers, or an unwired self-host build): still trusts cf-connecting-ip", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({}, observedKeys); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.9" }), "expensive")).resolves.toBeNull(); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.10" }), "expensive")).resolves.toBeNull(); + expect(observedKeys[0]).not.toBe(observedKeys[1]); // different header -> different bucket, same as always + }); + + it("trusts cf-connecting-ip when the immediate peer is loopback (the real self-host topology: cloudflared -> 127.0.0.1)", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({ LOOPOVER_PEER_IP: "127.0.0.1" }, observedKeys); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.9" }), "expensive")).resolves.toBeNull(); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.10" }), "expensive")).resolves.toBeNull(); + expect(observedKeys[0]).not.toBe(observedKeys[1]); // header is honored -> distinct callers stay distinct + }); + + it("falls back to the RIGHTMOST X-Forwarded-For hop (not the leftmost/client-supplied one) when cf-connecting-ip is absent, loopback peer", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({ LOOPOVER_PEER_IP: "::1" }, observedKeys); + // Two requests share the SAME rightmost hop but differ in every hop a client could have forged -- + // proving only the rightmost (nearest-to-us) hop is trusted. + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "x-forwarded-for": "9.9.9.9, 198.51.100.1" }), "expensive")).resolves.toBeNull(); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "x-forwarded-for": "6.6.6.6, 5.5.5.5, 198.51.100.1" }), "expensive")).resolves.toBeNull(); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toBe(`expensive:/loopover/shot:ip:${await hashToken("198.51.100.1")}`); + }); + + it("REGRESSION: falls back to the real peer IP itself (never a shared 'unknown-ip' bucket) when the loopback peer sends neither header", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({ LOOPOVER_PEER_IP: "127.0.0.1" }, observedKeys); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", {}), "expensive")).resolves.toBeNull(); + expect(observedKeys[0]).toBe(`expensive:/loopover/shot:ip:${await hashToken("127.0.0.1")}`); + expect(observedKeys[0]).not.toBe(`expensive:/loopover/shot:ip:${await hashToken("unknown-ip")}`); + }); + + it("REGRESSION: ignores cf-connecting-ip/X-Forwarded-For entirely (never lets an attacker rotate them to bypass keying) when the peer is NOT loopback/trusted -- keys on the real peer address instead", async () => { + const observedKeys: string[] = []; + const env = rateLimitTestEnv({ LOOPOVER_PEER_IP: "203.0.113.77" }, observedKeys); + // Two requests, EACH forging a different spoofed cf-connecting-ip -- both collapse to the SAME bucket + // because the header is never trusted once the connecting peer itself isn't loopback. + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.9" }), "expensive")).resolves.toBeNull(); + await expect(enforceRateLimit(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "198.51.100.50", "x-forwarded-for": "1.1.1.1" }), "expensive")).resolves.toBeNull(); + expect(observedKeys[0]).toBe(observedKeys[1]); + expect(observedKeys[0]).toBe(`expensive:/loopover/shot:ip:${await hashToken("203.0.113.77")}`); + }); + }); + + it("REGRESSION (#9044): a rotating Cf-Connecting-Ip can no longer bypass /loopover/shot's render-mode cap -- the fixed-key global ceiling throttles it regardless of identity", async () => { + // A REAL RateLimiter instance (not a stub) sharing storage per DO id, mirroring how idFromName/get would + // route every call for the SAME fixed key to the SAME durable object in production. + const instances = new Map(); + const realRateLimiterNamespace = { + idFromName(name: string) { + return name; + }, + get(id: string) { + let instance = instances.get(id); + if (!instance) { + instance = new RateLimiter(memoryDurableObjectState() as unknown as DurableObjectState, createTestEnv()); + instances.set(id, instance); + } + // A real DurableObjectStub's .fetch(url, init) builds a Request and forwards it to the instance's + // own fetch(request) override -- unlike calling that override directly (see the OTHER rate-limit + // test above), which takes a single Request argument, not (url, init). + return { fetch: (url: string, init?: RequestInit) => instance!.fetch(new Request(url, init)) }; + }, + }; + const env = createTestEnv({ RATE_LIMITER: realRateLimiterNamespace as unknown as DurableObjectNamespace }); + + // SHOT_RENDER_GLOBAL_CONFIG's budget (30/60s) is exhausted -- each call forges a DIFFERENT + // Cf-Connecting-Ip, simulating an attacker rotating the header on every request to try to dodge + // per-identity keying (the exact bypass class #9044 describes). + for (let i = 0; i < 30; i++) { + await expect(enforceShotRenderGlobalCeiling(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": `203.0.113.${i}` }))).resolves.toBeNull(); + } + // The 31st request, with yet another rotated identity, is STILL throttled: the ceiling is keyed on one + // fixed bucket, not on anything the caller controls. + const blocked = await enforceShotRenderGlobalCeiling(fakeContext(env, "/loopover/shot", { "cf-connecting-ip": "203.0.113.250" })); + expect(blocked).not.toBeNull(); + expect(blocked?.status).toBe(429); + await expect(blocked?.json()).resolves.toMatchObject({ error: "rate_limited", routeClass: "expensive" }); + }); + it("keys /v1/auth/github/token by SESSION, not by IP (#6117) -- unlike its pre-auth OAuth-flow siblings", async () => { const observedKeys: string[] = []; const env = rateLimitTestEnv({}, observedKeys); diff --git a/test/unit/shot-render-token.test.ts b/test/unit/shot-render-token.test.ts new file mode 100644 index 0000000000..38c805d2e7 --- /dev/null +++ b/test/unit/shot-render-token.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mintShotRenderToken, verifyShotRenderToken } from "../../src/review/visual/shot-render-token"; +import { createTestEnv } from "../helpers/d1"; + +const URL_A = "https://preview.pages.dev/app"; +const URL_B = "https://preview.pages.dev/other"; + +function paramsFromSuffix(suffix: string): URLSearchParams { + return new URLSearchParams(suffix); +} + +describe("shot-render-token (#9044 -- signed, expiring gate for /loopover/shot's on-demand render mode)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("mints a token that validates for the exact url it was minted for", async () => { + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + expect(suffix).toMatch(/^exp=\d+&sig=[0-9a-f]{64}$/); + await expect(verifyShotRenderToken(env, URL_A, paramsFromSuffix(suffix))).resolves.toBe(true); + }); + + it("rejects a token minted for a DIFFERENT url -- the signature binds to the exact url, not just any valid token shape", async () => { + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + await expect(verifyShotRenderToken(env, URL_B, paramsFromSuffix(suffix))).resolves.toBe(false); + }); + + it("rejects a token with a tampered signature", async () => { + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + const params = paramsFromSuffix(suffix); + const sig = params.get("sig")!; + // Flip one hex character -- still a well-formed 64-char hex string, just not the real signature. + const tampered = `${sig.slice(0, -1)}${sig.endsWith("0") ? "1" : "0"}`; + params.set("sig", tampered); + await expect(verifyShotRenderToken(env, URL_A, params)).resolves.toBe(false); + }); + + it("rejects an expired token", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-24T12:00:00.000Z")); + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + // Past the 24h TTL. + vi.setSystemTime(new Date("2026-06-25T12:00:01.000Z")); + await expect(verifyShotRenderToken(env, URL_A, paramsFromSuffix(suffix))).resolves.toBe(false); + }); + + it("accepts a token right up to (but not past) its expiry", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-24T12:00:00.000Z")); + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + vi.setSystemTime(new Date("2026-06-25T11:59:59.000Z")); // 1s before the 24h TTL elapses + await expect(verifyShotRenderToken(env, URL_A, paramsFromSuffix(suffix))).resolves.toBe(true); + }); + + it("rejects a missing sig param", async () => { + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + const params = paramsFromSuffix(suffix); + params.delete("sig"); + await expect(verifyShotRenderToken(env, URL_A, params)).resolves.toBe(false); + }); + + it("rejects a missing exp param", async () => { + const env = createTestEnv(); + const suffix = await mintShotRenderToken(env, URL_A); + const params = paramsFromSuffix(suffix); + params.delete("exp"); + await expect(verifyShotRenderToken(env, URL_A, params)).resolves.toBe(false); + }); + + it("rejects a non-numeric exp param", async () => { + const env = createTestEnv(); + await expect(verifyShotRenderToken(env, URL_A, new URLSearchParams({ exp: "not-a-number", sig: "a".repeat(64) }))).resolves.toBe(false); + }); + + it("rejects entirely absent params", async () => { + const env = createTestEnv(); + await expect(verifyShotRenderToken(env, URL_A, new URLSearchParams())).resolves.toBe(false); + }); + + it("two mints for the same url+instant produce the same signature (deterministic HMAC, not a nonce)", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-24T12:00:00.000Z")); + const env = createTestEnv(); + const first = await mintShotRenderToken(env, URL_A); + const second = await mintShotRenderToken(env, URL_A); + expect(first).toBe(second); + }); + + it("different INTERNAL_JOB_TOKEN secrets produce different signatures -- an instance's own secret is what a token is bound to", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-24T12:00:00.000Z")); + const envA = createTestEnv({ INTERNAL_JOB_TOKEN: "secret-a" }); + const envB = createTestEnv({ INTERNAL_JOB_TOKEN: "secret-b" }); + const suffixA = await mintShotRenderToken(envA, URL_A); + await expect(verifyShotRenderToken(envB, URL_A, paramsFromSuffix(suffixA))).resolves.toBe(false); + }); +}); diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts index f3fb169657..3175d96654 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -166,15 +166,19 @@ describe("visual capture preview discovery", () => { expect(seenUrls).toEqual([]); expect(result.previewPending).toBe(false); - expect(result.routes).toEqual([ - { - path: "/app", - beforeUrl: undefined, - beforeUrlMobile: undefined, - afterUrl: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900`, - afterUrlMobile: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844`, - }, - ]); + expect(result.routes).toHaveLength(1); + const route = result.routes[0]!; + expect(route.path).toBe("/app"); + expect(route.beforeUrl).toBeUndefined(); + expect(route.beforeUrlMobile).toBeUndefined(); + // The trailing &exp=&sig= (#9044, shot-render-token.ts) is a per-mint signed token -- checked separately + // (shape only) rather than baked into an exact string, since its value is time-dependent. + expect(route.afterUrl).toMatch( + new RegExp(`^https://worker\\.example/loopover/shot\\?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900&exp=\\d+&sig=[0-9a-f]{64}$`), + ); + expect(route.afterUrlMobile).toMatch( + new RegExp(`^https://worker\\.example/loopover/shot\\?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844&exp=\\d+&sig=[0-9a-f]{64}$`), + ); }); it("uses target.previewUrl directly (no url_template configured) and skips discovery entirely", async () => { @@ -1393,8 +1397,8 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { undefined, { themes: ["dark"], themeStorageKey: "theme" }, ); - expect(result.routes[0]?.beforeUrl).toBe( - `https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`, + expect(result.routes[0]?.beforeUrl).toMatch( + new RegExp(`^https://worker\\.example/loopover/shot\\?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}&exp=\\d+&sig=[0-9a-f]{64}$`), ); }); @@ -1407,7 +1411,9 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => { undefined, { themeStorageKey: "theme" }, ); - expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`); + expect(result.routes[0]?.beforeUrl).toMatch( + new RegExp(`^https://worker\\.example/loopover/shot\\?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&exp=\\d+&sig=[0-9a-f]{64}$`), + ); }); it("threads the theme storage key into the shot fingerprint too, so it never collides with an untagged-key capture of the same theme", async () => { diff --git a/test/unit/visual-shot.test.ts b/test/unit/visual-shot.test.ts index 968006dacc..807438deea 100644 --- a/test/unit/visual-shot.test.ts +++ b/test/unit/visual-shot.test.ts @@ -26,6 +26,13 @@ const mocks = vi.hoisted(() => ({ // same mock rather than introducing a second, redundant height property. scrollHeight: 900, evaluateCallCount: 0, + // #9044 DNS-resolution pin: a public, non-private address by default so every existing test's target host + // resolves "safely" and none of them exercise the new guard unintentionally — matching every other default + // in this hoisted object being the "everything is fine" case. Individual tests override via + // mockResolvedValueOnce/mockRejectedValueOnce/mockReturnValueOnce, consumed once and reverting to this + // default afterward (vi.clearAllMocks() in each beforeEach clears call history only, never this base + // implementation — same convention every other mock in this file already relies on). + dnsLookup: vi.fn(async (_hostname: string) => ({ address: "93.184.216.34", family: 4 as const })), })); vi.mock("@cloudflare/puppeteer", () => ({ @@ -34,6 +41,10 @@ vi.mock("@cloudflare/puppeteer", () => ({ }, })); +vi.mock("node:dns/promises", () => ({ + lookup: (hostname: string) => mocks.dnsLookup(hostname), +})); + function env(): Env { return { BROWSER: {} } as Env; } @@ -894,6 +905,92 @@ describe("captureInteractionFrames (#interaction-gif-capture)", () => { }); }); +describe("DNS-resolution pin (#9044, best-effort SSRF defense-in-depth)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.finalUrl = "https://preview.pages.dev/page"; + mocks.scrollHeight = 900; + mocks.dnsLookup.mockResolvedValue({ address: "93.184.216.34", family: 4 }); + mocks.evaluate.mockImplementation(async (fn: (...fnArgs: unknown[]) => unknown, ...fnArgs: unknown[]) => { + try { + fn(...fnArgs); + } catch { + // expected — see the shared mock comment at the top of this file. + } + return fnArgs.length === 0 ? mocks.scrollHeight : undefined; + }); + mocks.launch.mockImplementation(async () => { + let onRequest: ((request: ReturnType) => void) | undefined; + return { + newPage: async () => ({ + setRequestInterception: vi.fn(async () => undefined), + on: vi.fn((event: string, callback: (request: ReturnType) => void) => { + if (event === "request") onRequest = callback; + }), + setViewport: vi.fn(async () => undefined), + emulateMediaFeatures: mocks.emulateMediaFeatures, + goto: vi.fn(async (url: string) => { + onRequest?.(makeRequest(url)); + if (mocks.finalUrl !== url) onRequest?.(makeRequest(mocks.finalUrl)); + }), + reload: mocks.reload, + url: vi.fn(() => mocks.finalUrl), + screenshot: mocks.screenshot, + evaluate: mocks.evaluate, + waitForSelector: mocks.waitForSelector, + mouse: { move: mocks.mouseMove, down: mocks.mouseDown, up: mocks.mouseUp }, + }), + close: mocks.close, + }; + }); + }); + + it("captureShot rejects a public hostname whose DNS resolves to a disallowed address, before launching the browser", async () => { + mocks.dnsLookup.mockResolvedValueOnce({ address: "169.254.169.254", family: 4 }); // cloud metadata + const result = await captureShot(env(), "https://attacker-controlled.pages.dev/page"); + expect(result).toEqual({ png: null, authWalled: false }); + expect(mocks.launch).not.toHaveBeenCalled(); + }); + + it("captureScrollFrames rejects a public hostname whose DNS resolves to a disallowed address", async () => { + mocks.dnsLookup.mockResolvedValueOnce({ address: "127.0.0.1", family: 4 }); + const result = await captureScrollFrames(env(), "https://attacker-controlled.pages.dev/page", { width: 1440, height: 900 }); + expect(result).toEqual({ frames: [], authWalled: false }); + expect(mocks.launch).not.toHaveBeenCalled(); + }); + + it("captureInteractionFrames rejects a public hostname whose DNS resolves to a disallowed address", async () => { + mocks.dnsLookup.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 }); + const result = await captureInteractionFrames(env(), "https://attacker-controlled.pages.dev/page", ".x", "hover", { width: 1440, height: 900 }); + expect(result).toEqual({ frames: [], authWalled: false }); + expect(mocks.launch).not.toHaveBeenCalled(); + }); + + it("fails open (still renders) when DNS resolution errors -- not the sole guard, defense-in-depth only", async () => { + mocks.dnsLookup.mockRejectedValueOnce(new Error("ENOTFOUND")); + const response = await handleShot(request("https://preview.pages.dev/page"), env()); + expect(response.status).toBe(200); + }); + + it("fails open (still renders) when the DNS lookup times out", async () => { + vi.useFakeTimers(); + try { + mocks.dnsLookup.mockReturnValueOnce(new Promise(() => undefined)); + const result = captureShot(env(), "https://preview.pages.dev/page"); + await vi.advanceTimersByTimeAsync(1_500); + await expect(result).resolves.toEqual({ png: expect.any(Uint8Array), authWalled: false }); + } finally { + vi.useRealTimers(); + } + }); + + it("fails open (still renders) for a resolved address that is itself safe/public", async () => { + mocks.dnsLookup.mockResolvedValueOnce({ address: "93.184.216.34", family: 4 }); + const response = await handleShot(request("https://preview.pages.dev/page"), env()); + expect(response.status).toBe(200); + }); +}); + describe("visual screenshot placeholder cards", () => { it("serves the loading spinner SVG for placeholder=loading", async () => { const response = await handleShot(shotRequest("placeholder=loading"), {} as Env);