From 4e8fd5c48c05b87b5bfed8c76c185f23d6653d6e Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 29 Jul 2026 14:42:18 +0100 Subject: [PATCH 1/3] feat(vercel): add image optimization --- .agents/VERCEL.md | 21 +- AGENTS.md | 6 +- package.json | 5 + pnpm-lock.yaml | 331 ++++++++++++++++++++++++++++ src/runners/vercel/image.ts | 409 +++++++++++++++++++++++++++++++++++ src/runners/vercel/runner.ts | 44 +++- test/fixtures/app-image.mjs | 17 ++ test/vercel.test.ts | 227 +++++++++++++++++++ 8 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 src/runners/vercel/image.ts create mode 100644 test/fixtures/app-image.mjs diff --git a/.agents/VERCEL.md b/.agents/VERCEL.md index 440bdf5..50f2857 100644 --- a/.agents/VERCEL.md +++ b/.agents/VERCEL.md @@ -8,6 +8,7 @@ Extends `NodeWorkerEnvRunner` to simulate a Vercel deployment environment. - **`src/runners/vercel/worker.ts`** — Sets Vercel env vars and `Symbol.for("@vercel/request-context")` on globalThis, delegates to node-worker worker - **`src/runners/vercel/oidc.ts`** — `_checkVercelOidcToken()` decodes `VERCEL_OIDC_TOKEN` (JWT `exp` claim, no signature check) and returns `{ status: "missing" | "valid" | "expired" | "invalid", expiresAt? }`. `warnIfVercelOidcTokenInvalid()` logs a one-time dev warning hinting the user to run `vercel env pull`. Called from the `VercelEnvRunner` constructor - **`src/runners/vercel/queue-dev.ts`** — Bridge for local Vercel Queues delivery. `await registerVercelQueueConsumer({ topic, handler, consumerGroup?, visibilityTimeoutSeconds?, retry?, retryAfterSeconds? })` lets framework plugins bind a topic to a dispatcher; the first call lazy-loads `@vercel/queue` and constructs a shared `QueueClient`. Resolves to an unregister function. Re-registering the same `consumerGroup` on a topic replaces the handler via the SDK's own `consumerGroup` keying (HMR-safe; the unregister for a replaced registration becomes a no-op). `retryAfterSeconds` is a shorthand for `retry: () => ({ afterSeconds })`; pass `retry` for richer directives like `{ acknowledge: true }` +- **`src/runners/vercel/image.ts`** — `createVercelImageHandler()`: handles `/_vercel/image` requests using IPX for image optimization. Supports `url`, `w`, `h`, `q`, `f`, `fit`, `blur`, `cache` query params. Validates remote URLs against `domains`/`remotePatterns`, local URLs against `localPatterns`, blocks SVG by default. Falls back to unoptimized proxy when `ipx` is not installed ## How it works @@ -40,9 +41,27 @@ All headers are only injected when not already present in the request/response. **Local Vercel Queues delivery:** Frameworks running inside the worker `await registerVercelQueueConsumer({ topic, handler, consumerGroup?, visibilityTimeoutSeconds?, retry?, retryAfterSeconds? })` from `env-runner/runners/vercel/queue-dev` (e.g. Nitro forwards delivered messages to its `vercel:queue` runtime hook). The first call lazy-imports `@vercel/queue`, constructs a shared `QueueClient`, and registers a dev consumer via `registerDevConsumer`. Subsequent calls reuse the client; re-registering the same `consumerGroup` on a topic replaces the handler in place (HMR-safe). `retryAfterSeconds` is shorthand for a constant-delay retry; pass `retry: (error, metadata) => RetryDirective` for richer directives (`{ afterSeconds }`, `{ acknowledge: true }`, or `undefined` to propagate). If `@vercel/queue` is not installed or is too old to expose `registerDevConsumer`, a one-time warning is logged and registrations resolve to a no-op unregister — dev startup is never blocked. +**Image optimization (`/_vercel/image`):** Intercepts requests to `/_vercel/image` and processes images using IPX (optional `ipx` peer dependency). Supports Vercel's image optimization query parameters: + +- `url` (required) — source image URL (local path or absolute URL) +- `w` (required) — output width in pixels +- `q` (optional, default 75) — quality 1–100 +- `f` (optional) — output format as MIME type (`image/webp`, `image/avif`, etc.) +- `h` (optional) — output height in pixels +- `fit` (optional) — resize mode (`cover`, `contain`, `fill`, `inside`, `outside`) +- `blur` (optional) — blur amount +- `cache` (optional) — cache TTL override in seconds + +Format auto-detection from `Accept` header when `f` is not provided (prefers avif > webp). Response includes `Vary: Accept` for proper cache keying. Local images are fetched from the worker; remote images are fetched directly. When `ipx` is not installed, warns once and falls back to proxying the unoptimized source image. + +**URL validation:** Remote URLs are validated against `domains` (exact hostname match) and `remotePatterns` (protocol, hostname glob, port, pathname glob). Returns 400 when a remote URL doesn't match. Local URLs can be restricted via `localPatterns`. SVG sources are blocked by default (400) unless `dangerouslyAllowSVG` is true. + +Constructor accepts optional `images` config (`VercelImageConfig`) matching the Vercel Build Output API `images` property: `sizes`, `domains`, `remotePatterns`, `localPatterns`, `qualities`, `formats`, `minimumCacheTTL`, `dangerouslyAllowSVG`, `contentSecurityPolicy`, `contentDispositionType`. + ## Testing - Vercel suites (`test/vercel.test.ts` and the Vercel entry in `test/runners.test.ts`) stub a fake far-future `VERCEL_OIDC_TOKEN` via `vi.stubEnv` so the OIDC check doesn't log warnings (real env token takes precedence) -- **`test/vercel.test.ts`** — Tests for `VercelEnvRunner`: request header injection (`x-vercel-deployment-url`, `x-vercel-id`, `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `x-forwarded-proto`, `x-forwarded-host`), response header injection (`server`, `x-vercel-id`, `x-vercel-cache`), environment variables (`VERCEL`, `VERCEL_ENV`, `VERCEL_REGION`, `NOW_REGION`), header preservation, pre-existing header respect +- **`test/vercel.test.ts`** — Tests for `VercelEnvRunner`: request header injection (`x-vercel-deployment-url`, `x-vercel-id`, `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `x-forwarded-proto`, `x-forwarded-host`), response header injection (`server`, `x-vercel-id`, `x-vercel-cache`), environment variables (`VERCEL`, `VERCEL_ENV`, `VERCEL_REGION`, `NOW_REGION`), header preservation, pre-existing header respect, image optimization (`/_vercel/image` with format detection, Accept header negotiation, parameter validation, cache-control/Vary/Content-Length headers, SVG blocking, remote URL domain/pattern validation, sizes/qualities config enforcement) - Test fixture in `test/fixtures/app-headers.mjs` — Entry that echoes all request headers as JSON for vercel header injection tests - Test fixture in `test/fixtures/app-env.mjs` — Entry that echoes request headers and selected environment variables as JSON +- Test fixture in `test/fixtures/app-image.mjs` — Entry that serves a 1x1 PNG at `/test.png` for vercel image optimization tests diff --git a/AGENTS.md b/AGENTS.md index 6a72067..819e92c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ src/ │ │ ├── runner.ts # VercelEnvRunner (extends NodeWorkerEnvRunner) │ │ ├── worker.ts # Sets Vercel request context symbol, delegates to node-worker │ │ ├── oidc.ts # VERCEL_OIDC_TOKEN check + dev-time warning -│ │ └── queue-dev.ts # Local Vercel Queues delivery bridge (registerDevConsumer) +│ │ ├── queue-dev.ts # Local Vercel Queues delivery bridge (registerDevConsumer) +│ │ └── image.ts # /_vercel/image optimization handler (IPX-based) │ └── netlify/ │ ├── runner.ts # NetlifyEnvRunner (extends NodeWorkerEnvRunner) │ └── worker.ts # Sets global Netlify context, delegates to node-worker @@ -183,6 +184,7 @@ Generic test infrastructure, cross-runner suites (`runners.test.ts`, `manager.te - `cjs-module-lexer` / `es-module-lexer` — CJS named-export detection and ESM import-specifier parsing in the miniflare module fallback service (devDependencies inlined into `dist` by obuild) - `@netlify/runtime` — Netlify compute runtime (optional peer dependency, used by `NetlifyEnvRunner` worker for full `globalThis.Netlify` + `globalThis.caches` setup) - `wrangler` — Cloudflare Wrangler (optional peer dependency, used by `MiniflareEnvRunner`'s `wrangler` option to load a `wrangler.{json,jsonc,toml}` config via `unstable_readConfig` + `unstable_getMiniflareWorkerOptions`; a built-in minimal plain-JSON reader is used when it's absent) +- `ipx` — Image optimization (optional peer dependency, used by `VercelEnvRunner` for `/_vercel/image` endpoint) ## Reference docs (`.agents/`) @@ -191,7 +193,7 @@ Runner-specific and deep-dive notes, split out of this file: - [`.agents/ARCHITECTURE.md`](.agents/ARCHITECTURE.md) — detailed core source-file notes, the shared `BaseEnvRunner` lifecycle, `RunnerManager`/`EnvServer` - [`.agents/NODE-RUNNERS.md`](.agents/NODE-RUNNERS.md) — node-worker, node-process, bun-process, deno-process, and self runners (+ orphan tests) - [`.agents/MINIFLARE.md`](.agents/MINIFLARE.md) — Miniflare internals (`unsafeEvalBinding`, `unsafeModuleFallbackService`, service bindings) **and** the `MiniflareEnvRunner` + wrangler config + tests -- [`.agents/VERCEL.md`](.agents/VERCEL.md) — `VercelEnvRunner` (env vars, header injection, OIDC, Vercel Queues) + tests +- [`.agents/VERCEL.md`](.agents/VERCEL.md) — `VercelEnvRunner` (env vars, header injection, OIDC, Vercel Queues, image optimization) + tests - [`.agents/NETLIFY.md`](.agents/NETLIFY.md) — `NetlifyEnvRunner` (header injection) + tests - [`.agents/VIRTUAL-MODULES.md`](.agents/VIRTUAL-MODULES.md) — virtual modules across Node/Bun/Deno/Miniflare + tests - [`.agents/TESTS.md`](.agents/TESTS.md) — generic test infrastructure, cross-runner suites, shared fixtures diff --git a/package.json b/package.json index d187b0b..ffa68c9 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "cjs-module-lexer": "^2.2.0", "env-runner-fixture": "link:", "es-module-lexer": "^2.2.0", + "ipx": "^4.0.0-alpha.1", "miniflare": "^4.20260625.0", "obuild": "^0.4.37", "oxfmt": "^0.56.0", @@ -70,6 +71,7 @@ "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", + "ipx": "^4.0.0-alpha.1", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, @@ -83,6 +85,9 @@ "@vercel/queue": { "optional": true }, + "ipx": { + "optional": true + }, "wrangler": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d34186..9189357 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: es-module-lexer: specifier: ^2.2.0 version: 2.2.0 + ipx: + specifier: ^4.0.0-alpha.1 + version: 4.0.0-beta.1(@types/node@26.0.1) miniflare: specifier: ^4.20260625.0 version: 4.20260625.0 @@ -359,70 +362,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -430,6 +508,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -437,6 +522,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -444,6 +536,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -451,6 +550,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -458,6 +564,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -465,6 +578,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -472,6 +592,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -479,29 +606,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1570,6 +1731,16 @@ packages: resolution: {integrity: sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==} engines: {node: '>=18'} + ipx@4.0.0-beta.1: + resolution: {integrity: sha512-y6bt8CakzpsSO/TiiD8j+1oM+BFJs4rCiJyg8SyBiEaOUIhOu6DFX7lwCpZB/RnUzxVNQFqU4c4IL4MzyRcEQQ==} + engines: {node: ^20.16.0 || >=22.3.0} + hasBin: true + peerDependencies: + unstorage: '*' + peerDependenciesMeta: + unstorage: + optional: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1929,6 +2100,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1952,6 +2132,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.12.4: + resolution: {integrity: sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g==} + engines: {node: '>=20.16.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2374,95 +2559,199 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.11.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3332,6 +3621,13 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + ipx@4.0.0-beta.1(@types/node@26.0.1): + dependencies: + sharp: 0.35.3(@types/node@26.0.1) + srvx: 0.12.4 + transitivePeerDependencies: + - '@types/node' + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -3737,6 +4033,39 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.3(@types/node@26.0.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.0.1 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3751,6 +4080,8 @@ snapshots: srvx@0.11.19: {} + srvx@0.12.4: {} + stackback@0.0.2: {} std-env@3.10.0: {} diff --git a/src/runners/vercel/image.ts b/src/runners/vercel/image.ts new file mode 100644 index 0000000..f854a2e --- /dev/null +++ b/src/runners/vercel/image.ts @@ -0,0 +1,409 @@ +import type { WorkerAddress } from "../../types.ts"; + +export interface VercelRemotePattern { + protocol?: string; + hostname: string; + port?: string; + pathname?: string; + search?: string; +} + +export interface VercelLocalPattern { + pathname?: string; + search?: string; +} + +export interface VercelImageConfig { + sizes?: number[]; + domains?: string[]; + remotePatterns?: VercelRemotePattern[]; + localPatterns?: VercelLocalPattern[]; + qualities?: number[]; + formats?: string[]; + minimumCacheTTL?: number; + dangerouslyAllowSVG?: boolean; + contentSecurityPolicy?: string; + contentDispositionType?: string; +} + +type IPXModule = typeof import("ipx"); + +let _ipxModule: IPXModule | undefined; +let _ipxLoaded = false; + +async function loadIPX(): Promise { + if (_ipxLoaded) return _ipxModule; + _ipxLoaded = true; + try { + _ipxModule = await import("ipx"); + } catch { + console.warn( + "ipx is not installed. Install it for Vercel image optimization: npx nypm i -D ipx", + ); + } + return _ipxModule; +} + +function resolveWorkerUrl(address: WorkerAddress, path: string): string { + if ("socketPath" in address && address.socketPath) { + throw new Error( + "Vercel image handler requires a TCP worker address (host/port); unix sockets are not supported.", + ); + } + const host = address.host || "127.0.0.1"; + return `http://${host}:${address.port}${path}`; +} + +// --- URL validation --- + +function isRemoteUrl(url: string): boolean { + return /^https?:\/\//.test(url); +} + +// Build Output API uses PCRE regex (^...$), Next.js config uses globs (**, *) +function matchPattern(pattern: string, value: string): boolean { + if (pattern.startsWith("^") && pattern.endsWith("$")) { + return new RegExp(pattern).test(value); + } + let re = "^"; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern.charAt(i); + if (ch === "*" && pattern.charAt(i + 1) === "*") { + re += ".*"; + i++; + } else if (ch === "*") { + re += "[^/]*"; + } else if (".+?{}()[]\\^$|".includes(ch)) { + re += "\\" + ch; + } else { + re += ch; + } + } + return new RegExp(re + "$").test(value); +} + +function matchRemotePattern(pattern: VercelRemotePattern, url: URL): boolean { + if (pattern.protocol && url.protocol !== pattern.protocol + ":") return false; + if (!matchPattern(pattern.hostname, url.hostname)) return false; + if (pattern.port !== undefined && url.port !== pattern.port) return false; + if (pattern.pathname && !matchPattern(pattern.pathname, url.pathname)) return false; + if (pattern.search !== undefined && url.search !== pattern.search) return false; + return true; +} + +function validateRemoteUrl(sourceUrl: string, config?: VercelImageConfig): boolean { + if (!config?.domains?.length && !config?.remotePatterns?.length) { + return true; + } + try { + const parsed = new URL(sourceUrl); + if (config.domains?.includes(parsed.hostname)) return true; + if (config.remotePatterns?.some((p) => matchRemotePattern(p, parsed))) return true; + } catch {} + return false; +} + +function validateLocalUrl(sourceUrl: string, config?: VercelImageConfig): boolean { + if (!config?.localPatterns?.length) return true; + const [pathname = "", search] = sourceUrl.split("?"); + return config.localPatterns.some((p) => { + if (p.pathname && !matchPattern(p.pathname, pathname)) return false; + if (p.search !== undefined && (search || "") !== p.search.replace(/^\?/, "")) return false; + return true; + }); +} + +function isSvgSource(url: string): boolean { + const path = url.startsWith("/") + ? url + : (() => { + try { + return new URL(url).pathname; + } catch { + return url; + } + })(); + return /\.svgz?(\?|$)/i.test(path); +} + +function applySecurityHeaders( + headers: Headers | Record, + sourceUrl: string, + config?: VercelImageConfig, +): void { + const set = (key: string, value: string) => { + if (headers instanceof Headers) headers.set(key, value); + else headers[key] = value; + }; + if (config?.contentSecurityPolicy) { + set("content-security-policy", config.contentSecurityPolicy); + } else if (config?.dangerouslyAllowSVG) { + // Match Next.js default CSP when SVGs are allowed + set("content-security-policy", "script-src 'none'; frame-src 'none'; sandbox;"); + } + if (config?.contentDispositionType) { + const filename = sourceUrl.split("/").pop()?.split("?")[0] || "image"; + set("content-disposition", `${config.contentDispositionType}; filename="${filename}"`); + } +} + +// --- Unoptimized fallback --- + +async function fetchUnoptimized( + sourceUrl: string, + getAddress: () => WorkerAddress | undefined, + config?: VercelImageConfig, + cacheTTL?: number, +): Promise { + let res: Response; + if (sourceUrl.startsWith("/")) { + const address = getAddress(); + if (!address) { + return new Response("Runner not ready", { status: 503 }); + } + res = await fetch(resolveWorkerUrl(address, sourceUrl)); + } else { + res = await fetch(sourceUrl); + } + + const headers = new Headers(res.headers); + const contentType = headers.get("content-type") || ""; + if (!/^image\//i.test(contentType)) { + return new Response('"url" parameter is valid but upstream is not an image', { + status: 400, + }); + } + if (!config?.dangerouslyAllowSVG && /^image\/svg\+xml\b/i.test(contentType)) { + return new Response('"url" parameter is valid but image type is not allowed', { + status: 400, + }); + } + const existingVary = headers.get("vary"); + if (!existingVary) { + headers.set("vary", "Accept"); + } else if (!/(^|,\s*)Accept(\s*,|\s*$)/i.test(existingVary)) { + headers.set("vary", `${existingVary}, Accept`); + } + if (!headers.has("cache-control")) { + const ttl = cacheTTL ?? config?.minimumCacheTTL ?? 60; + headers.set("cache-control", `public, max-age=${ttl}, s-maxage=${ttl}`); + } + applySecurityHeaders(headers, sourceUrl, config); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); +} + +// --- Handler factory --- + +export interface VercelImageHandler { + handle: (request: Request) => Promise; + close: () => void; +} + +export function createVercelImageHandler(opts: { + getAddress: () => WorkerAddress | undefined; + config?: VercelImageConfig; +}): VercelImageHandler { + const { getAddress, config } = opts; + + let _ipx: ReturnType | undefined; + let _ipxPromise: Promise | undefined> | undefined; + + async function getIPX() { + if (_ipx) return _ipx; + if (_ipxPromise) return _ipxPromise; + _ipxPromise = (async () => { + const ipxModule = await loadIPX(); + if (!ipxModule) return undefined; + + const workerStorage: import("ipx").IPXStorage = { + name: "vercel:worker", + async getMeta(id) { + const address = getAddress(); + if (!address) return undefined; + try { + const res = await fetch(resolveWorkerUrl(address, id), { method: "HEAD" }); + if (!res.ok) return undefined; + const lastModified = res.headers.get("last-modified"); + return { + mtime: lastModified ? new Date(lastModified) : undefined, + maxAge: config?.minimumCacheTTL ?? 60, + }; + } catch { + return undefined; + } + }, + async getData(id) { + const address = getAddress(); + if (!address) return undefined; + try { + const res = await fetch(resolveWorkerUrl(address, id)); + if (!res.ok) return undefined; + return await res.arrayBuffer(); + } catch { + return undefined; + } + }, + }; + + // Remote URL validation is handled before calling ipx(), so + // allow all domains here and let our validation layer handle restrictions + _ipx = ipxModule.createIPX({ + storage: workerStorage, + httpStorage: ipxModule.ipxHttpStorage({ allowAllDomains: true }), + maxAge: config?.minimumCacheTTL ?? 60, + }); + + return _ipx; + })(); + return _ipxPromise; + } + + return { + close() { + _ipx = undefined; + _ipxPromise = undefined; + }, + async handle(request: Request): Promise { + const url = new URL(request.url); + + const sourceUrl = url.searchParams.get("url"); + const w = url.searchParams.get("w"); + const q = url.searchParams.get("q") || "75"; + const f = url.searchParams.get("f"); + const fit = url.searchParams.get("fit"); + const h = url.searchParams.get("h"); + const blur = url.searchParams.get("blur"); + + if (!sourceUrl) { + return new Response('"url" parameter is required', { status: 400 }); + } + if (!w) { + return new Response('"w" parameter is required', { status: 400 }); + } + + const width = Number.parseInt(w); + if (Number.isNaN(width) || width <= 0) { + return new Response('"w" must be a positive integer', { status: 400 }); + } + + const quality = Number.parseInt(q); + if (Number.isNaN(quality) || quality < 1 || quality > 100) { + return new Response('"q" must be between 1 and 100', { status: 400 }); + } + + if (config?.sizes?.length && !config.sizes.includes(width)) { + return new Response(`"w" must be one of: ${config.sizes.join(", ")}`, { status: 400 }); + } + + if (config?.qualities?.length && !config.qualities.includes(quality)) { + return new Response(`"q" must be one of: ${config.qualities.join(", ")}`, { status: 400 }); + } + + if (f && config?.formats?.length && !config.formats.includes(f)) { + return new Response(`"f" must be one of: ${config.formats.join(", ")}`, { status: 400 }); + } + + // Reject protocol-relative URLs to avoid local/remote ambiguity + if (sourceUrl.startsWith("//")) { + return new Response('"url" parameter is not allowed', { status: 400 }); + } + + // Validate source URL against allowlists + const isLocal = sourceUrl.startsWith("/"); + const isRemote = isRemoteUrl(sourceUrl); + if (!isLocal && !isRemote) { + return new Response('"url" parameter is not allowed', { status: 400 }); + } + if (isRemote && !validateRemoteUrl(sourceUrl, config)) { + return new Response('"url" parameter is not allowed', { status: 400 }); + } + if (isLocal && !validateLocalUrl(sourceUrl, config)) { + return new Response('"url" parameter is not allowed', { status: 400 }); + } + + // Block SVG unless explicitly allowed + if (!config?.dangerouslyAllowSVG && isSvgSource(sourceUrl)) { + return new Response('"url" parameter is valid but image type is not allowed', { + status: 400, + }); + } + + const cacheOverride = Number.parseInt(url.searchParams.get("cache") || ""); + const cacheTTL = + Number.isFinite(cacheOverride) && cacheOverride > 0 + ? cacheOverride + : (config?.minimumCacheTTL ?? 60); + + const ipx = await getIPX(); + if (!ipx) { + return fetchUnoptimized(sourceUrl, getAddress, config, cacheTTL); + } + + // Build IPX modifiers + const modifiers: Record = { width, quality }; + if (h) { + const height = Number.parseInt(h); + if (!Number.isNaN(height) && height > 0) { + modifiers.height = height; + } + } + if (fit) { + modifiers.fit = fit; + } + if (blur) { + const blurValue = Number.parseInt(blur); + if (!Number.isNaN(blurValue) && blurValue > 0) { + modifiers.blur = blurValue; + } + } + + // Format: explicit param > Accept header negotiation + if (f) { + modifiers.format = f.replace("image/", ""); + } else { + const accept = request.headers.get("accept") || ""; + const allowed = config?.formats?.map((fmt) => fmt.replace(/^image\//, "")); + const isAllowed = (fmt: string) => !allowed || allowed.includes(fmt); + if (accept.includes("image/avif") && isAllowed("avif")) { + modifiers.format = "avif"; + } else if (accept.includes("image/webp") && isAllowed("webp")) { + modifiers.format = "webp"; + } + } + + try { + const img = ipx(sourceUrl, modifiers); + const { data, format } = await img.process(); + + // Defense in depth: block SVG output even if the URL check was bypassed + if (!config?.dangerouslyAllowSVG && format === "svg+xml") { + return new Response('"url" parameter is valid but image type is not allowed', { + status: 400, + }); + } + + const contentType = format ? `image/${format}` : "application/octet-stream"; + const body = + typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + + const headers: Record = { + "content-type": contentType, + "content-length": String(body.byteLength), + "cache-control": `public, max-age=${cacheTTL}, s-maxage=${cacheTTL}`, + vary: "Accept", + }; + applySecurityHeaders(headers, sourceUrl, config); + + return new Response(body, { headers }); + } catch (error: any) { + const status = error.statusCode || 500; + return new Response(error.message || "Image optimization failed", { status }); + } + }, + }; +} diff --git a/src/runners/vercel/runner.ts b/src/runners/vercel/runner.ts index 3714309..ec0494d 100644 --- a/src/runners/vercel/runner.ts +++ b/src/runners/vercel/runner.ts @@ -5,9 +5,14 @@ import { fileURLToPath } from "node:url"; import type { EnvRunnerData } from "../../common/base-runner.ts"; import { NodeWorkerEnvRunner } from "../node-worker/runner.ts"; +import { + type VercelImageConfig, + type VercelImageHandler, + createVercelImageHandler, +} from "./image.ts"; import { warnIfVercelOidcTokenInvalid } from "./oidc.ts"; -export type { EnvRunnerData }; +export type { EnvRunnerData, VercelImageConfig }; let _defaultEntry: string; @@ -21,15 +26,20 @@ function generateVercelId(): string { } export class VercelEnvRunner extends NodeWorkerEnvRunner { + private _imageHandler?: VercelImageHandler; + private _imageConfig?: VercelImageConfig; + constructor(opts: { name: string; workerEntry?: string; hooks?: WorkerHooks; data?: EnvRunnerData; + images?: VercelImageConfig; }) { _defaultEntry ||= fileURLToPath(import.meta.resolve("env-runner/runners/vercel/worker")); super({ ...opts, workerEntry: opts.workerEntry || _defaultEntry }); warnIfVercelOidcTokenInvalid(); + this._imageConfig = opts.images; } override async fetch(input: string | URL | Request, init?: RequestInit): Promise { @@ -62,19 +72,37 @@ export class VercelEnvRunner extends NodeWorkerEnvRunner { headers.set("x-real-ip", clientIp); } + let requestUrl: URL | undefined; try { - const url = new URL(input instanceof Request ? input.url : input.toString()); + requestUrl = new URL(input instanceof Request ? input.url : input.toString()); if (!headers.has("x-forwarded-proto")) { - headers.set("x-forwarded-proto", url.protocol.replace(":", "")); + headers.set("x-forwarded-proto", requestUrl.protocol.replace(":", "")); } if (!headers.has("x-forwarded-host")) { - headers.set("x-forwarded-host", headers.get("host") || url.host); + headers.set("x-forwarded-host", headers.get("host") || requestUrl.host); } } catch { // URL parsing failed, skip proto/host headers } - const res = await super.fetch(input, { ...init, headers }); + let res: Response; + if (requestUrl?.pathname === "/_vercel/image") { + if (!this._address) { + await this.waitForReady().catch(() => {}); + } + if (!this._address) { + return new Response("vercel env runner is unavailable", { status: 503 }); + } + this._imageHandler ||= createVercelImageHandler({ + getAddress: () => this._address, + config: this._imageConfig, + }); + res = await this._imageHandler.handle(new Request(requestUrl, { headers })); + } else if (input instanceof Request) { + res = await super.fetch(new Request(input, { ...init, headers })); + } else { + res = await super.fetch(input, { ...init, headers }); + } // Inject Vercel response headers const resHeaders = new Headers(res.headers); @@ -95,6 +123,12 @@ export class VercelEnvRunner extends NodeWorkerEnvRunner { }); } + override async close(cause?: unknown) { + this._imageHandler?.close(); + this._imageHandler = undefined; + await super.close(cause); + } + protected override _runtimeType() { return "vercel"; } diff --git a/test/fixtures/app-image.mjs b/test/fixtures/app-image.mjs new file mode 100644 index 0000000..9efba9a --- /dev/null +++ b/test/fixtures/app-image.mjs @@ -0,0 +1,17 @@ +// Minimal 1x1 red PNG (base64-encoded) +const PNG_1x1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "base64", +); + +export default { + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/test.png") { + return new Response(PNG_1x1, { + headers: { "content-type": "image/png" }, + }); + } + return new Response("ok"); + }, +}; diff --git a/test/vercel.test.ts b/test/vercel.test.ts index b568e98..d2d38ba 100644 --- a/test/vercel.test.ts +++ b/test/vercel.test.ts @@ -10,6 +10,7 @@ const _dir = dirname(fileURLToPath(import.meta.url)); const headersEntry = resolve(_dir, "./fixtures/app-headers.mjs"); const envEntry = resolve(_dir, "./fixtures/app-env.mjs"); const appEntry = resolve(_dir, "./fixtures/app.mjs"); +const imageEntry = resolve(_dir, "./fixtures/app-image.mjs"); describe("VercelEnvRunner", () => { let runner: VercelEnvRunner | undefined; @@ -162,4 +163,230 @@ describe("VercelEnvRunner", () => { expect(env.VERCEL_REGION).toBeUndefined(); expect(env.NOW_REGION).toBeUndefined(); }); + + // /_vercel/image optimization tests + describe("image optimization", () => { + it("returns optimized image for local source", async () => { + runner = new VercelEnvRunner({ name: "test-img", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/^image\//); + }); + + it("returns correct format when f param is provided", async () => { + runner = new VercelEnvRunner({ name: "test-img-fmt", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch( + "http://localhost/_vercel/image?url=/test.png&w=1&q=75&f=image/webp", + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/webp"); + }); + + it("auto-detects format from Accept header", async () => { + runner = new VercelEnvRunner({ name: "test-img-accept", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch( + new Request("http://localhost/_vercel/image?url=/test.png&w=1&q=75", { + headers: { accept: "image/webp,image/png,*/*" }, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/webp"); + }); + + it("returns 400 for missing url param", async () => { + runner = new VercelEnvRunner({ name: "test-img-nourl", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?w=100&q=75"); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing w param", async () => { + runner = new VercelEnvRunner({ name: "test-img-now", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&q=75"); + expect(res.status).toBe(400); + }); + + it("includes vercel response headers on image responses", async () => { + runner = new VercelEnvRunner({ name: "test-img-headers", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.headers.get("server")).toBe("Vercel"); + expect(res.headers.get("x-vercel-id")).toMatch(/^dev1::/); + expect(res.headers.get("x-vercel-cache")).toBe("MISS"); + }); + + it("sets cache-control header", async () => { + runner = new VercelEnvRunner({ name: "test-img-cache", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.headers.get("cache-control")).toMatch(/max-age=\d+/); + }); + + it("sets Vary: Accept header for format negotiation", async () => { + runner = new VercelEnvRunner({ name: "test-img-vary", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.headers.get("vary")).toBe("Accept"); + }); + + it("sets Content-Length header", async () => { + runner = new VercelEnvRunner({ name: "test-img-cl", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + const cl = res.headers.get("content-length"); + expect(cl).toBeTruthy(); + expect(Number(cl)).toBeGreaterThan(0); + }); + + it("blocks SVG sources by default", async () => { + runner = new VercelEnvRunner({ name: "test-img-svg", data: { entry: imageEntry } }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/icon.svg&w=100&q=75"); + expect(res.status).toBe(400); + expect(await res.text()).toContain("image type is not allowed"); + }); + + it("allows SVG when dangerouslyAllowSVG is true", async () => { + runner = new VercelEnvRunner({ + name: "test-img-svg-allow", + data: { entry: imageEntry }, + images: { dangerouslyAllowSVG: true }, + }); + await runner.waitForReady(); + // The fixture doesn't actually serve SVG, but validation should pass + // (will fail at IPX level, not at our validation) + const res = await runner.fetch("http://localhost/_vercel/image?url=/icon.svg&w=100&q=75"); + // Should not be 400 "image type is not allowed" + expect(await res.text()).not.toContain("image type is not allowed"); + }); + + it("returns 400 for disallowed remote URL when domains configured", async () => { + runner = new VercelEnvRunner({ + name: "test-img-remote-blocked", + data: { entry: imageEntry }, + images: { domains: ["allowed.invalid"] }, + }); + await runner.waitForReady(); + const res = await runner.fetch( + "http://localhost/_vercel/image?url=https://evil.invalid/img.png&w=100&q=75", + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain('"url" parameter is not allowed'); + }); + + it("allows remote URL when domain matches", async () => { + runner = new VercelEnvRunner({ + name: "test-img-remote-allowed", + data: { entry: imageEntry }, + images: { domains: ["allowed.invalid"] }, + }); + await runner.waitForReady(); + // Will pass validation but fail to fetch (no such host) + const res = await runner.fetch( + "http://localhost/_vercel/image?url=https://allowed.invalid/img.png&w=100&q=75", + ); + // Should NOT be 400 "url parameter is not allowed" + expect(await res.text()).not.toContain('"url" parameter is not allowed'); + }); + + it("validates against remotePatterns (glob format)", async () => { + runner = new VercelEnvRunner({ + name: "test-img-remote-pattern", + data: { entry: imageEntry }, + images: { + remotePatterns: [{ protocol: "https", hostname: "cdn.invalid" }], + }, + }); + await runner.waitForReady(); + + // Blocked: different hostname + const blocked = await runner.fetch( + "http://localhost/_vercel/image?url=https://other.invalid/img.png&w=100&q=75", + ); + expect(blocked.status).toBe(400); + + // Allowed: matching pattern (will fail to fetch but passes validation) + const allowed = await runner.fetch( + "http://localhost/_vercel/image?url=https://cdn.invalid/img.png&w=100&q=75", + ); + expect(await allowed.text()).not.toContain('"url" parameter is not allowed'); + }); + + it("validates against remotePatterns (Build Output API regex format)", async () => { + runner = new VercelEnvRunner({ + name: "test-img-remote-regex", + data: { entry: imageEntry }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "^cdn\\.invalid$", + pathname: "^/assets/.*$", + }, + ], + }, + }); + await runner.waitForReady(); + + // Blocked: wrong hostname + const blocked1 = await runner.fetch( + "http://localhost/_vercel/image?url=https://other.invalid/assets/img.png&w=100&q=75", + ); + expect(blocked1.status).toBe(400); + expect(await blocked1.text()).toContain('"url" parameter is not allowed'); + + // Blocked: wrong pathname + const blocked2 = await runner.fetch( + "http://localhost/_vercel/image?url=https://cdn.invalid/other/img.png&w=100&q=75", + ); + expect(blocked2.status).toBe(400); + + // Allowed: matches regex pattern (will fail to fetch but passes validation) + const allowed = await runner.fetch( + "http://localhost/_vercel/image?url=https://cdn.invalid/assets/img.png&w=100&q=75", + ); + expect(await allowed.text()).not.toContain('"url" parameter is not allowed'); + }); + + it("returns 400 for width not in configured sizes", async () => { + runner = new VercelEnvRunner({ + name: "test-img-sizes", + data: { entry: imageEntry }, + images: { sizes: [64, 128, 256] }, + }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=100&q=75"); + expect(res.status).toBe(400); + expect(await res.text()).toContain('"w" must be one of'); + }); + + it("returns 400 for quality not in configured qualities", async () => { + runner = new VercelEnvRunner({ + name: "test-img-qualities", + data: { entry: imageEntry }, + images: { qualities: [50, 75, 100] }, + }); + await runner.waitForReady(); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=60"); + expect(res.status).toBe(400); + expect(await res.text()).toContain('"q" must be one of'); + }); + + it("allows remote images when no domain restrictions configured", async () => { + runner = new VercelEnvRunner({ + name: "test-img-remote-open", + data: { entry: imageEntry }, + }); + await runner.waitForReady(); + // No domains/remotePatterns = allow all (will fail to actually fetch) + const res = await runner.fetch( + "http://localhost/_vercel/image?url=https://any.invalid/img.png&w=100&q=75", + ); + expect(await res.text()).not.toContain('"url" parameter is not allowed'); + }); + }); }); From 56c049609b0b55b7aeccc9eae60973198d8ebfea Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Mon, 3 Aug 2026 19:01:11 +0100 Subject: [PATCH 2/3] chore: improve code --- .agents/VERCEL.md | 51 +++- AGENTS.md | 1 + package.json | 5 +- pnpm-lock.yaml | 2 +- src/index.ts | 7 + src/runners/vercel/image.ts | 526 +++++++++++++++++++++++------------ src/runners/vercel/runner.ts | 36 ++- test/vercel-image.test.ts | 512 ++++++++++++++++++++++++++++++++++ test/vercel.test.ts | 258 ++++++----------- 9 files changed, 1012 insertions(+), 386 deletions(-) create mode 100644 test/vercel-image.test.ts diff --git a/.agents/VERCEL.md b/.agents/VERCEL.md index 50f2857..c4ad86b 100644 --- a/.agents/VERCEL.md +++ b/.agents/VERCEL.md @@ -8,7 +8,7 @@ Extends `NodeWorkerEnvRunner` to simulate a Vercel deployment environment. - **`src/runners/vercel/worker.ts`** — Sets Vercel env vars and `Symbol.for("@vercel/request-context")` on globalThis, delegates to node-worker worker - **`src/runners/vercel/oidc.ts`** — `_checkVercelOidcToken()` decodes `VERCEL_OIDC_TOKEN` (JWT `exp` claim, no signature check) and returns `{ status: "missing" | "valid" | "expired" | "invalid", expiresAt? }`. `warnIfVercelOidcTokenInvalid()` logs a one-time dev warning hinting the user to run `vercel env pull`. Called from the `VercelEnvRunner` constructor - **`src/runners/vercel/queue-dev.ts`** — Bridge for local Vercel Queues delivery. `await registerVercelQueueConsumer({ topic, handler, consumerGroup?, visibilityTimeoutSeconds?, retry?, retryAfterSeconds? })` lets framework plugins bind a topic to a dispatcher; the first call lazy-loads `@vercel/queue` and constructs a shared `QueueClient`. Resolves to an unregister function. Re-registering the same `consumerGroup` on a topic replaces the handler via the SDK's own `consumerGroup` keying (HMR-safe; the unregister for a replaced registration becomes a no-op). `retryAfterSeconds` is a shorthand for `retry: () => ({ afterSeconds })`; pass `retry` for richer directives like `{ acknowledge: true }` -- **`src/runners/vercel/image.ts`** — `createVercelImageHandler()`: handles `/_vercel/image` requests using IPX for image optimization. Supports `url`, `w`, `h`, `q`, `f`, `fit`, `blur`, `cache` query params. Validates remote URLs against `domains`/`remotePatterns`, local URLs against `localPatterns`, blocks SVG by default. Falls back to unoptimized proxy when `ipx` is not installed +- **`src/runners/vercel/image.ts`** — `createVercelImageHandler()`: handles `/_vercel/image` requests using IPX for image optimization. Serves `GET`/`HEAD` only (405 otherwise) and supports the `url`, `w`, `q`, `f` query params. `parseImageRequest()` parses and validates in one place (returning a `Response` for every rejection so Vercel's plain-text messages pass straight through): remote URLs are default-deny unless matched by `domains`/`remotePatterns`, local URLs are narrowed by `localPatterns`, SVG is blocked by default. It then delegates to ipx v4's `createIPXFetchHandler()` (ETag/304, content-type, security headers), whose `parseURL` closes over that single parse. Falls back to unoptimized proxy when `ipx` is not installed ## How it works @@ -22,7 +22,11 @@ Extends `NodeWorkerEnvRunner` to simulate a Vercel deployment environment. The w `VERCEL_REGION` and `NOW_REGION` are intentionally not defaulted — Vercel SDKs rely on them being valid region identifiers when set, so they must be explicitly provided if required. -**Request header injection:** Overrides `fetch()` to inject Vercel-specific headers before delegating to the parent: +**Request header injection:** Overrides `fetch()` to inject Vercel-specific headers before delegating to the parent. `fetch()` awaits readiness up front (`waitForReady()`, which rejects immediately once the runner is closed) because both dispatch paths need the worker address and `x-vercel-deployment-url` is derived from it — without the wait, the first request to a cold runner would silently omit that header. A runner that never reports an address yields a single `503 "vercel env runner is unavailable"` that still carries the Vercel response headers. + +A `Request` input is forwarded to `super.fetch()` **as-is**, never re-wrapped in `new Request(input, …)`: `cli.ts` hands the front server's own request object straight through, and srvx's request class passes `instanceof Request` while the undici `Request` constructor refuses to clone it (`Cannot read private member #state …`). Header injection still wins because httpxy's `proxyFetch()` merges `init` over a `Request`'s own fields. The `/_vercel/image` branch builds its request from the parsed `URL` instead, for the same reason. `test/vercel.test.ts` covers this through a real srvx server. + +Injected headers: - `x-vercel-deployment-url` — constructed from the worker's address (`http://:`) - `x-vercel-id` — unique request ID in format `dev1::--` (stable podId per process, matches vercel dev behavior) @@ -44,24 +48,43 @@ All headers are only injected when not already present in the request/response. **Image optimization (`/_vercel/image`):** Intercepts requests to `/_vercel/image` and processes images using IPX (optional `ipx` peer dependency). Supports Vercel's image optimization query parameters: - `url` (required) — source image URL (local path or absolute URL) -- `w` (required) — output width in pixels -- `q` (optional, default 75) — quality 1–100 -- `f` (optional) — output format as MIME type (`image/webp`, `image/avif`, etc.) -- `h` (optional) — output height in pixels -- `fit` (optional) — resize mode (`cover`, `contain`, `fill`, `inside`, `outside`) -- `blur` (optional) — blur amount -- `cache` (optional) — cache TTL override in seconds +- `w` (required) — output width in pixels. Must be a bare non-negative integer (`^\d+$`); `parseInt`-style inputs like `8abc`, `0x10`, `1e3` are rejected +- `q` (optional, default 75) — quality 1–100, same strict-integer rule as `w`. When `qualities` is configured, an omitted `q` snaps to the configured value closest to 75 instead of being rejected +- `f` (optional) — output format, as a MIME type (`image/webp`) or a bare name (`webp`); both forms are compared against `formats` with the `image/` prefix stripped + +These are exactly the params the real `/_vercel/image` endpoint honors (`url`, `w`, `q` — `f` is our internal format-pinning param, see below). Vercel ignores any other query param, so we deliberately do **not** support `h`/`fit`/`blur`/`cache`: honoring them in dev would produce transforms that silently vanish in production. `f` aside, unknown params are ignored, matching Vercel. + +Only `GET` and `HEAD` are served; any other method gets `405` with `Allow: GET, HEAD` before the query is even parsed, so a stray `POST` can't silently return an optimized image. + +Format auto-detection from `Accept` header when `f` is not provided (prefers avif > webp). ipx's own `f=auto` is deliberately **not** used: its `autoDetectFormat()` falls back to `jpeg` when the `Accept` header offers nothing better, which would flatten PNG transparency, whereas Vercel keeps the source format. Response includes `Vary: Accept` for proper cache keying. Local images are fetched from the worker; remote images are fetched directly. + +**Unoptimized fallback:** when `ipx` is not installed, the handler warns once and proxies the source image. This path is kept at parity with the ipx path rather than being the weaker one: a non-ok upstream forwards its own status with `"url" parameter is valid but upstream response is invalid` (a missing local source is a 404, not a 400 "not an image"), an unreachable upstream is a `502` instead of an escaping fetch rejection, and `content-security-policy: default-src 'none'` is applied to match what ipx sets on its own responses. + +**Worker address:** local sources are fetched over TCP from `getAddress()`. `VercelEnvRunner` always listens on TCP, but `createVercelImageHandler()` is public API taking an arbitrary `getAddress`, so a `socketPath` address is rejected up front with a `500` naming the limitation — otherwise it would build `http://undefined:undefined/…` and the failed fetch would surface as a misleading 404 "resource not found". The check is scoped to local sources; a remote source never touches the worker. + +**ipx wiring (v4):** the ipx **instance** is built once, lazily, and memoized for the lifetime of the runner (`close()` drops it) — it is the expensive half, since it memoizes the `sharp` and `svgo` dynamic imports. The **fetch handler** around it is built per request, because `createIPXFetchHandler(ipx, { parseURL })` is only a closure allocation plus an `Object.assign` (~15µs measured, against ~490ms for one sharp encode). + +That split is what keeps format negotiation in one place. `parseURL` is ipx v4's supported hook for a non-default URL style, but its signature is `(url: string) => IPXParsedURL` — it never sees the request, so it cannot read the `Accept` header. With a memoized handler, `parseURL` would be a long-lived closure that has to re-derive the modifiers from the URL alone; the negotiated format would then have to be smuggled back into the URL as an explicit `f=` so the second parse agreed with the first. Building the handler per request instead lets `parseURL` close over the parse `handle()` already did (`() => ({ id: sourceUrl, modifiers })`), so the query is parsed once, `Accept` is read once, and the request is handed to ipx unmodified. Because nothing re-wraps it, a framework mounting the handler on its own server can pass that server's request class straight through. + +ipx owns `content-type`, `etag` (weak, from the source `mtime` + modifiers when available, content-hashed otherwise), `if-none-match`/`if-modified-since` → `304`, `last-modified`, `content-security-policy: default-src 'none'` and `x-content-type-options: nosniff`. The handler then overrides `cache-control` (Vercel semantics, `minimumCacheTTL`), ensures `Vary: Accept`, applies the configured CSP/`content-disposition` (quotes and backslashes are stripped from the filename, which would otherwise terminate the quoted value), and buffers the body to set `content-length` (undici does not derive it from a `Uint8Array` body). **ipx error bodies are re-stated, statuses are kept.** ipx answers failures with an `HTTPError` JSON body naming its own `IPX_*` code and the resolved source path (`{"status":404,"statusText":"IPX_RESOURCE_NOT_FOUND","message":"Resource not found: /sample.jpg"}`). The statuses are already right — better than a blanket 500 — so `IPX_ERROR_MESSAGES` maps each one onto the plain-text message the rest of the endpoint uses, keeping the status: `404`/`502` → `"url" parameter is valid but upstream response is invalid`, `403` (forbidden host/IP) → `"url" parameter is not allowed`, `400` → `"url" parameter is valid but upstream is not an image`, anything else → `Image optimization failed`. That keeps the ipx and unoptimized-fallback paths answering alike and stops ipx internals reaching the client. The only reachable `400`s are undecodable sources (`IPX_INVALID_IMAGE`, `IPX_INVALID_SVG`) — the modifiers ipx receives are just width/quality/format, all validated by `parseImageRequest()` first. A throw escaping ipx's own error handling is not expected, so it is logged once via `console.warn` before the body is normalized the same way. + +Two ipx v4 defaults are relied on rather: `maxOutputDimension` (8192) clamps `w`/`h` so a request cannot make sharp allocate a huge buffer, and SVG output is always sanitized (scripts, `on*` handlers, `javascript:` URIs) plus optimized with svgo. + +**URL validation:** Remote sources are **default-deny**, matching Vercel — with neither `domains` nor `remotePatterns` configured, every remote `url` is rejected with 400 `"url" parameter is not allowed`. Allowing them by default would both diverge from production and turn the dev server into an open image proxy. Configured remotes are matched against `domains` (exact hostname) and `remotePatterns` (protocol, hostname glob/regex, port, pathname glob/regex). Local paths are allowed unless narrowed by `localPatterns`. Compiled patterns are cached in a module-level `Map` since they are re-tested on every request. SVG sources are blocked by default (400) unless `dangerouslyAllowSVG` is true. + +Because ipx follows redirects itself, the allowlist is also handed to `ipxHttpStorage({ domains })` whenever every configured hostname is a literal (`literalHostnames()`), so each redirect hop is re-validated and an allowlisted host cannot bounce the fetch to an internal address. Configs using globs or Build Output API regexes fall back to `allowAllDomains: true` (only the initial URL is gated, by our own matcher). -Format auto-detection from `Accept` header when `f` is not provided (prefers avif > webp). Response includes `Vary: Accept` for proper cache keying. Local images are fetched from the worker; remote images are fetched directly. When `ipx` is not installed, warns once and falls back to proxying the unoptimized source image. +Constructor accepts optional `images` config (`VercelImageConfig`) matching the Vercel Build Output API `images` property: `sizes`, `domains`, `remotePatterns`, `localPatterns`, `qualities`, `formats`, `minimumCacheTTL`, `dangerouslyAllowSVG`, `contentSecurityPolicy`, `contentDispositionType`. Plus one env-runner extension: `blockPrivateIPs` (**default `true`**) forwards to `ipxHttpStorage` to reject remote sources that are, or resolve to, a non-public IP. It only affects remote sources — local paths always go through the worker storage, never `ipxHttpStorage`. Set it to `false` to optimize from a `localhost`/in-cluster origin. -**URL validation:** Remote URLs are validated against `domains` (exact hostname match) and `remotePatterns` (protocol, hostname glob, port, pathname glob). Returns 400 when a remote URL doesn't match. Local URLs can be restricted via `localPatterns`. SVG sources are blocked by default (400) unless `dangerouslyAllowSVG` is true. +`createVercelImageHandler()`, `VercelImageConfig` and friends are exported from both `env-runner` and `env-runner/runners/vercel/image`, so a framework can mount the same handler without constructing a `VercelEnvRunner`. It takes `{ getAddress, config }` — `getAddress` is polled per request so it survives hot-reloads, and returns 503 (unoptimized fallback) or 404 (ipx path) while the worker has no address yet. Mounted under `VercelEnvRunner` that window doesn't arise, since `fetch()` awaits readiness before dispatching. -Constructor accepts optional `images` config (`VercelImageConfig`) matching the Vercel Build Output API `images` property: `sizes`, `domains`, `remotePatterns`, `localPatterns`, `qualities`, `formats`, `minimumCacheTTL`, `dangerouslyAllowSVG`, `contentSecurityPolicy`, `contentDispositionType`. +**Known gaps** (not implemented, and worth knowing before treating this as production-shaped): there is no result cache, so an identical request re-runs sharp every time (~490ms measured for a 2.7 MB JPEG → `w=1080` AVIF), and revalidation is only cheap when the worker sends `last-modified` — that lets ipx build a weak ETag from `mtime` and answer 304 in ~1ms, whereas without it ipx must fully re-encode just to compute a content ETag (~484ms, plus a second worker fetch). There is also no cap on concurrent sharp encodes. ## Testing - Vercel suites (`test/vercel.test.ts` and the Vercel entry in `test/runners.test.ts`) stub a fake far-future `VERCEL_OIDC_TOKEN` via `vi.stubEnv` so the OIDC check doesn't log warnings (real env token takes precedence) -- **`test/vercel.test.ts`** — Tests for `VercelEnvRunner`: request header injection (`x-vercel-deployment-url`, `x-vercel-id`, `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `x-forwarded-proto`, `x-forwarded-host`), response header injection (`server`, `x-vercel-id`, `x-vercel-cache`), environment variables (`VERCEL`, `VERCEL_ENV`, `VERCEL_REGION`, `NOW_REGION`), header preservation, pre-existing header respect, image optimization (`/_vercel/image` with format detection, Accept header negotiation, parameter validation, cache-control/Vary/Content-Length headers, SVG blocking, remote URL domain/pattern validation, sizes/qualities config enforcement) +- **`test/vercel.test.ts`** — Tests for `VercelEnvRunner`: request header injection (`x-vercel-deployment-url`, `x-vercel-id`, `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `x-forwarded-proto`, `x-forwarded-host`), response header injection (`server`, `x-vercel-id`, `x-vercel-cache`), environment variables (`VERCEL`, `VERCEL_ENV`, `VERCEL_REGION`, `NOW_REGION`), header preservation, pre-existing header respect. Its `image optimization` block covers **wiring only** — that `/_vercel/image` reaches the handler, that request headers and the request method survive the hop, that the `images` config is threaded through, and that the Vercel response headers are injected on both success and 400 responses. Its `host-runtime request objects` block starts a real srvx server and passes srvx's own request through `fetch()` (both the normal and the image path) — this is the only coverage for the no-re-wrap rule above, since every other test calls `fetch()` with a URL string or an undici `Request` +- **`test/vercel-image.test.ts`** — Unit tests for `createVercelImageHandler()` with a stub `getAddress`, so the whole matrix runs without spawning a worker (~300ms). A single `node:http` server doubles as the worker and as the "remote" origin for the `domains`/`remotePatterns`/`blockPrivateIPs` cases; it decodes the request path so the percent-encoded `/od"d.png` route can exercise `content-disposition` filename escaping. Covers request-method gating (GET/HEAD vs 405), `socketPath` rejection for local sources (and non-rejection for remote ones), parameter validation, remote default-deny, `localPatterns`, SVG, cache-control/`cache`/Vary/content-length/content-disposition/baseline CSP, ETag + 304, format negotiation (including through a real srvx server, since the request now reaches ipx unwrapped), and upstream failures with their re-stated bodies (404 missing source, 400 undecodable source, 403 blocked private IP — each asserting the plain-text message rather than only the status, which is what pins the ipx JSON out of the response). A trailing `describe` re-imports the module under `vi.doMock("ipx")` to exercise the unoptimized fallback (warn-once, non-image upstream, forwarded upstream status, unreachable-upstream 502, baseline CSP, 405, 503 with no address) - Test fixture in `test/fixtures/app-headers.mjs` — Entry that echoes all request headers as JSON for vercel header injection tests - Test fixture in `test/fixtures/app-env.mjs` — Entry that echoes request headers and selected environment variables as JSON -- Test fixture in `test/fixtures/app-image.mjs` — Entry that serves a 1x1 PNG at `/test.png` for vercel image optimization tests +- Test fixture in `test/fixtures/app-image.mjs` — Entry that serves a 1x1 PNG at `/test.png` for the vercel image wiring tests diff --git a/AGENTS.md b/AGENTS.md index 819e92c..e1e48bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ const runner2 = new NodeProcessEnvRunner({ - `env-runner/runners/miniflare` (`./runners/miniflare`) — Direct import of `MiniflareEnvRunner` - `env-runner/runners/vercel` (`./runners/vercel`) — Direct import of `VercelEnvRunner` - `env-runner/runners/vercel/worker` (`./runners/vercel/worker`) — Vercel worker (sets request context, delegates to node-worker) +- `env-runner/runners/vercel/image` (`./runners/vercel/image`) — `createVercelImageHandler()` + `VercelImageConfig`, mountable without a `VercelEnvRunner` (also re-exported from `env-runner`) - `env-runner/runners/netlify` (`./runners/netlify`) — Direct import of `NetlifyEnvRunner` - `env-runner/runners/netlify/worker` (`./runners/netlify/worker`) — Netlify worker (sets global Netlify context, delegates to node-worker) - `env-runner/vite` (`./vite`) — Vite Environment API helpers (`createViteHotChannel`, `createViteTransport`) diff --git a/package.json b/package.json index ffa68c9..88e03ed 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "./runners/vercel": "./dist/runners/vercel/runner.mjs", "./runners/vercel/worker": "./dist/runners/vercel/worker.mjs", "./runners/vercel/queue-dev": "./dist/runners/vercel/queue-dev.mjs", + "./runners/vercel/image": "./dist/runners/vercel/image.mjs", "./runners/netlify": "./dist/runners/netlify/runner.mjs", "./runners/netlify/worker": "./dist/runners/netlify/worker.mjs", "./vite": "./dist/vite.mjs" @@ -59,7 +60,7 @@ "cjs-module-lexer": "^2.2.0", "env-runner-fixture": "link:", "es-module-lexer": "^2.2.0", - "ipx": "^4.0.0-alpha.1", + "ipx": "^4.0.0-beta.1", "miniflare": "^4.20260625.0", "obuild": "^0.4.37", "oxfmt": "^0.56.0", @@ -71,7 +72,7 @@ "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", - "ipx": "^4.0.0-alpha.1", + "ipx": "^4.0.0-beta.1", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9189357..44ae7c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: specifier: ^2.2.0 version: 2.2.0 ipx: - specifier: ^4.0.0-alpha.1 + specifier: ^4.0.0-beta.1 version: 4.0.0-beta.1(@types/node@26.0.1) miniflare: specifier: ^4.20260625.0 diff --git a/src/index.ts b/src/index.ts index 16ff6d7..ebf5c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,4 +32,11 @@ export { MiniflareEnvRunner, } from "./runners/miniflare/runner.ts"; export { VercelEnvRunner } from "./runners/vercel/runner.ts"; +export { + type VercelImageConfig, + type VercelImageHandler, + type VercelLocalPattern, + type VercelRemotePattern, + createVercelImageHandler, +} from "./runners/vercel/image.ts"; export { NetlifyEnvRunner } from "./runners/netlify/runner.ts"; diff --git a/src/runners/vercel/image.ts b/src/runners/vercel/image.ts index f854a2e..c84b3d4 100644 --- a/src/runners/vercel/image.ts +++ b/src/runners/vercel/image.ts @@ -24,8 +24,20 @@ export interface VercelImageConfig { dangerouslyAllowSVG?: boolean; contentSecurityPolicy?: string; contentDispositionType?: string; + + /** + * env-runner extension (not part of the Vercel `images` config): reject remote + * sources that are, or resolve to, a non-public IP address. Only applies to + * remote sources — local paths are always served through the worker. + * + * @default true + */ + blockPrivateIPs?: boolean; } +const DEFAULT_MAX_AGE = 60; +const DEFAULT_QUALITY = 75; + type IPXModule = typeof import("ipx"); let _ipxModule: IPXModule | undefined; @@ -44,14 +56,20 @@ async function loadIPX(): Promise { return _ipxModule; } +// `VercelEnvRunner` extends `NodeWorkerEnvRunner`, which always listens on TCP function resolveWorkerUrl(address: WorkerAddress, path: string): string { - if ("socketPath" in address && address.socketPath) { - throw new Error( - "Vercel image handler requires a TCP worker address (host/port); unix sockets are not supported.", - ); - } - const host = address.host || "127.0.0.1"; - return `http://${host}:${address.port}${path}`; + return `http://${address.host || "127.0.0.1"}:${address.port}${path}`; +} + +const SOCKET_ADDRESS_MESSAGE = + "Vercel image handler requires a TCP worker address (host/port); unix sockets are not supported."; + +// `VercelEnvRunner` always listens on TCP, but `createVercelImageHandler()` is public +// API taking an arbitrary `getAddress`. Checked up front for local sources so a socket +// address fails loudly instead of building `http://undefined:undefined/...`, whose +// failed fetch would otherwise surface as a misleading 404 "resource not found". +function rejectSocketAddress(address: WorkerAddress | undefined): Response | undefined { + return address?.socketPath ? new Response(SOCKET_ADDRESS_MESSAGE, { status: 500 }) : undefined; } // --- URL validation --- @@ -60,26 +78,38 @@ function isRemoteUrl(url: string): boolean { return /^https?:\/\//.test(url); } -// Build Output API uses PCRE regex (^...$), Next.js config uses globs (**, *) -function matchPattern(pattern: string, value: string): boolean { +// Build Output API uses PCRE regex (^...$), Next.js config uses globs (**, *). +// Patterns come from config and are re-tested on every request, so compile once. +const _patternCache = new Map(); + +function patternToRegExp(pattern: string): RegExp { + let compiled = _patternCache.get(pattern); + if (compiled) return compiled; if (pattern.startsWith("^") && pattern.endsWith("$")) { - return new RegExp(pattern).test(value); - } - let re = "^"; - for (let i = 0; i < pattern.length; i++) { - const ch = pattern.charAt(i); - if (ch === "*" && pattern.charAt(i + 1) === "*") { - re += ".*"; - i++; - } else if (ch === "*") { - re += "[^/]*"; - } else if (".+?{}()[]\\^$|".includes(ch)) { - re += "\\" + ch; - } else { - re += ch; + compiled = new RegExp(pattern); + } else { + let re = "^"; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern.charAt(i); + if (ch === "*" && pattern.charAt(i + 1) === "*") { + re += ".*"; + i++; + } else if (ch === "*") { + re += "[^/]*"; + } else if (".+?{}()[]\\^$|".includes(ch)) { + re += "\\" + ch; + } else { + re += ch; + } } + compiled = new RegExp(re + "$"); } - return new RegExp(re + "$").test(value); + _patternCache.set(pattern, compiled); + return compiled; +} + +function matchPattern(pattern: string, value: string): boolean { + return patternToRegExp(pattern).test(value); } function matchRemotePattern(pattern: VercelRemotePattern, url: URL): boolean { @@ -91,9 +121,10 @@ function matchRemotePattern(pattern: VercelRemotePattern, url: URL): boolean { return true; } +// A remote source is only optimized when it is covered by `domains` or `remotePatterns`. function validateRemoteUrl(sourceUrl: string, config?: VercelImageConfig): boolean { if (!config?.domains?.length && !config?.remotePatterns?.length) { - return true; + return false; } try { const parsed = new URL(sourceUrl); @@ -113,6 +144,19 @@ function validateLocalUrl(sourceUrl: string, config?: VercelImageConfig): boolea }); } +// ipx re-validates every redirect hop against its own `domains` allowlist, which is +// only possible with literal hostnames. Returns undefined when the config uses globs +// or Build Output API regexes, in which case ipx runs with `allowAllDomains` and the +// initial URL is gated by `validateRemoteUrl()` above. +function literalHostnames(config?: VercelImageConfig): string[] | undefined { + const hostnames = [...(config?.domains || [])]; + for (const pattern of config?.remotePatterns || []) { + if (!pattern.hostname || /[*^$?[\]{}()|+\\]/.test(pattern.hostname)) return undefined; + hostnames.push(pattern.hostname); + } + return hostnames.length > 0 ? hostnames : undefined; +} + function isSvgSource(url: string): boolean { const path = url.startsWith("/") ? url @@ -126,44 +170,209 @@ function isSvgSource(url: string): boolean { return /\.svgz?(\?|$)/i.test(path); } +function ensureVaryAccept(headers: Headers): void { + const existing = headers.get("vary"); + if (!existing) { + headers.set("vary", "Accept"); + } else if (!/(^|,\s*)Accept(\s*,|\s*$)/i.test(existing)) { + headers.set("vary", `${existing}, Accept`); + } +} + function applySecurityHeaders( - headers: Headers | Record, + headers: Headers, sourceUrl: string, config?: VercelImageConfig, ): void { - const set = (key: string, value: string) => { - if (headers instanceof Headers) headers.set(key, value); - else headers[key] = value; - }; + // Crafted image files can be sniffed as HTML + headers.set("x-content-type-options", "nosniff"); if (config?.contentSecurityPolicy) { - set("content-security-policy", config.contentSecurityPolicy); + headers.set("content-security-policy", config.contentSecurityPolicy); } else if (config?.dangerouslyAllowSVG) { // Match Next.js default CSP when SVGs are allowed - set("content-security-policy", "script-src 'none'; frame-src 'none'; sandbox;"); + headers.set("content-security-policy", "script-src 'none'; frame-src 'none'; sandbox;"); + } else if (!headers.has("content-security-policy")) { + // ipx sets this on its own responses; setting it here too keeps the + // unoptimized fallback from being the weaker path. + headers.set("content-security-policy", "default-src 'none'"); } if (config?.contentDispositionType) { - const filename = sourceUrl.split("/").pop()?.split("?")[0] || "image"; - set("content-disposition", `${config.contentDispositionType}; filename="${filename}"`); + // Quotes and backslashes are stripped rather than escaped: they would + // otherwise terminate the quoted `filename` and mangle the whole header. + const filename = sourceUrl.split("/").pop()?.split("?")[0]?.replaceAll(/["\\]/g, "") || "image"; + headers.set("content-disposition", `${config.contentDispositionType}; filename="${filename}"`); } } +// --- Request parsing --- + +interface ParsedImageRequest { + sourceUrl: string; + modifiers: Record; +} + +function badRequest(message: string): Response { + return new Response(message, { status: 400 }); +} + +/** Strips the `image/` prefix so `f=webp` and `f=image/webp` compare equal. */ +function bareFormat(format: string): string { + return format.replace(/^image\//, ""); +} + +function nearestQuality(target: number, qualities?: number[]): number { + if (!qualities?.length) return target; + return qualities.reduce((best, q) => (Math.abs(q - target) < Math.abs(best - target) ? q : best)); +} + +function negotiateFormat(accept: string, allowed?: string[]): string | undefined { + const isAllowed = (fmt: string) => !allowed?.length || allowed.includes(fmt); + if (accept.includes("image/avif") && isAllowed("avif")) return "avif"; + if (accept.includes("image/webp") && isAllowed("webp")) return "webp"; + return undefined; +} + +// ipx answers failures with JSON carrying its own `IPX_*` codes and the resolved +// source path. Its statuses are right, so keep them and re-state the body with the +// plain-text message the rest of the endpoint uses. +const IPX_ERROR_MESSAGES: Record = { + // The only reachable 400s are undecodable sources (`IPX_INVALID_IMAGE`, + // `IPX_INVALID_SVG`): the modifiers ipx receives are width/quality/format, all + // already validated by `parseImageRequest()`. + 400: '"url" parameter is valid but upstream is not an image', + 403: '"url" parameter is not allowed', + 404: '"url" parameter is valid but upstream response is invalid', + // DNS failure, redirect loop, bad redirect + 502: '"url" parameter is valid but upstream response is invalid', +}; + +function ipxError(status: number): Response { + return new Response(IPX_ERROR_MESSAGES[status] || "Image optimization failed", { status }); +} + +/** + * Parses and validates the Vercel `/_vercel/image` query string. + * + * Every rejection is returned as a `Response` so the caller can pass Vercel's own + * plain-text error messages straight through. `accept` only drives format + * negotiation when the request carries no `f` param. + */ +function parseImageRequest( + url: URL, + accept: string, + config: VercelImageConfig | undefined, +): ParsedImageRequest | Response { + const sourceUrl = url.searchParams.get("url"); + if (!sourceUrl) { + return badRequest('"url" parameter is required'); + } + + const w = url.searchParams.get("w"); + if (!w) { + return badRequest('"w" parameter is required'); + } + // Reject trailing garbage ("8abc") and signs/hex that `parseInt` would accept + if (!/^\d+$/.test(w)) { + return badRequest('"w" must be a positive integer'); + } + const width = Number.parseInt(w, 10); + if (width <= 0) { + return badRequest('"w" must be a positive integer'); + } + if (config?.sizes?.length && !config.sizes.includes(width)) { + return badRequest(`"w" must be one of: ${config.sizes.join(", ")}`); + } + + // An omitted `q` snaps to the closest configured quality rather than a hard 75 + // that a narrow `qualities` list would then reject on every default request. + const q = url.searchParams.get("q"); + let quality: number; + if (q === null) { + quality = nearestQuality(DEFAULT_QUALITY, config?.qualities); + } else { + if (!/^\d+$/.test(q)) { + return badRequest('"q" must be between 1 and 100'); + } + quality = Number.parseInt(q, 10); + if (quality < 1 || quality > 100) { + return badRequest('"q" must be between 1 and 100'); + } + if (config?.qualities?.length && !config.qualities.includes(quality)) { + return badRequest(`"q" must be one of: ${config.qualities.join(", ")}`); + } + } + + const allowedFormats = config?.formats?.map(bareFormat); + const f = url.searchParams.get("f"); + const format = f ? bareFormat(f) : undefined; + if (format && allowedFormats?.length && !allowedFormats.includes(format)) { + return badRequest(`"f" must be one of: ${config!.formats!.join(", ")}`); + } + + // Reject protocol-relative URLs to avoid local/remote ambiguity + if (sourceUrl.startsWith("//")) { + return badRequest('"url" parameter is not allowed'); + } + + const isLocal = sourceUrl.startsWith("/"); + const isRemote = isRemoteUrl(sourceUrl); + if (!isLocal && !isRemote) { + return badRequest('"url" parameter is not allowed'); + } + if (isRemote && !validateRemoteUrl(sourceUrl, config)) { + return badRequest('"url" parameter is not allowed'); + } + if (isLocal && !validateLocalUrl(sourceUrl, config)) { + return badRequest('"url" parameter is not allowed'); + } + + // Block SVG unless explicitly allowed + if (!config?.dangerouslyAllowSVG && isSvgSource(sourceUrl)) { + return badRequest('"url" parameter is valid but image type is not allowed'); + } + + const modifiers: Record = { width, quality }; + + // Format: explicit param > Accept header negotiation + const resolvedFormat = format ?? negotiateFormat(accept, allowedFormats); + if (resolvedFormat) { + modifiers.format = resolvedFormat; + } + + return { sourceUrl, modifiers }; +} + // --- Unoptimized fallback --- async function fetchUnoptimized( sourceUrl: string, getAddress: () => WorkerAddress | undefined, - config?: VercelImageConfig, - cacheTTL?: number, + config: VercelImageConfig | undefined, + maxAge: number, ): Promise { let res: Response; - if (sourceUrl.startsWith("/")) { - const address = getAddress(); - if (!address) { - return new Response("Runner not ready", { status: 503 }); + try { + if (sourceUrl.startsWith("/")) { + const address = getAddress(); + if (!address) { + return new Response("Runner not ready", { status: 503 }); + } + res = await fetch(resolveWorkerUrl(address, sourceUrl)); + } else { + res = await fetch(sourceUrl); } - res = await fetch(resolveWorkerUrl(address, sourceUrl)); - } else { - res = await fetch(sourceUrl); + } catch { + // Connection refused, DNS failure, aborted upstream + return new Response('"url" parameter is valid but upstream response is invalid', { + status: 502, + }); + } + + // A failed upstream is a missing or broken source, not a content-type problem + if (!res.ok) { + return new Response('"url" parameter is valid but upstream response is invalid', { + status: res.status, + }); } const headers = new Headers(res.headers); @@ -178,15 +387,9 @@ async function fetchUnoptimized( status: 400, }); } - const existingVary = headers.get("vary"); - if (!existingVary) { - headers.set("vary", "Accept"); - } else if (!/(^|,\s*)Accept(\s*,|\s*$)/i.test(existingVary)) { - headers.set("vary", `${existingVary}, Accept`); - } + ensureVaryAccept(headers); if (!headers.has("cache-control")) { - const ttl = cacheTTL ?? config?.minimumCacheTTL ?? 60; - headers.set("cache-control", `public, max-age=${ttl}, s-maxage=${ttl}`); + headers.set("cache-control", `public, max-age=${maxAge}, s-maxage=${maxAge}`); } applySecurityHeaders(headers, sourceUrl, config); @@ -209,14 +412,17 @@ export function createVercelImageHandler(opts: { config?: VercelImageConfig; }): VercelImageHandler { const { getAddress, config } = opts; + const maxAge = config?.minimumCacheTTL ?? DEFAULT_MAX_AGE; - let _ipx: ReturnType | undefined; - let _ipxPromise: Promise | undefined> | undefined; + type IPXFetchHandler = ReturnType; + type FetchHandlerFactory = (parsed: ParsedImageRequest) => IPXFetchHandler; - async function getIPX() { - if (_ipx) return _ipx; - if (_ipxPromise) return _ipxPromise; - _ipxPromise = (async () => { + let _factoryPromise: Promise | undefined; + + // The ipx instance is the expensive half (it memoizes the sharp/svgo imports), so + // it is built once and reused; the fetch handler around it is built per request. + function getFetchHandlerFactory(): Promise { + _factoryPromise ||= (async () => { const ipxModule = await loadIPX(); if (!ipxModule) return undefined; @@ -227,14 +433,16 @@ export function createVercelImageHandler(opts: { if (!address) return undefined; try { const res = await fetch(resolveWorkerUrl(address, id), { method: "HEAD" }); - if (!res.ok) return undefined; + // Not every framework answers HEAD; existence is decided by `getData()` + // so a failed probe only means "no last-modified available". + if (!res.ok) return { maxAge }; const lastModified = res.headers.get("last-modified"); return { mtime: lastModified ? new Date(lastModified) : undefined, - maxAge: config?.minimumCacheTTL ?? 60, + maxAge, }; } catch { - return undefined; + return { maxAge }; } }, async getData(id) { @@ -250,160 +458,110 @@ export function createVercelImageHandler(opts: { }, }; - // Remote URL validation is handled before calling ipx(), so - // allow all domains here and let our validation layer handle restrictions - _ipx = ipxModule.createIPX({ + // The requested remote URL is already validated against domains/remotePatterns + // before the handler is called, but ipx follows redirects itself and only + // re-validates the hops when it has an allowlist of its own — so pass the literal + // hostnames whenever the config has no globs/regexes, and fall back to + // allowAllDomains otherwise (see `literalHostnames()`). + const domains = literalHostnames(config); + const blockPrivateIPs = config?.blockPrivateIPs ?? true; + const ipx = ipxModule.createIPX({ storage: workerStorage, - httpStorage: ipxModule.ipxHttpStorage({ allowAllDomains: true }), - maxAge: config?.minimumCacheTTL ?? 60, + httpStorage: ipxModule.ipxHttpStorage( + domains ? { domains, blockPrivateIPs } : { allowAllDomains: true, blockPrivateIPs }, + ), + maxAge, }); - return _ipx; + // ipx's own fetch handler owns content-type, ETag/304 revalidation, + // last-modified and the baseline security headers. `parseURL` only ever sees a + // URL, which can't carry the Accept-negotiated format, so it closes over the + // parse `handle()` already did instead of re-deriving it. + return ({ sourceUrl, modifiers }) => + ipxModule.createIPXFetchHandler(ipx, { + parseURL: () => ({ id: sourceUrl, modifiers }), + }); })(); - return _ipxPromise; + return _factoryPromise; } return { close() { - _ipx = undefined; - _ipxPromise = undefined; + _factoryPromise = undefined; }, async handle(request: Request): Promise { - const url = new URL(request.url); - - const sourceUrl = url.searchParams.get("url"); - const w = url.searchParams.get("w"); - const q = url.searchParams.get("q") || "75"; - const f = url.searchParams.get("f"); - const fit = url.searchParams.get("fit"); - const h = url.searchParams.get("h"); - const blur = url.searchParams.get("blur"); - - if (!sourceUrl) { - return new Response('"url" parameter is required', { status: 400 }); - } - if (!w) { - return new Response('"w" parameter is required', { status: 400 }); - } - - const width = Number.parseInt(w); - if (Number.isNaN(width) || width <= 0) { - return new Response('"w" must be a positive integer', { status: 400 }); - } - - const quality = Number.parseInt(q); - if (Number.isNaN(quality) || quality < 1 || quality > 100) { - return new Response('"q" must be between 1 and 100', { status: 400 }); + // The endpoint only reads images; anything but GET/HEAD is rejected rather + // than silently optimizing, matching the deployed endpoint. + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed", { + status: 405, + headers: { allow: "GET, HEAD" }, + }); } - if (config?.sizes?.length && !config.sizes.includes(width)) { - return new Response(`"w" must be one of: ${config.sizes.join(", ")}`, { status: 400 }); + const parsed = parseImageRequest( + new URL(request.url), + request.headers.get("accept") || "", + config, + ); + if (parsed instanceof Response) { + return parsed; } + const { sourceUrl } = parsed; - if (config?.qualities?.length && !config.qualities.includes(quality)) { - return new Response(`"q" must be one of: ${config.qualities.join(", ")}`, { status: 400 }); + // Only local sources go through the worker; a remote source never touches it. + if (sourceUrl.startsWith("/")) { + const socketError = rejectSocketAddress(getAddress()); + if (socketError) { + return socketError; + } } - if (f && config?.formats?.length && !config.formats.includes(f)) { - return new Response(`"f" must be one of: ${config.formats.join(", ")}`, { status: 400 }); + const createFetchHandler = await getFetchHandlerFactory(); + if (!createFetchHandler) { + return fetchUnoptimized(sourceUrl, getAddress, config, maxAge); } - // Reject protocol-relative URLs to avoid local/remote ambiguity - if (sourceUrl.startsWith("//")) { - return new Response('"url" parameter is not allowed', { status: 400 }); + let res: Response; + try { + res = await createFetchHandler(parsed)(request); + } catch (error: any) { + // Unexpected: ipx converts its own HTTPErrors into responses. Log the detail, + // since the body is normalized like every other error. + console.warn("[env-runner] vercel image optimization failed:", error); + return ipxError(error.status || error.statusCode || 500); } - // Validate source URL against allowlists - const isLocal = sourceUrl.startsWith("/"); - const isRemote = isRemoteUrl(sourceUrl); - if (!isLocal && !isRemote) { - return new Response('"url" parameter is not allowed', { status: 400 }); - } - if (isRemote && !validateRemoteUrl(sourceUrl, config)) { - return new Response('"url" parameter is not allowed', { status: 400 }); - } - if (isLocal && !validateLocalUrl(sourceUrl, config)) { - return new Response('"url" parameter is not allowed', { status: 400 }); + // 404 for a missing source, 403 for a forbidden host/IP, 400 for an undecodable + // one — right statuses, but ipx's JSON body names its own codes and the source path. + if (!res.ok && res.status !== 304) { + return ipxError(res.status); } - // Block SVG unless explicitly allowed - if (!config?.dangerouslyAllowSVG && isSvgSource(sourceUrl)) { + // Defense in depth: block SVG output even if the URL check was bypassed + if ( + !config?.dangerouslyAllowSVG && + /^image\/svg\+xml\b/i.test(res.headers.get("content-type") || "") + ) { return new Response('"url" parameter is valid but image type is not allowed', { status: 400, }); } - const cacheOverride = Number.parseInt(url.searchParams.get("cache") || ""); - const cacheTTL = - Number.isFinite(cacheOverride) && cacheOverride > 0 - ? cacheOverride - : (config?.minimumCacheTTL ?? 60); + const headers = new Headers(res.headers); + headers.set("cache-control", `public, max-age=${maxAge}, s-maxage=${maxAge}`); + ensureVaryAccept(headers); + applySecurityHeaders(headers, sourceUrl, config); - const ipx = await getIPX(); - if (!ipx) { - return fetchUnoptimized(sourceUrl, getAddress, config, cacheTTL); + if (res.status === 304) { + return new Response(null, { status: 304, headers }); } - // Build IPX modifiers - const modifiers: Record = { width, quality }; - if (h) { - const height = Number.parseInt(h); - if (!Number.isNaN(height) && height > 0) { - modifiers.height = height; - } - } - if (fit) { - modifiers.fit = fit; - } - if (blur) { - const blurValue = Number.parseInt(blur); - if (!Number.isNaN(blurValue) && blurValue > 0) { - modifiers.blur = blurValue; - } - } - - // Format: explicit param > Accept header negotiation - if (f) { - modifiers.format = f.replace("image/", ""); - } else { - const accept = request.headers.get("accept") || ""; - const allowed = config?.formats?.map((fmt) => fmt.replace(/^image\//, "")); - const isAllowed = (fmt: string) => !allowed || allowed.includes(fmt); - if (accept.includes("image/avif") && isAllowed("avif")) { - modifiers.format = "avif"; - } else if (accept.includes("image/webp") && isAllowed("webp")) { - modifiers.format = "webp"; - } - } - - try { - const img = ipx(sourceUrl, modifiers); - const { data, format } = await img.process(); - - // Defense in depth: block SVG output even if the URL check was bypassed - if (!config?.dangerouslyAllowSVG && format === "svg+xml") { - return new Response('"url" parameter is valid but image type is not allowed', { - status: 400, - }); - } - - const contentType = format ? `image/${format}` : "application/octet-stream"; - const body = - typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); - - const headers: Record = { - "content-type": contentType, - "content-length": String(body.byteLength), - "cache-control": `public, max-age=${cacheTTL}, s-maxage=${cacheTTL}`, - vary: "Accept", - }; - applySecurityHeaders(headers, sourceUrl, config); - - return new Response(body, { headers }); - } catch (error: any) { - const status = error.statusCode || 500; - return new Response(error.message || "Image optimization failed", { status }); - } + // Buffered so `content-length` is always set (ipx has the whole image in + // memory anyway, there is no streaming to preserve). + const body = new Uint8Array(await res.arrayBuffer()); + headers.set("content-length", String(body.byteLength)); + return new Response(body, { status: res.status, headers }); }, }; } diff --git a/src/runners/vercel/runner.ts b/src/runners/vercel/runner.ts index ec0494d..19086c1 100644 --- a/src/runners/vercel/runner.ts +++ b/src/runners/vercel/runner.ts @@ -48,6 +48,15 @@ export class VercelEnvRunner extends NodeWorkerEnvRunner { const requestId = generateVercelId(); + // Both dispatch paths need the worker address, and `x-vercel-deployment-url` + // is derived from it — so wait for readiness before injecting headers rather + // than silently dropping that header on the first request to a cold runner. + // `waitForReady()` rejects immediately once the runner is closed, so a worker + // that never came up still fails fast. + if (!this._address) { + await this.waitForReady().catch(() => {}); + } + if (this._address && this._address.port != null && !headers.has("x-vercel-deployment-url")) { const host = this._address.host || "127.0.0.1"; headers.set("x-vercel-deployment-url", `http://${host}:${this._address.port}`); @@ -86,21 +95,28 @@ export class VercelEnvRunner extends NodeWorkerEnvRunner { } let res: Response; - if (requestUrl?.pathname === "/_vercel/image") { - if (!this._address) { - await this.waitForReady().catch(() => {}); - } - if (!this._address) { - return new Response("vercel env runner is unavailable", { status: 503 }); - } + if (!this._address) { + // Same body the base runner produces, but routed through the response-header + // injection below so a 503 still looks like a Vercel response. + res = new Response(`${this._runtimeType()} env runner is unavailable`, { status: 503 }); + } else if (requestUrl?.pathname === "/_vercel/image") { this._imageHandler ||= createVercelImageHandler({ getAddress: () => this._address, config: this._imageConfig, }); - res = await this._imageHandler.handle(new Request(requestUrl, { headers })); - } else if (input instanceof Request) { - res = await super.fetch(new Request(input, { ...init, headers })); + // Built from `requestUrl` rather than re-wrapping `input`: the incoming + // request may be a host-runtime request object (e.g. srvx's) that the + // undici `Request` constructor refuses to clone. + res = await this._imageHandler.handle( + new Request(requestUrl, { + method: (input instanceof Request ? input.method : init?.method) || "GET", + headers, + signal: input instanceof Request ? input.signal : init?.signal, + }), + ); } else { + // `input` is forwarded as-is: `proxyFetch()` merges `init` over a `Request`'s + // own fields, so the injected `headers` win without cloning `input`. res = await super.fetch(input, { ...init, headers }); } diff --git a/test/vercel-image.test.ts b/test/vercel-image.test.ts new file mode 100644 index 0000000..1f07026 --- /dev/null +++ b/test/vercel-image.test.ts @@ -0,0 +1,512 @@ +import type { AddressInfo } from "node:net"; + +import { createServer, type Server } from "node:http"; +import { serve } from "srvx"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { + createVercelImageHandler, + type VercelImageConfig, + type VercelImageHandler, +} from "../src/runners/vercel/image.ts"; + +// Minimal 1x1 red PNG +const PNG_1x1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "base64", +); +const SVG = Buffer.from( + ``, +); + +/** + * Stands in for the worker: `getAddress()` points at it, and it doubles as a + * "remote" origin for the `domains`/`remotePatterns`/`blockPrivateIPs` tests. + */ +let origin: Server; +let port: number; + +beforeAll(async () => { + origin = createServer((req, res) => { + // Decoded so a path with percent-encoded characters (see the + // content-disposition escaping test) is matched by its literal form + const path = decodeURIComponent((req.url || "").split("?")[0]!); + if (path === '/od"d.png') { + res.writeHead(200, { "content-type": "image/png" }); + res.end(req.method === "HEAD" ? undefined : PNG_1x1); + } else if (path === "/test.png" || path === "/assets/test.png") { + res.writeHead(200, { "content-type": "image/png" }); + res.end(req.method === "HEAD" ? undefined : PNG_1x1); + } else if (path === "/icon.svg") { + res.writeHead(200, { "content-type": "image/svg+xml" }); + res.end(req.method === "HEAD" ? undefined : SVG); + } else if (path === "/note.txt") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(req.method === "HEAD" ? undefined : "not an image"); + } else { + res.writeHead(404).end(); + } + }); + await new Promise((r) => origin.listen(0, "127.0.0.1", r)); + port = (origin.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((r) => origin.close(r)); +}); + +function makeHandler(config?: VercelImageConfig): VercelImageHandler { + return createVercelImageHandler({ + getAddress: () => ({ host: "127.0.0.1", port }), + config, + }); +} + +function get( + handler: VercelImageHandler, + query: string, + headers?: Record, +): Promise { + return handler.handle(new Request(`http://localhost/_vercel/image?${query}`, { headers })); +} + +describe("createVercelImageHandler", () => { + describe("request method", () => { + it("serves GET and HEAD", async () => { + for (const method of ["GET", "HEAD"]) { + const res = await makeHandler().handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8", { method }), + ); + expect(res.status).toBe(200); + } + }); + + it.each(["POST", "PUT", "DELETE"])("rejects %s with 405", async (method) => { + const res = await makeHandler().handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8", { method }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD"); + }); + }); + + describe("worker address", () => { + // `createVercelImageHandler()` is public API, so `getAddress` may return an + // address the handler can't reach over HTTP + const socketAddress = () => ({ socketPath: "/tmp/env-runner.sock" }) as never; + + it("rejects a unix socket address for a local source", async () => { + const handler = createVercelImageHandler({ getAddress: socketAddress }); + const res = await get(handler, "url=/test.png&w=8"); + expect(res.status).toBe(500); + expect(await res.text()).toContain("unix sockets are not supported"); + }); + + it("still serves a remote source with a socket address", async () => { + // A remote source never touches the worker, so the guard must not apply + const handler = createVercelImageHandler({ + getAddress: socketAddress, + config: { domains: ["127.0.0.1"], blockPrivateIPs: false }, + }); + const res = await get( + handler, + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(res.status).toBe(200); + }); + }); + + describe("parameter validation", () => { + it("requires the url parameter", async () => { + const res = await get(makeHandler(), "w=64"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is required'); + }); + + it("requires the w parameter", async () => { + const res = await get(makeHandler(), "url=/test.png"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"w" parameter is required'); + }); + + it.each(["abc", "0", "-64", "8abc", "0x10", "1e3"])("rejects w=%s", async (w) => { + const res = await get(makeHandler(), `url=/test.png&w=${w}`); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"w" must be a positive integer'); + }); + + it.each(["0", "101", "abc", "75abc", "-5"])("rejects q=%s", async (q) => { + const res = await get(makeHandler(), `url=/test.png&w=8&q=${q}`); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"q" must be between 1 and 100'); + }); + + it("rejects a width outside the configured sizes", async () => { + const res = await get(makeHandler({ sizes: [64, 128] }), "url=/test.png&w=100"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"w" must be one of: 64, 128'); + }); + + it("rejects a quality outside the configured qualities", async () => { + const res = await get(makeHandler({ qualities: [50, 100] }), "url=/test.png&w=8&q=60"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"q" must be one of: 50, 100'); + }); + + it("snaps an omitted q to the nearest configured quality", async () => { + // A hard default of 75 would be rejected by this `qualities` list + const res = await get(makeHandler({ qualities: [50, 100] }), "url=/test.png&w=8"); + expect(res.status).toBe(200); + }); + + it("rejects a format outside the configured formats", async () => { + const res = await get( + makeHandler({ formats: ["image/webp"] }), + "url=/test.png&w=8&f=image/avif", + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"f" must be one of: image/webp'); + }); + + it("compares f against formats without the image/ prefix", async () => { + // `f=webp` and `f=image/webp` must both satisfy `formats: ["image/webp"]` + for (const f of ["webp", "image/webp"]) { + const res = await get(makeHandler({ formats: ["image/webp"] }), `url=/test.png&w=8&f=${f}`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/webp"); + } + }); + + it.each(["//evil.example/x.png", "data:image/png;base64,AAAA", "ftp://x.example/a.png"])( + "rejects a non-local, non-http url (%s)", + async (url) => { + const res = await get(makeHandler(), `url=${encodeURIComponent(url)}&w=8`); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }, + ); + }); + + describe("remote source allowlist", () => { + it("denies remote sources when nothing is configured", async () => { + const res = await get(makeHandler(), "url=https://cdn.example/a.png&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + + it("allows a remote source matching domains", async () => { + const res = await get( + makeHandler({ domains: ["127.0.0.1"], blockPrivateIPs: false }), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/^image\//); + }); + + it("denies a remote source not matching domains", async () => { + const res = await get( + makeHandler({ domains: ["allowed.example"] }), + "url=https://evil.example/a.png&w=8", + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + + it("matches remotePatterns in glob form", async () => { + const config: VercelImageConfig = { + remotePatterns: [{ protocol: "http", hostname: "127.0.0.1", pathname: "/assets/**" }], + blockPrivateIPs: false, + }; + const denied = await get( + makeHandler(config), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(denied.status).toBe(400); + expect(await denied.text()).toBe('"url" parameter is not allowed'); + + const allowed = await get( + makeHandler(config), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/assets/test.png`)}&w=8`, + ); + expect(allowed.status).toBe(200); + }); + + it("matches remotePatterns in Build Output API regex form", async () => { + const config: VercelImageConfig = { + remotePatterns: [ + { protocol: "http", hostname: "^127\\.0\\.0\\.1$", pathname: "^/assets/.*$" }, + ], + blockPrivateIPs: false, + }; + const denied = await get( + makeHandler(config), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(denied.status).toBe(400); + + const allowed = await get( + makeHandler(config), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/assets/test.png`)}&w=8`, + ); + expect(allowed.status).toBe(200); + }); + + it("blocks private IPs by default", async () => { + const res = await get( + makeHandler({ domains: ["127.0.0.1"] }), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(res.status).toBe(403); + // ipx's own `IPX_FORBIDDEN_IP` JSON is re-stated as the endpoint's message + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + }); + + describe("local source allowlist", () => { + it("allows any local path when localPatterns is unset", async () => { + const res = await get(makeHandler(), "url=/test.png&w=8"); + expect(res.status).toBe(200); + }); + + it("allows a local path matching localPatterns", async () => { + const res = await get( + makeHandler({ localPatterns: [{ pathname: "/assets/**" }] }), + "url=/assets/test.png&w=8", + ); + expect(res.status).toBe(200); + }); + + it("denies a local path not matching localPatterns", async () => { + const res = await get( + makeHandler({ localPatterns: [{ pathname: "/assets/**" }] }), + "url=/test.png&w=8", + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + }); + + describe("svg", () => { + it("blocks svg sources by default", async () => { + const res = await get(makeHandler(), "url=/icon.svg&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is valid but image type is not allowed'); + }); + + it("serves svg when dangerouslyAllowSVG is set", async () => { + const res = await get(makeHandler({ dangerouslyAllowSVG: true }), "url=/icon.svg&w=8"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/svg+xml"); + expect(res.headers.get("content-security-policy")).toBe( + "script-src 'none'; frame-src 'none'; sandbox;", + ); + }); + }); + + describe("response headers", () => { + it("derives cache-control from minimumCacheTTL", async () => { + const res = await get(makeHandler({ minimumCacheTTL: 120 }), "url=/test.png&w=8"); + expect(res.headers.get("cache-control")).toBe("public, max-age=120, s-maxage=120"); + }); + + it("sets nosniff, Vary and content-length", async () => { + const res = await get(makeHandler(), "url=/test.png&w=8"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("vary")).toBe("Accept"); + expect(Number(res.headers.get("content-length"))).toBe((await res.arrayBuffer()).byteLength); + }); + + it("sets a baseline CSP", async () => { + const res = await get(makeHandler(), "url=/test.png&w=8"); + expect(res.headers.get("content-security-policy")).toBe("default-src 'none'"); + }); + + it("sets content-disposition when configured", async () => { + const res = await get( + makeHandler({ contentDispositionType: "attachment" }), + "url=/test.png&w=8", + ); + expect(res.headers.get("content-disposition")).toBe('attachment; filename="test.png"'); + }); + + it("strips quotes from the content-disposition filename", async () => { + // An unescaped `"` would terminate the quoted filename and mangle the header + const res = await get( + makeHandler({ contentDispositionType: "attachment" }), + `url=${encodeURIComponent('/od"d.png')}&w=8`, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toBe('attachment; filename="odd.png"'); + }); + + it("revalidates with if-none-match", async () => { + const handler = makeHandler(); + const first = await get(handler, "url=/test.png&w=8"); + expect(first.status).toBe(200); + const etag = first.headers.get("etag"); + expect(etag).toBeTruthy(); + + const second = await get(handler, "url=/test.png&w=8", { "if-none-match": etag! }); + expect(second.status).toBe(304); + expect(second.headers.get("cache-control")).toBeTruthy(); + }); + }); + + describe("format negotiation", () => { + it("negotiates from a host-runtime request object", async () => { + // The request reaches ipx unwrapped, so a framework mounting the handler on its + // own server passes that server's request class straight through + const handler = makeHandler(); + const front = serve({ + port: 0, + hostname: "127.0.0.1", + gracefulShutdown: false, + fetch: (request) => handler.handle(request), + }); + await front.ready(); + try { + const res = await fetch(new URL("/_vercel/image?url=/test.png&w=8", front.url), { + headers: { accept: "image/avif,image/webp,*/*" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/avif"); + } finally { + await front.close(); + } + }); + + it("prefers avif over webp from the Accept header", async () => { + const res = await get(makeHandler(), "url=/test.png&w=8", { + accept: "image/avif,image/webp,*/*", + }); + expect(res.headers.get("content-type")).toBe("image/avif"); + }); + + it("skips formats excluded by config", async () => { + const res = await get(makeHandler({ formats: ["image/webp"] }), "url=/test.png&w=8", { + accept: "image/avif,image/webp,*/*", + }); + expect(res.headers.get("content-type")).toBe("image/webp"); + }); + + it("keeps the source format when Accept offers nothing better", async () => { + const res = await get(makeHandler(), "url=/test.png&w=8", { accept: "image/png,*/*" }); + expect(res.headers.get("content-type")).toBe("image/png"); + }); + }); + + // ipx's own JSON error bodies name its `IPX_*` codes and the resolved source path. + // The statuses are kept; the bodies are re-stated to match the fallback path. + describe("upstream failures", () => { + it("returns 404 for a missing local source", async () => { + const res = await get(makeHandler(), "url=/missing.png&w=8"); + expect(res.status).toBe(404); + expect(await res.text()).toBe('"url" parameter is valid but upstream response is invalid'); + }); + + it("returns 400 for an undecodable source", async () => { + const res = await get(makeHandler(), "url=/note.txt&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is valid but upstream is not an image'); + }); + + it("returns 404 before the runner reports an address", async () => { + // The worker storage yields nothing, which ipx reports as a missing source + const handler = createVercelImageHandler({ getAddress: () => undefined }); + const res = await handler.handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8"), + ); + expect(res.status).toBe(404); + }); + }); +}); + +describe("createVercelImageHandler without ipx", () => { + beforeAll(() => { + vi.resetModules(); + vi.doMock("ipx", () => { + throw new Error("Cannot find package 'ipx'"); + }); + }); + + afterAll(() => { + vi.doUnmock("ipx"); + vi.resetModules(); + }); + + async function makeBareHandler(config?: VercelImageConfig) { + // Re-imported so the module-level ipx load state is re-evaluated under the mock + const { createVercelImageHandler: create } = await import("../src/runners/vercel/image.ts"); + return create({ getAddress: () => ({ host: "127.0.0.1", port }), config }); + } + + it("proxies the unoptimized source and warns once", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = await makeBareHandler(); + + const res = await get(handler, "url=/test.png&w=8"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("vary")).toBe("Accept"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("cache-control")).toBe("public, max-age=60, s-maxage=60"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("ipx is not installed")); + + await get(handler, "url=/test.png&w=8"); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("still validates before proxying", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=https://cdn.example/a.png&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + + it("rejects a non-image upstream", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/note.txt&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is valid but upstream is not an image'); + }); + + it("forwards the upstream status for a missing source", async () => { + // Not a content-type problem, so it must not be reported as "not an image" + const handler = await makeBareHandler(); + const res = await get(handler, "url=/missing.png&w=8"); + expect(res.status).toBe(404); + expect(await res.text()).toBe('"url" parameter is valid but upstream response is invalid'); + }); + + it("reports an unreachable upstream as 502", async () => { + const { createVercelImageHandler: create } = await import("../src/runners/vercel/image.ts"); + // Port 1 is reserved, so the connection is refused rather than timing out + const handler = create({ getAddress: () => ({ host: "127.0.0.1", port: 1 }) }); + const res = await get(handler, "url=/test.png&w=8"); + expect(res.status).toBe(502); + }); + + it("sets a baseline CSP on the fallback path too", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/test.png&w=8"); + expect(res.headers.get("content-security-policy")).toBe("default-src 'none'"); + }); + + it("rejects non-GET/HEAD before touching the upstream", async () => { + const handler = await makeBareHandler(); + const res = await handler.handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8", { method: "POST" }), + ); + expect(res.status).toBe(405); + }); + + it("returns 503 before the runner reports an address", async () => { + const { createVercelImageHandler: create } = await import("../src/runners/vercel/image.ts"); + const handler = create({ getAddress: () => undefined }); + const res = await handler.handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8"), + ); + expect(res.status).toBe(503); + }); +}); diff --git a/test/vercel.test.ts b/test/vercel.test.ts index d2d38ba..222bee3 100644 --- a/test/vercel.test.ts +++ b/test/vercel.test.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; import { describe, expect, it, afterEach, beforeAll, afterAll, vi } from "vitest"; +import { serve } from "srvx"; +import type { Server } from "srvx"; import { VercelEnvRunner } from "../src/runners/vercel/runner.ts"; // Fake unsigned JWT with a far-future `exp` to silence the Vercel OIDC token warning @@ -164,195 +166,101 @@ describe("VercelEnvRunner", () => { expect(env.NOW_REGION).toBeUndefined(); }); - // /_vercel/image optimization tests - describe("image optimization", () => { - it("returns optimized image for local source", async () => { - runner = new VercelEnvRunner({ name: "test-img", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toMatch(/^image\//); - }); + // `cli.ts` hands the front server's own request object straight to `fetch()`. + // srvx's request passes `instanceof Request` but the undici `Request` + // constructor refuses to clone it, so `fetch()` must forward it untouched. + describe("host-runtime request objects", () => { + let server: Server | undefined; - it("returns correct format when f param is provided", async () => { - runner = new VercelEnvRunner({ name: "test-img-fmt", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch( - "http://localhost/_vercel/image?url=/test.png&w=1&q=75&f=image/webp", - ); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/webp"); + afterEach(async () => { + await server?.close(); + server = undefined; }); - it("auto-detects format from Accept header", async () => { - runner = new VercelEnvRunner({ name: "test-img-accept", data: { entry: imageEntry } }); + it("forwards a srvx request without re-wrapping it", async () => { + runner = new VercelEnvRunner({ name: "test-srvx-req", data: { entry: headersEntry } }); await runner.waitForReady(); - const res = await runner.fetch( - new Request("http://localhost/_vercel/image?url=/test.png&w=1&q=75", { - headers: { accept: "image/webp,image/png,*/*" }, - }), - ); + + server = serve({ + port: 0, + hostname: "127.0.0.1", + gracefulShutdown: false, + fetch: (request) => runner!.fetch(request), + }); + await server.ready(); + + const res = await fetch(new URL("/", server.url)); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/webp"); + // The injected headers still reach the worker through `proxyFetch()` + const headers = await res.json(); + expect(headers["x-vercel-id"]).toMatch(/^dev1::/); + expect(headers["x-vercel-deployment-url"]).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); }); - it("returns 400 for missing url param", async () => { - runner = new VercelEnvRunner({ name: "test-img-nourl", data: { entry: imageEntry } }); + it("forwards a srvx request to the image handler", async () => { + runner = new VercelEnvRunner({ name: "test-srvx-img", data: { entry: imageEntry } }); await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?w=100&q=75"); - expect(res.status).toBe(400); + + server = serve({ + port: 0, + hostname: "127.0.0.1", + gracefulShutdown: false, + fetch: (request) => runner!.fetch(request), + }); + await server.ready(); + + const res = await fetch(new URL("/_vercel/image?url=/test.png&w=1&q=75", server.url)); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/^image\//); }); + }); - it("returns 400 for missing w param", async () => { - runner = new VercelEnvRunner({ name: "test-img-now", data: { entry: imageEntry } }); + // Wiring only — the handler's own validation/optimization matrix lives in + // `test/vercel-image.test.ts`, which runs without spawning a worker. + describe("image optimization", () => { + it("optimizes a local source served by the worker", async () => { + runner = new VercelEnvRunner({ name: "test-img", data: { entry: imageEntry } }); await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&q=75"); - expect(res.status).toBe(400); + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/^image\//); + expect(Number(res.headers.get("content-length"))).toBeGreaterThan(0); }); - it("includes vercel response headers on image responses", async () => { + it("injects the Vercel response headers on image responses", async () => { runner = new VercelEnvRunner({ name: "test-img-headers", data: { entry: imageEntry } }); await runner.waitForReady(); const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); expect(res.headers.get("server")).toBe("Vercel"); expect(res.headers.get("x-vercel-id")).toMatch(/^dev1::/); expect(res.headers.get("x-vercel-cache")).toBe("MISS"); - }); - - it("sets cache-control header", async () => { - runner = new VercelEnvRunner({ name: "test-img-cache", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); - expect(res.headers.get("cache-control")).toMatch(/max-age=\d+/); - }); - - it("sets Vary: Accept header for format negotiation", async () => { - runner = new VercelEnvRunner({ name: "test-img-vary", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + expect(res.headers.get("cache-control")).toBe("public, max-age=60, s-maxage=60"); expect(res.headers.get("vary")).toBe("Accept"); }); - it("sets Content-Length header", async () => { - runner = new VercelEnvRunner({ name: "test-img-cl", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); - const cl = res.headers.get("content-length"); - expect(cl).toBeTruthy(); - expect(Number(cl)).toBeGreaterThan(0); - }); - - it("blocks SVG sources by default", async () => { - runner = new VercelEnvRunner({ name: "test-img-svg", data: { entry: imageEntry } }); - await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/icon.svg&w=100&q=75"); - expect(res.status).toBe(400); - expect(await res.text()).toContain("image type is not allowed"); - }); - - it("allows SVG when dangerouslyAllowSVG is true", async () => { - runner = new VercelEnvRunner({ - name: "test-img-svg-allow", - data: { entry: imageEntry }, - images: { dangerouslyAllowSVG: true }, - }); - await runner.waitForReady(); - // The fixture doesn't actually serve SVG, but validation should pass - // (will fail at IPX level, not at our validation) - const res = await runner.fetch("http://localhost/_vercel/image?url=/icon.svg&w=100&q=75"); - // Should not be 400 "image type is not allowed" - expect(await res.text()).not.toContain("image type is not allowed"); - }); - - it("returns 400 for disallowed remote URL when domains configured", async () => { - runner = new VercelEnvRunner({ - name: "test-img-remote-blocked", - data: { entry: imageEntry }, - images: { domains: ["allowed.invalid"] }, - }); + it("forwards request headers to the handler", async () => { + runner = new VercelEnvRunner({ name: "test-img-accept", data: { entry: imageEntry } }); await runner.waitForReady(); const res = await runner.fetch( - "http://localhost/_vercel/image?url=https://evil.invalid/img.png&w=100&q=75", + new Request("http://localhost/_vercel/image?url=/test.png&w=1&q=75", { + headers: { accept: "image/webp,image/png,*/*" }, + }), ); - expect(res.status).toBe(400); - expect(await res.text()).toContain('"url" parameter is not allowed'); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/webp"); }); - it("allows remote URL when domain matches", async () => { - runner = new VercelEnvRunner({ - name: "test-img-remote-allowed", - data: { entry: imageEntry }, - images: { domains: ["allowed.invalid"] }, - }); + it("preserves the request method", async () => { + runner = new VercelEnvRunner({ name: "test-img-head", data: { entry: imageEntry } }); await runner.waitForReady(); - // Will pass validation but fail to fetch (no such host) const res = await runner.fetch( - "http://localhost/_vercel/image?url=https://allowed.invalid/img.png&w=100&q=75", - ); - // Should NOT be 400 "url parameter is not allowed" - expect(await res.text()).not.toContain('"url" parameter is not allowed'); - }); - - it("validates against remotePatterns (glob format)", async () => { - runner = new VercelEnvRunner({ - name: "test-img-remote-pattern", - data: { entry: imageEntry }, - images: { - remotePatterns: [{ protocol: "https", hostname: "cdn.invalid" }], - }, - }); - await runner.waitForReady(); - - // Blocked: different hostname - const blocked = await runner.fetch( - "http://localhost/_vercel/image?url=https://other.invalid/img.png&w=100&q=75", + new Request("http://localhost/_vercel/image?url=/test.png&w=1&q=75", { method: "HEAD" }), ); - expect(blocked.status).toBe(400); - - // Allowed: matching pattern (will fail to fetch but passes validation) - const allowed = await runner.fetch( - "http://localhost/_vercel/image?url=https://cdn.invalid/img.png&w=100&q=75", - ); - expect(await allowed.text()).not.toContain('"url" parameter is not allowed'); - }); - - it("validates against remotePatterns (Build Output API regex format)", async () => { - runner = new VercelEnvRunner({ - name: "test-img-remote-regex", - data: { entry: imageEntry }, - images: { - remotePatterns: [ - { - protocol: "https", - hostname: "^cdn\\.invalid$", - pathname: "^/assets/.*$", - }, - ], - }, - }); - await runner.waitForReady(); - - // Blocked: wrong hostname - const blocked1 = await runner.fetch( - "http://localhost/_vercel/image?url=https://other.invalid/assets/img.png&w=100&q=75", - ); - expect(blocked1.status).toBe(400); - expect(await blocked1.text()).toContain('"url" parameter is not allowed'); - - // Blocked: wrong pathname - const blocked2 = await runner.fetch( - "http://localhost/_vercel/image?url=https://cdn.invalid/other/img.png&w=100&q=75", - ); - expect(blocked2.status).toBe(400); - - // Allowed: matches regex pattern (will fail to fetch but passes validation) - const allowed = await runner.fetch( - "http://localhost/_vercel/image?url=https://cdn.invalid/assets/img.png&w=100&q=75", - ); - expect(await allowed.text()).not.toContain('"url" parameter is not allowed'); + expect(res.status).toBe(200); + expect(await res.arrayBuffer()).toHaveProperty("byteLength", 0); }); - it("returns 400 for width not in configured sizes", async () => { + it("passes the images config through to the handler", async () => { runner = new VercelEnvRunner({ name: "test-img-sizes", data: { entry: imageEntry }, @@ -361,32 +269,32 @@ describe("VercelEnvRunner", () => { await runner.waitForReady(); const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=100&q=75"); expect(res.status).toBe(400); - expect(await res.text()).toContain('"w" must be one of'); + expect(await res.text()).toBe('"w" must be one of: 64, 128, 256'); }); - it("returns 400 for quality not in configured qualities", async () => { - runner = new VercelEnvRunner({ - name: "test-img-qualities", - data: { entry: imageEntry }, - images: { qualities: [50, 75, 100] }, - }); + it("propagates handler errors with the Vercel response headers", async () => { + runner = new VercelEnvRunner({ name: "test-img-nourl", data: { entry: imageEntry } }); await runner.waitForReady(); - const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=60"); + const res = await runner.fetch("http://localhost/_vercel/image?w=100&q=75"); expect(res.status).toBe(400); - expect(await res.text()).toContain('"q" must be one of'); + expect(await res.text()).toBe('"url" parameter is required'); + expect(res.headers.get("server")).toBe("Vercel"); }); - it("allows remote images when no domain restrictions configured", async () => { - runner = new VercelEnvRunner({ - name: "test-img-remote-open", - data: { entry: imageEntry }, - }); + it("revalidates with if-none-match end to end", async () => { + runner = new VercelEnvRunner({ name: "test-img-etag", data: { entry: imageEntry } }); await runner.waitForReady(); - // No domains/remotePatterns = allow all (will fail to actually fetch) - const res = await runner.fetch( - "http://localhost/_vercel/image?url=https://any.invalid/img.png&w=100&q=75", + const res = await runner.fetch("http://localhost/_vercel/image?url=/test.png&w=1&q=75"); + const etag = res.headers.get("etag"); + expect(etag).toBeTruthy(); + + const revalidated = await runner.fetch( + new Request("http://localhost/_vercel/image?url=/test.png&w=1&q=75", { + headers: { "if-none-match": etag! }, + }), ); - expect(await res.text()).not.toContain('"url" parameter is not allowed'); + expect(revalidated.status).toBe(304); + expect(revalidated.headers.get("cache-control")).toBe("public, max-age=60, s-maxage=60"); }); }); }); From e842b15b39629ba34c2d66cb8a8251e517e43f70 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 4 Aug 2026 15:25:03 +0100 Subject: [PATCH 3/3] chore: improvements --- .agents/VERCEL.md | 29 +++- src/runners/vercel/image.ts | 323 +++++++++++++++++++++++++----------- test/vercel-image.test.ts | 211 +++++++++++++++++++++-- test/vercel.test.ts | 16 ++ 4 files changed, 454 insertions(+), 125 deletions(-) diff --git a/.agents/VERCEL.md b/.agents/VERCEL.md index c4ad86b..47f9199 100644 --- a/.agents/VERCEL.md +++ b/.agents/VERCEL.md @@ -8,7 +8,7 @@ Extends `NodeWorkerEnvRunner` to simulate a Vercel deployment environment. - **`src/runners/vercel/worker.ts`** — Sets Vercel env vars and `Symbol.for("@vercel/request-context")` on globalThis, delegates to node-worker worker - **`src/runners/vercel/oidc.ts`** — `_checkVercelOidcToken()` decodes `VERCEL_OIDC_TOKEN` (JWT `exp` claim, no signature check) and returns `{ status: "missing" | "valid" | "expired" | "invalid", expiresAt? }`. `warnIfVercelOidcTokenInvalid()` logs a one-time dev warning hinting the user to run `vercel env pull`. Called from the `VercelEnvRunner` constructor - **`src/runners/vercel/queue-dev.ts`** — Bridge for local Vercel Queues delivery. `await registerVercelQueueConsumer({ topic, handler, consumerGroup?, visibilityTimeoutSeconds?, retry?, retryAfterSeconds? })` lets framework plugins bind a topic to a dispatcher; the first call lazy-loads `@vercel/queue` and constructs a shared `QueueClient`. Resolves to an unregister function. Re-registering the same `consumerGroup` on a topic replaces the handler via the SDK's own `consumerGroup` keying (HMR-safe; the unregister for a replaced registration becomes a no-op). `retryAfterSeconds` is a shorthand for `retry: () => ({ afterSeconds })`; pass `retry` for richer directives like `{ acknowledge: true }` -- **`src/runners/vercel/image.ts`** — `createVercelImageHandler()`: handles `/_vercel/image` requests using IPX for image optimization. Serves `GET`/`HEAD` only (405 otherwise) and supports the `url`, `w`, `q`, `f` query params. `parseImageRequest()` parses and validates in one place (returning a `Response` for every rejection so Vercel's plain-text messages pass straight through): remote URLs are default-deny unless matched by `domains`/`remotePatterns`, local URLs are narrowed by `localPatterns`, SVG is blocked by default. It then delegates to ipx v4's `createIPXFetchHandler()` (ETag/304, content-type, security headers), whose `parseURL` closes over that single parse. Falls back to unoptimized proxy when `ipx` is not installed +- **`src/runners/vercel/image.ts`** — `createVercelImageHandler()`: handles `/_vercel/image` requests using IPX for image optimization. Serves `GET`/`HEAD` only (405 otherwise) and supports the `url`, `w`, `q` query params. `parseImageRequest()` parses and validates in one place (returning a `Response` for every rejection, with the bodies shared through the `MESSAGES` map so the ipx path, the unoptimized fallback and the ipx error mapping all answer a given failure alike): remote URLs are default-deny unless matched by `domains`/`remotePatterns`, local URLs are normalized and narrowed by `localPatterns`, SVG is blocked by default. It then delegates to ipx v4's `createIPXFetchHandler()` (ETag/304, content-type, security headers), whose `parseURL` closes over that single parse. Falls back to unoptimized proxy when `ipx` is not installed ## How it works @@ -48,30 +48,41 @@ All headers are only injected when not already present in the request/response. **Image optimization (`/_vercel/image`):** Intercepts requests to `/_vercel/image` and processes images using IPX (optional `ipx` peer dependency). Supports Vercel's image optimization query parameters: - `url` (required) — source image URL (local path or absolute URL) -- `w` (required) — output width in pixels. Must be a bare non-negative integer (`^\d+$`); `parseInt`-style inputs like `8abc`, `0x10`, `1e3` are rejected -- `q` (optional, default 75) — quality 1–100, same strict-integer rule as `w`. When `qualities` is configured, an omitted `q` snaps to the configured value closest to 75 instead of being rejected -- `f` (optional) — output format, as a MIME type (`image/webp`) or a bare name (`webp`); both forms are compared against `formats` with the `image/` prefix stripped +- `w` (required) — output width in pixels. Must be a bare positive integer (`^\d+$`, greater than `0`); `parseInt`-style inputs like `8abc`, `0x10`, `1e3` are rejected +- `q` (optional, default 75) — quality 1-100, same strict-integer rule as `w`. When `qualities` is configured, an omitted `q` snaps to the configured value closest to 75 instead of being rejected -These are exactly the params the real `/_vercel/image` endpoint honors (`url`, `w`, `q` — `f` is our internal format-pinning param, see below). Vercel ignores any other query param, so we deliberately do **not** support `h`/`fit`/`blur`/`cache`: honoring them in dev would produce transforms that silently vanish in production. `f` aside, unknown params are ignored, matching Vercel. +These are exactly the params the real `/_vercel/image` endpoint honors, and **every other query param is ignored**, matching it. So there is deliberately no `h`/`fit`/`blur`/`cache`, and no format-pinning param either: honoring any of them in dev would produce a transform that silently vanishes in production. The output format comes from `Accept` alone. + +**Error bodies are Next.js', not Vercel's.** The plain-text messages this endpoint returns (`"url" parameter is required`, `"url" parameter is not allowed`, …) are the ones Next.js' own image optimizer uses. The deployed Vercel endpoint answers _every_ rejection with a generic `Bad request / INVALID_IMAGE_OPTIMIZE_REQUEST / ` page (verified against a Vercel-hosted `/_vercel/image`), which says nothing useful while developing. The detailed messages are a deliberate divergence; the statuses match. They all live in one `MESSAGES` map so the ipx path and the fallback cannot drift apart. Only `GET` and `HEAD` are served; any other method gets `405` with `Allow: GET, HEAD` before the query is even parsed, so a stray `POST` can't silently return an optimized image. -Format auto-detection from `Accept` header when `f` is not provided (prefers avif > webp). ipx's own `f=auto` is deliberately **not** used: its `autoDetectFormat()` falls back to `jpeg` when the `Accept` header offers nothing better, which would flatten PNG transparency, whereas Vercel keeps the source format. Response includes `Vary: Accept` for proper cache keying. Local images are fetched from the worker; remote images are fetched directly. +Format auto-detection from the `Accept` header (prefers avif > webp), narrowed by `formats` when configured. ipx's own `f=auto` is deliberately **not** used: its `autoDetectFormat()` falls back to `jpeg` when the `Accept` header offers nothing better, which would flatten PNG transparency, whereas Vercel keeps the source format. Response includes `Vary: Accept` for proper cache keying. Local images are fetched from the worker; remote images are fetched directly. **Unoptimized fallback:** when `ipx` is not installed, the handler warns once and proxies the source image. This path is kept at parity with the ipx path rather than being the weaker one: a non-ok upstream forwards its own status with `"url" parameter is valid but upstream response is invalid` (a missing local source is a 404, not a 400 "not an image"), an unreachable upstream is a `502` instead of an escaping fetch rejection, and `content-security-policy: default-src 'none'` is applied to match what ipx sets on its own responses. +Upstream headers are **not** copied wholesale. Only `content-type`, `etag`, `last-modified` and `cache-control` pass through, plus `content-length` when the upstream sent no `content-encoding` — `fetch` has already decoded the body, so forwarding `content-encoding: gzip` (and the compressed length beside it) left the client unable to decode what it received, and a `set-cookie` from a remote image origin would have been served under the app's own origin. The upstream status is reused only when it is one a `Response` can actually carry (`errorStatus()`): a worker answering an unconditional request with `304` would otherwise make `new Response(body, { status: 304 })` throw out of `handle()`. Bodies of rejected upstream responses are explicitly cancelled so their socket returns to undici's pool without waiting for GC. + +Remote sources are fetched with `redirect: "error"` on this path. `validateRemoteUrl()` gates only the URL that was requested, and a redirect leaves it — so an allowlisted host could otherwise bounce the fetch to an internal address and have the result served under the app's origin (`blockPrivateIPs` is an `ipxHttpStorage` option and does not apply to a plain `fetch`). The ipx path re-validates every hop instead, so refusing redirects here is what keeps the two paths from disagreeing. Local sources still follow redirects, matching `workerStorage.getData()` on the ipx path, since a framework may legitimately redirect its own asset paths. + **Worker address:** local sources are fetched over TCP from `getAddress()`. `VercelEnvRunner` always listens on TCP, but `createVercelImageHandler()` is public API taking an arbitrary `getAddress`, so a `socketPath` address is rejected up front with a `500` naming the limitation — otherwise it would build `http://undefined:undefined/…` and the failed fetch would surface as a misleading 404 "resource not found". The check is scoped to local sources; a remote source never touches the worker. **ipx wiring (v4):** the ipx **instance** is built once, lazily, and memoized for the lifetime of the runner (`close()` drops it) — it is the expensive half, since it memoizes the `sharp` and `svgo` dynamic imports. The **fetch handler** around it is built per request, because `createIPXFetchHandler(ipx, { parseURL })` is only a closure allocation plus an `Object.assign` (~15µs measured, against ~490ms for one sharp encode). -That split is what keeps format negotiation in one place. `parseURL` is ipx v4's supported hook for a non-default URL style, but its signature is `(url: string) => IPXParsedURL` — it never sees the request, so it cannot read the `Accept` header. With a memoized handler, `parseURL` would be a long-lived closure that has to re-derive the modifiers from the URL alone; the negotiated format would then have to be smuggled back into the URL as an explicit `f=` so the second parse agreed with the first. Building the handler per request instead lets `parseURL` close over the parse `handle()` already did (`() => ({ id: sourceUrl, modifiers })`), so the query is parsed once, `Accept` is read once, and the request is handed to ipx unmodified. Because nothing re-wraps it, a framework mounting the handler on its own server can pass that server's request class straight through. +That split is what keeps format negotiation in one place. `parseURL` is ipx v4's supported hook for a non-default URL style, but its signature is `(url: string) => IPXParsedURL` — it never sees the request, so it cannot read the `Accept` header. With a memoized handler, `parseURL` would be a long-lived closure that has to re-derive the modifiers from the URL alone; the negotiated format would then have to be smuggled back into the URL as an explicit format param so the second parse agreed with the first. Building the handler per request instead lets `parseURL` close over the parse `handle()` already did (`() => ({ id: sourceUrl, modifiers })`), so the query is parsed once, `Accept` is read once, and the request is handed to ipx unmodified. Because nothing re-wraps it, a framework mounting the handler on its own server can pass that server's request class straight through. -ipx owns `content-type`, `etag` (weak, from the source `mtime` + modifiers when available, content-hashed otherwise), `if-none-match`/`if-modified-since` → `304`, `last-modified`, `content-security-policy: default-src 'none'` and `x-content-type-options: nosniff`. The handler then overrides `cache-control` (Vercel semantics, `minimumCacheTTL`), ensures `Vary: Accept`, applies the configured CSP/`content-disposition` (quotes and backslashes are stripped from the filename, which would otherwise terminate the quoted value), and buffers the body to set `content-length` (undici does not derive it from a `Uint8Array` body). **ipx error bodies are re-stated, statuses are kept.** ipx answers failures with an `HTTPError` JSON body naming its own `IPX_*` code and the resolved source path (`{"status":404,"statusText":"IPX_RESOURCE_NOT_FOUND","message":"Resource not found: /sample.jpg"}`). The statuses are already right — better than a blanket 500 — so `IPX_ERROR_MESSAGES` maps each one onto the plain-text message the rest of the endpoint uses, keeping the status: `404`/`502` → `"url" parameter is valid but upstream response is invalid`, `403` (forbidden host/IP) → `"url" parameter is not allowed`, `400` → `"url" parameter is valid but upstream is not an image`, anything else → `Image optimization failed`. That keeps the ipx and unoptimized-fallback paths answering alike and stops ipx internals reaching the client. The only reachable `400`s are undecodable sources (`IPX_INVALID_IMAGE`, `IPX_INVALID_SVG`) — the modifiers ipx receives are just width/quality/format, all validated by `parseImageRequest()` first. A throw escaping ipx's own error handling is not expected, so it is logged once via `console.warn` before the body is normalized the same way. +ipx owns `content-type`, `etag` (weak, from the source `mtime` + modifiers when available, content-hashed otherwise), `if-none-match`/`if-modified-since` → `304`, `last-modified`, `content-security-policy: default-src 'none'` and `x-content-type-options: nosniff`. The handler then overrides `cache-control` (Vercel semantics, `minimumCacheTTL`), ensures `Vary: Accept`, applies the configured CSP/`content-disposition`, and buffers the body to set `content-length` (undici does not derive it from a `Uint8Array` body). + +`content-disposition` is built by `contentDisposition()`: quotes and backslashes are stripped from the filename (they would terminate or escape the quoted value) and anything outside printable ASCII is replaced with `_`, with the real name carried in RFC 5987 `filename*` — appended only when it differs, so the common case stays byte-identical to what Vercel sends. The non-ASCII half is not cosmetic: `Headers.set()` throws a `TypeError` on any value above U+00FF, so a source such as `/日本.png` previously took the whole request down with an unhandled rejection rather than returning a `Response`. The filename is percent-decoded first, since a local source is normalized (and therefore encoded) by then. **ipx error bodies are re-stated, statuses are kept.** ipx answers failures with an `HTTPError` JSON body naming its own `IPX_*` code and the resolved source path (`{"status":404,"statusText":"IPX_RESOURCE_NOT_FOUND","message":"Resource not found: /sample.jpg"}`). The statuses are already right — better than a blanket 500 — so `IPX_ERROR_MESSAGES` maps each one onto the plain-text message the rest of the endpoint uses, keeping the status: `404`/`502` → `"url" parameter is valid but upstream response is invalid`, `403` (forbidden host/IP) → `"url" parameter is not allowed`, `400` → `"url" parameter is valid but upstream is not an image`, anything else → `Image optimization failed`. That keeps the ipx and unoptimized-fallback paths answering alike and stops ipx internals reaching the client. The only reachable `400`s are undecodable sources (`IPX_INVALID_IMAGE`, `IPX_INVALID_SVG`) — the modifiers ipx receives are just width/quality/format, all validated by `parseImageRequest()` first. A throw escaping ipx's own error handling is not expected, so it is logged once via `console.warn` before the body is normalized the same way. Two ipx v4 defaults are relied on rather: `maxOutputDimension` (8192) clamps `w`/`h` so a request cannot make sharp allocate a huge buffer, and SVG output is always sanitized (scripts, `on*` handlers, `javascript:` URIs) plus optimized with svgo. +**Local sources are normalized before they are validated**, and the normalized form is what becomes the fetch id (`new URL(sourceUrl, "http://localhost")` → `pathname + search`). Validating the raw query-param string while fetching it through a URL parser meant the two disagreed, and `localPatterns` could be walked out of: `/assets/../secret.png` lexically satisfies a `/assets/**` rule and then resolves to `/secret.png` on the worker (which the same config denies when asked for directly), and the hand-rolled `split("?")` hid everything past a second `?` from a `search` rule, so `/a.png?v=1?evil=2` passed `search: "?v=1"` and was then fetched with both query strings. The invariant is now that the allowlist sees exactly the path the worker is asked for. Percent-encoding is deliberately left intact rather than decoded, for the same reason (`/assets/..%2Fx` is forwarded as written, so whatever the worker does with it is what was checked) — a `localPatterns` glob therefore matches the encoded pathname, as it does in Next.js. + **URL validation:** Remote sources are **default-deny**, matching Vercel — with neither `domains` nor `remotePatterns` configured, every remote `url` is rejected with 400 `"url" parameter is not allowed`. Allowing them by default would both diverge from production and turn the dev server into an open image proxy. Configured remotes are matched against `domains` (exact hostname) and `remotePatterns` (protocol, hostname glob/regex, port, pathname glob/regex). Local paths are allowed unless narrowed by `localPatterns`. Compiled patterns are cached in a module-level `Map` since they are re-tested on every request. SVG sources are blocked by default (400) unless `dangerouslyAllowSVG` is true. +A Build Output API pattern is raw user-authored regex, so a typo is a `SyntaxError` from `new RegExp()`. `patternToRegExp()` catches it, warns once per pattern (the cache makes it once), and substitutes a never-matching regex so the bad rule denies itself and the rest of the config keeps working. Without that, an invalid `localPatterns` entry threw straight out of `handle()` — `validateLocalUrl()` has no `catch` of its own, so every request under that config failed as an unhandled throw — while an invalid `remotePatterns` entry was silently swallowed as a blanket deny by `validateRemoteUrl()`'s `catch`. The glob branch cannot throw, since `[` and `]` are escaped and no character class can form. + Because ipx follows redirects itself, the allowlist is also handed to `ipxHttpStorage({ domains })` whenever every configured hostname is a literal (`literalHostnames()`), so each redirect hop is re-validated and an allowlisted host cannot bounce the fetch to an internal address. Configs using globs or Build Output API regexes fall back to `allowAllDomains: true` (only the initial URL is gated, by our own matcher). Constructor accepts optional `images` config (`VercelImageConfig`) matching the Vercel Build Output API `images` property: `sizes`, `domains`, `remotePatterns`, `localPatterns`, `qualities`, `formats`, `minimumCacheTTL`, `dangerouslyAllowSVG`, `contentSecurityPolicy`, `contentDispositionType`. Plus one env-runner extension: `blockPrivateIPs` (**default `true`**) forwards to `ipxHttpStorage` to reject remote sources that are, or resolve to, a non-public IP. It only affects remote sources — local paths always go through the worker storage, never `ipxHttpStorage`. Set it to `false` to optimize from a `localhost`/in-cluster origin. @@ -84,7 +95,7 @@ Constructor accepts optional `images` config (`VercelImageConfig`) matching the - Vercel suites (`test/vercel.test.ts` and the Vercel entry in `test/runners.test.ts`) stub a fake far-future `VERCEL_OIDC_TOKEN` via `vi.stubEnv` so the OIDC check doesn't log warnings (real env token takes precedence) - **`test/vercel.test.ts`** — Tests for `VercelEnvRunner`: request header injection (`x-vercel-deployment-url`, `x-vercel-id`, `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `x-forwarded-proto`, `x-forwarded-host`), response header injection (`server`, `x-vercel-id`, `x-vercel-cache`), environment variables (`VERCEL`, `VERCEL_ENV`, `VERCEL_REGION`, `NOW_REGION`), header preservation, pre-existing header respect. Its `image optimization` block covers **wiring only** — that `/_vercel/image` reaches the handler, that request headers and the request method survive the hop, that the `images` config is threaded through, and that the Vercel response headers are injected on both success and 400 responses. Its `host-runtime request objects` block starts a real srvx server and passes srvx's own request through `fetch()` (both the normal and the image path) — this is the only coverage for the no-re-wrap rule above, since every other test calls `fetch()` with a URL string or an undici `Request` -- **`test/vercel-image.test.ts`** — Unit tests for `createVercelImageHandler()` with a stub `getAddress`, so the whole matrix runs without spawning a worker (~300ms). A single `node:http` server doubles as the worker and as the "remote" origin for the `domains`/`remotePatterns`/`blockPrivateIPs` cases; it decodes the request path so the percent-encoded `/od"d.png` route can exercise `content-disposition` filename escaping. Covers request-method gating (GET/HEAD vs 405), `socketPath` rejection for local sources (and non-rejection for remote ones), parameter validation, remote default-deny, `localPatterns`, SVG, cache-control/`cache`/Vary/content-length/content-disposition/baseline CSP, ETag + 304, format negotiation (including through a real srvx server, since the request now reaches ipx unwrapped), and upstream failures with their re-stated bodies (404 missing source, 400 undecodable source, 403 blocked private IP — each asserting the plain-text message rather than only the status, which is what pins the ipx JSON out of the response). A trailing `describe` re-imports the module under `vi.doMock("ipx")` to exercise the unoptimized fallback (warn-once, non-image upstream, forwarded upstream status, unreachable-upstream 502, baseline CSP, 405, 503 with no address) +- **`test/vercel-image.test.ts`** — Unit tests for `createVercelImageHandler()` with a stub `getAddress`, so the whole matrix runs without spawning a worker (~300ms). A single `node:http` server doubles as the worker and as the "remote" origin for the `domains`/`remotePatterns`/`blockPrivateIPs` cases; it decodes the request path so the percent-encoded `/od"d.png` route can exercise `content-disposition` filename escaping. Covers request-method gating (GET/HEAD vs 405), `socketPath` rejection for local sources (and non-rejection for remote ones), parameter validation, remote default-deny, `localPatterns` (including the two normalization bypasses above: a `../` traversal out of the allowed prefix and a `search` rule satisfied by only the first of two query strings), SVG, cache-control/`cache`/Vary/content-length/content-disposition/baseline CSP, a non-ASCII filename surviving as `filename*` on both a local and a remote source, ETag + 304, format negotiation (including through a real srvx server, since the request now reaches ipx unwrapped), the params the endpoint ignores (`f`/`h`/`fit`/`blur`/unknown — asserted under `accept: image/png` so a pinned format would show up as a changed `content-type`), and upstream failures with their re-stated bodies (404 missing source, 400 undecodable source, 403 blocked private IP — each asserting the plain-text message rather than only the status, which is what pins the ipx JSON out of the response). A trailing `describe` re-imports the module under `vi.doMock("ipx")` to exercise the unoptimized fallback (warn-once, non-image upstream, forwarded upstream status, unreachable-upstream 502, baseline CSP, 405, 503 with no address, a gzipped upstream losing its `content-encoding`/stale length, `set-cookie` not being forwarded, and an upstream `304` becoming a 502 instead of throwing). The fake origin serves `/gzipped.png`, `/notmodified.png` and `/日本.png` for those cases - Test fixture in `test/fixtures/app-headers.mjs` — Entry that echoes all request headers as JSON for vercel header injection tests - Test fixture in `test/fixtures/app-env.mjs` — Entry that echoes request headers and selected environment variables as JSON - Test fixture in `test/fixtures/app-image.mjs` — Entry that serves a 1x1 PNG at `/test.png` for the vercel image wiring tests diff --git a/src/runners/vercel/image.ts b/src/runners/vercel/image.ts index c84b3d4..df3a24a 100644 --- a/src/runners/vercel/image.ts +++ b/src/runners/vercel/image.ts @@ -38,22 +38,29 @@ export interface VercelImageConfig { const DEFAULT_MAX_AGE = 60; const DEFAULT_QUALITY = 75; +// Shared by request validation, the ipx path, the unoptimized fallback and the ipx +// error mapping, so all four answer a given failure with the same body. These are +// the messages Next.js' image optimizer uses; Vercel's own `/_vercel/image` +// answers every rejection with a generic `INVALID_IMAGE_OPTIMIZE_REQUEST` page, +// which says nothing useful in dev. +const MESSAGES = { + notAllowed: '"url" parameter is not allowed', + notAnImage: '"url" parameter is valid but upstream is not an image', + typeNotAllowed: '"url" parameter is valid but image type is not allowed', + upstreamInvalid: '"url" parameter is valid but upstream response is invalid', +} as const; + type IPXModule = typeof import("ipx"); -let _ipxModule: IPXModule | undefined; -let _ipxLoaded = false; +let _ipxPromise: Promise | undefined; -async function loadIPX(): Promise { - if (_ipxLoaded) return _ipxModule; - _ipxLoaded = true; - try { - _ipxModule = await import("ipx"); - } catch { +function loadIPX(): Promise { + return (_ipxPromise ||= import("ipx").catch(() => { console.warn( "ipx is not installed. Install it for Vercel image optimization: npx nypm i -D ipx", ); - } - return _ipxModule; + return undefined; + })); } // `VercelEnvRunner` extends `NodeWorkerEnvRunner`, which always listens on TCP @@ -82,12 +89,28 @@ function isRemoteUrl(url: string): boolean { // Patterns come from config and are re-tested on every request, so compile once. const _patternCache = new Map(); +// Denies the rule it came from instead of matching everything. Safe to share: +// without a `g`/`y` flag, `.test()` keeps no per-regex state. +const NEVER_MATCH = /(?!)/; + function patternToRegExp(pattern: string): RegExp { let compiled = _patternCache.get(pattern); if (compiled) return compiled; if (pattern.startsWith("^") && pattern.endsWith("$")) { - compiled = new RegExp(pattern); + // Build Output API patterns are raw user-authored regex, so a typo is a + // `SyntaxError` — which threw straight out of `handle()` via + // `validateLocalUrl()`, and was silently swallowed as a blanket deny by + // `validateRemoteUrl()`'s `catch`. Warn once (the cache below makes it once + // per pattern) and fail closed, so one bad rule can't take down the endpoint. + try { + compiled = new RegExp(pattern); + } catch { + console.warn(`[env-runner] ignoring invalid Vercel image pattern: ${pattern}`); + compiled = NEVER_MATCH; + } } else { + // The glob branch can't throw: `[` and `]` are escaped below, so no + // character class — the one unterminated construct a glob could produce — forms. let re = "^"; for (let i = 0; i < pattern.length; i++) { const ch = pattern.charAt(i); @@ -134,12 +157,14 @@ function validateRemoteUrl(sourceUrl: string, config?: VercelImageConfig): boole return false; } -function validateLocalUrl(sourceUrl: string, config?: VercelImageConfig): boolean { +// Takes the parsed (and therefore normalized) local URL rather than the raw string: +// see `parseImageRequest()` for why the two must not diverge. +function validateLocalUrl(sourceUrl: URL, config?: VercelImageConfig): boolean { if (!config?.localPatterns?.length) return true; - const [pathname = "", search] = sourceUrl.split("?"); + const search = sourceUrl.search.replace(/^\?/, ""); return config.localPatterns.some((p) => { - if (p.pathname && !matchPattern(p.pathname, pathname)) return false; - if (p.search !== undefined && (search || "") !== p.search.replace(/^\?/, "")) return false; + if (p.pathname && !matchPattern(p.pathname, sourceUrl.pathname)) return false; + if (p.search !== undefined && search !== p.search.replace(/^\?/, "")) return false; return true; }); } @@ -158,15 +183,14 @@ function literalHostnames(config?: VercelImageConfig): string[] | undefined { } function isSvgSource(url: string): boolean { - const path = url.startsWith("/") - ? url - : (() => { - try { - return new URL(url).pathname; - } catch { - return url; - } - })(); + let path = url; + if (!url.startsWith("/")) { + try { + path = new URL(url).pathname; + } catch { + // Not a valid absolute URL either; fall back to matching the raw string. + } + } return /\.svgz?(\?|$)/i.test(path); } @@ -197,17 +221,71 @@ function applySecurityHeaders( headers.set("content-security-policy", "default-src 'none'"); } if (config?.contentDispositionType) { - // Quotes and backslashes are stripped rather than escaped: they would - // otherwise terminate the quoted `filename` and mangle the whole header. - const filename = sourceUrl.split("/").pop()?.split("?")[0]?.replaceAll(/["\\]/g, "") || "image"; - headers.set("content-disposition", `${config.contentDispositionType}; filename="${filename}"`); + headers.set( + "content-disposition", + contentDisposition(config.contentDispositionType, sourceUrl), + ); + } +} + +// A quoted `filename` can carry neither quotes/backslashes (they terminate or +// escape the value) nor anything above U+00FF: `Headers.set()` throws on a +// non-ByteString value, and that `TypeError` escaped `handle()` as an unhandled +// rejection for any source with a non-Latin1 name (`/日本.png`). So quotes are +// stripped, everything outside printable ASCII is replaced, and the real name is +// carried by RFC 5987 `filename*` — appended only when it differs, so the common +// case stays byte-identical to what Vercel sends. +function contentDisposition(type: string, sourceUrl: string): string { + const segment = sourceUrl.split("?")[0]!.split("/").pop() || ""; + let name = segment; + try { + // A local source is normalized (and therefore percent-encoded) by the time it + // gets here, so decode it back into the name the user recognizes. + name = decodeURIComponent(segment); + } catch { + // Malformed percent-encoding; keep the raw segment. + } + name = name.replaceAll(/["\\]/g, "") || "image"; + const ascii = name.replaceAll(/[^\u0020-\u007E]/g, "_"); + const value = `${type}; filename="${ascii}"`; + return ascii === name ? value : `${value}; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +// Content-type is the source of truth for the SVG block, not the URL: a source +// whose URL doesn't look like SVG can still resolve to `image/svg+xml`, so both +// the ipx path and the unoptimized fallback re-check it on the response. +function blockSvgOutput(contentType: string, config?: VercelImageConfig): Response | undefined { + // `image/svg` (without the `+xml`) as well: browsers do not render it, but there + // is no reason for the check to be narrower than the media types it is guarding. + if (!config?.dangerouslyAllowSVG && /^image\/svg\b/i.test(contentType)) { + return new Response(MESSAGES.typeNotAllowed, { status: 400 }); + } + return undefined; +} + +// Shared by the ipx path and the unoptimized fallback. `overwriteCacheControl` +// captures their one real difference: ipx's `cache-control` reflects Vercel's own +// `minimumCacheTTL` semantics and always wins, while the fallback only fills it in +// when the upstream didn't already set one. +function finalizeImageHeaders( + headers: Headers, + sourceUrl: string, + config: VercelImageConfig | undefined, + maxAge: number, + overwriteCacheControl: boolean, +): void { + if (overwriteCacheControl || !headers.has("cache-control")) { + headers.set("cache-control", `public, max-age=${maxAge}, s-maxage=${maxAge}`); } + ensureVaryAccept(headers); + applySecurityHeaders(headers, sourceUrl, config); } // --- Request parsing --- interface ParsedImageRequest { sourceUrl: string; + isLocal: boolean; modifiers: Record; } @@ -215,7 +293,10 @@ function badRequest(message: string): Response { return new Response(message, { status: 400 }); } -/** Strips the `image/` prefix so `f=webp` and `f=image/webp` compare equal. */ +/** + * Strips the `image/` prefix so a configured `formats` entry (`image/webp`, as the + * Build Output API writes it) compares equal to a negotiation candidate (`webp`). + */ function bareFormat(format: string): string { return format.replace(/^image\//, ""); } @@ -239,15 +320,28 @@ const IPX_ERROR_MESSAGES: Record = { // The only reachable 400s are undecodable sources (`IPX_INVALID_IMAGE`, // `IPX_INVALID_SVG`): the modifiers ipx receives are width/quality/format, all // already validated by `parseImageRequest()`. - 400: '"url" parameter is valid but upstream is not an image', - 403: '"url" parameter is not allowed', - 404: '"url" parameter is valid but upstream response is invalid', + 400: MESSAGES.notAnImage, + 403: MESSAGES.notAllowed, + 404: MESSAGES.upstreamInvalid, // DNS failure, redirect loop, bad redirect - 502: '"url" parameter is valid but upstream response is invalid', + 502: MESSAGES.upstreamInvalid, }; -function ipxError(status: number): Response { - return new Response(IPX_ERROR_MESSAGES[status] || "Image optimization failed", { status }); +// `new Response(body, { status })` throws for a null-body status (204/304) and a +// `RangeError` for anything outside 200-599, either of which would escape +// `handle()`. Statuses that reach here come from an upstream response or from a +// thrown error's `status`/`statusCode`, so neither is trustworthy on its own. +function errorStatus(status: unknown, fallback: number): number { + return typeof status === "number" && Number.isInteger(status) && status >= 400 && status <= 599 + ? status + : fallback; +} + +function ipxError(status: unknown): Response { + const resolved = errorStatus(status, 500); + return new Response(IPX_ERROR_MESSAGES[resolved] || "Image optimization failed", { + status: resolved, + }); } /** @@ -262,7 +356,7 @@ function parseImageRequest( accept: string, config: VercelImageConfig | undefined, ): ParsedImageRequest | Response { - const sourceUrl = url.searchParams.get("url"); + let sourceUrl = url.searchParams.get("url"); if (!sourceUrl) { return badRequest('"url" parameter is required'); } @@ -302,96 +396,131 @@ function parseImageRequest( } } - const allowedFormats = config?.formats?.map(bareFormat); - const f = url.searchParams.get("f"); - const format = f ? bareFormat(f) : undefined; - if (format && allowedFormats?.length && !allowedFormats.includes(format)) { - return badRequest(`"f" must be one of: ${config!.formats!.join(", ")}`); - } - // Reject protocol-relative URLs to avoid local/remote ambiguity if (sourceUrl.startsWith("//")) { - return badRequest('"url" parameter is not allowed'); + return badRequest(MESSAGES.notAllowed); } const isLocal = sourceUrl.startsWith("/"); - const isRemote = isRemoteUrl(sourceUrl); - if (!isLocal && !isRemote) { - return badRequest('"url" parameter is not allowed'); - } - if (isRemote && !validateRemoteUrl(sourceUrl, config)) { - return badRequest('"url" parameter is not allowed'); - } - if (isLocal && !validateLocalUrl(sourceUrl, config)) { - return badRequest('"url" parameter is not allowed'); + if (isLocal) { + // A local source is normalized before it is validated *and* before it becomes + // the fetch id, so the allowlist sees exactly the path the worker will be asked + // for. Matching the raw string instead let `/assets/../secret.png` satisfy a + // `/assets/**` rule and then resolve to `/secret.png` once fetched, and the + // hand-rolled `split("?")` hid everything after a second `?` from a `search` + // rule (`/a.png?v=1?evil=2` passed `search: "?v=1"`). + const localUrl = new URL(sourceUrl, "http://localhost"); + sourceUrl = localUrl.pathname + localUrl.search; + if (!validateLocalUrl(localUrl, config)) { + return badRequest(MESSAGES.notAllowed); + } + } else if (isRemoteUrl(sourceUrl)) { + if (!validateRemoteUrl(sourceUrl, config)) { + return badRequest(MESSAGES.notAllowed); + } + } else { + return badRequest(MESSAGES.notAllowed); } // Block SVG unless explicitly allowed if (!config?.dangerouslyAllowSVG && isSvgSource(sourceUrl)) { - return badRequest('"url" parameter is valid but image type is not allowed'); + return badRequest(MESSAGES.typeNotAllowed); } const modifiers: Record = { width, quality }; - // Format: explicit param > Accept header negotiation - const resolvedFormat = format ?? negotiateFormat(accept, allowedFormats); + // The output format is negotiated from `Accept` and nothing else, matching the + // deployed endpoint: it honors `url`, `w` and `q`, and ignores every other query + // param. A param that pinned the format here would be a transform that works in + // dev and silently disappears in production — the same reason `h`/`fit`/`blur` + // are not supported. + const resolvedFormat = negotiateFormat(accept, config?.formats?.map(bareFormat)); if (resolvedFormat) { modifiers.format = resolvedFormat; } - return { sourceUrl, modifiers }; + return { sourceUrl, isLocal, modifiers }; } // --- Unoptimized fallback --- +// Everything else the upstream sent is dropped. `fetch` has already decoded the +// body, so forwarding `content-encoding` (and the compressed `content-length` +// beside it) left the client unable to decode what it received — and a `set-cookie` +// from a remote image origin has no business being served under the app's own. +const PASSTHROUGH_HEADERS = ["content-type", "etag", "last-modified", "cache-control"]; + +function imageHeadersFrom(upstream: Headers): Headers { + const headers = new Headers(); + for (const name of PASSTHROUGH_HEADERS) { + const value = upstream.get(name); + if (value) headers.set(name, value); + } + // Only describes the body about to be served when nothing was encoded on the wire. + if (!upstream.has("content-encoding")) { + const length = upstream.get("content-length"); + if (length) headers.set("content-length", length); + } + return headers; +} + +// An upstream body that is never read keeps its socket checked out of undici's pool +// until the response is garbage collected. +function discardBody(res: Response): void { + res.body?.cancel().catch(() => {}); +} + async function fetchUnoptimized( sourceUrl: string, + isLocal: boolean, getAddress: () => WorkerAddress | undefined, config: VercelImageConfig | undefined, maxAge: number, ): Promise { let res: Response; try { - if (sourceUrl.startsWith("/")) { + if (isLocal) { const address = getAddress(); if (!address) { return new Response("Runner not ready", { status: 503 }); } + // Redirects are followed here, matching `workerStorage.getData()` on the ipx + // path: a framework may legitimately redirect its own asset paths. res = await fetch(resolveWorkerUrl(address, sourceUrl)); } else { - res = await fetch(sourceUrl); + // `validateRemoteUrl()` only gated this URL, and a redirect leaves it — an + // allowlisted host could bounce the fetch to an internal address. The ipx path + // re-validates every hop via `ipxHttpStorage({ domains })`, so refuse them + // here rather than let the fallback be the weaker path (`blockPrivateIPs` + // is an ipx-only knob and does not apply to this `fetch`). + res = await fetch(sourceUrl, { redirect: "error" }); } } catch { - // Connection refused, DNS failure, aborted upstream - return new Response('"url" parameter is valid but upstream response is invalid', { - status: 502, - }); + // Connection refused, DNS failure, aborted upstream, refused redirect + return new Response(MESSAGES.upstreamInvalid, { status: 502 }); } - // A failed upstream is a missing or broken source, not a content-type problem + // A failed upstream is a missing or broken source, not a content-type problem. + // Its status is reused only when it is one a `Response` can carry (a `304` from a + // worker answering an unconditional request would otherwise throw). if (!res.ok) { - return new Response('"url" parameter is valid but upstream response is invalid', { - status: res.status, - }); + discardBody(res); + return new Response(MESSAGES.upstreamInvalid, { status: errorStatus(res.status, 502) }); } - const headers = new Headers(res.headers); - const contentType = headers.get("content-type") || ""; + const contentType = res.headers.get("content-type") || ""; if (!/^image\//i.test(contentType)) { - return new Response('"url" parameter is valid but upstream is not an image', { - status: 400, - }); - } - if (!config?.dangerouslyAllowSVG && /^image\/svg\+xml\b/i.test(contentType)) { - return new Response('"url" parameter is valid but image type is not allowed', { - status: 400, - }); + discardBody(res); + return new Response(MESSAGES.notAnImage, { status: 400 }); } - ensureVaryAccept(headers); - if (!headers.has("cache-control")) { - headers.set("cache-control", `public, max-age=${maxAge}, s-maxage=${maxAge}`); + const svgBlock = blockSvgOutput(contentType, config); + if (svgBlock) { + discardBody(res); + return svgBlock; } - applySecurityHeaders(headers, sourceUrl, config); + + const headers = imageHeadersFrom(res.headers); + finalizeImageHeaders(headers, sourceUrl, config, maxAge, false); return new Response(res.body, { status: res.status, @@ -507,10 +636,10 @@ export function createVercelImageHandler(opts: { if (parsed instanceof Response) { return parsed; } - const { sourceUrl } = parsed; + const { sourceUrl, isLocal } = parsed; // Only local sources go through the worker; a remote source never touches it. - if (sourceUrl.startsWith("/")) { + if (isLocal) { const socketError = rejectSocketAddress(getAddress()); if (socketError) { return socketError; @@ -519,7 +648,7 @@ export function createVercelImageHandler(opts: { const createFetchHandler = await getFetchHandlerFactory(); if (!createFetchHandler) { - return fetchUnoptimized(sourceUrl, getAddress, config, maxAge); + return fetchUnoptimized(sourceUrl, isLocal, getAddress, config, maxAge); } let res: Response; @@ -529,7 +658,7 @@ export function createVercelImageHandler(opts: { // Unexpected: ipx converts its own HTTPErrors into responses. Log the detail, // since the body is normalized like every other error. console.warn("[env-runner] vercel image optimization failed:", error); - return ipxError(error.status || error.statusCode || 500); + return ipxError(error.status ?? error.statusCode); } // 404 for a missing source, 403 for a forbidden host/IP, 400 for an undecodable @@ -539,22 +668,18 @@ export function createVercelImageHandler(opts: { } // Defense in depth: block SVG output even if the URL check was bypassed - if ( - !config?.dangerouslyAllowSVG && - /^image\/svg\+xml\b/i.test(res.headers.get("content-type") || "") - ) { - return new Response('"url" parameter is valid but image type is not allowed', { - status: 400, - }); - } + const svgBlock = blockSvgOutput(res.headers.get("content-type") || "", config); + if (svgBlock) return svgBlock; const headers = new Headers(res.headers); - headers.set("cache-control", `public, max-age=${maxAge}, s-maxage=${maxAge}`); - ensureVaryAccept(headers); - applySecurityHeaders(headers, sourceUrl, config); - - if (res.status === 304) { - return new Response(null, { status: 304, headers }); + finalizeImageHeaders(headers, sourceUrl, config, maxAge, true); + + // ipx (via h3) already nulls the body for 304s and for HEAD requests, in + // both cases keeping the `content-length` it computed from the full image. + // Buffering `res.arrayBuffer()` below would read that as empty and clobber + // a correct header with `0`, so a null body always short-circuits first. + if (res.body === null) { + return new Response(null, { status: res.status, headers }); } // Buffered so `content-length` is always set (ipx has the whole image in diff --git a/test/vercel-image.test.ts b/test/vercel-image.test.ts index 1f07026..3a57ad6 100644 --- a/test/vercel-image.test.ts +++ b/test/vercel-image.test.ts @@ -1,6 +1,7 @@ import type { AddressInfo } from "node:net"; import { createServer, type Server } from "node:http"; +import { gzipSync } from "node:zlib"; import { serve } from "srvx"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -31,9 +32,23 @@ beforeAll(async () => { // Decoded so a path with percent-encoded characters (see the // content-disposition escaping test) is matched by its literal form const path = decodeURIComponent((req.url || "").split("?")[0]!); - if (path === '/od"d.png') { + if (path === '/od"d.png' || path === "/日本.png") { res.writeHead(200, { "content-type": "image/png" }); res.end(req.method === "HEAD" ? undefined : PNG_1x1); + } else if (path === "/gzipped.png") { + // `fetch` decodes this before the fallback path sees it, so the upstream + // `content-encoding`/`content-length` no longer describe the served body + const gz = gzipSync(PNG_1x1); + res.writeHead(200, { + "content-type": "image/png", + "content-encoding": "gzip", + "content-length": String(gz.length), + "set-cookie": "sid=abc; Path=/", + }); + res.end(req.method === "HEAD" ? undefined : gz); + } else if (path === "/notmodified.png") { + // A worker answering an unconditional request with 304 + res.writeHead(304).end(); } else if (path === "/test.png" || path === "/assets/test.png") { res.writeHead(200, { "content-type": "image/png" }); res.end(req.method === "HEAD" ? undefined : PNG_1x1); @@ -43,6 +58,10 @@ beforeAll(async () => { } else if (path === "/note.txt") { res.writeHead(200, { "content-type": "text/plain" }); res.end(req.method === "HEAD" ? undefined : "not an image"); + } else if (path === "/redirect.png") { + // Stands in for an allowlisted host bouncing the fetch elsewhere + res.writeHead(302, { location: `http://127.0.0.1:${port}/test.png` }); + res.end(); } else { res.writeHead(404).end(); } @@ -159,23 +178,20 @@ describe("createVercelImageHandler", () => { expect(res.status).toBe(200); }); - it("rejects a format outside the configured formats", async () => { - const res = await get( - makeHandler({ formats: ["image/webp"] }), - "url=/test.png&w=8&f=image/avif", - ); - expect(res.status).toBe(400); - expect(await res.text()).toBe('"f" must be one of: image/webp'); - }); - - it("compares f against formats without the image/ prefix", async () => { - // `f=webp` and `f=image/webp` must both satisfy `formats: ["image/webp"]` - for (const f of ["webp", "image/webp"]) { - const res = await get(makeHandler({ formats: ["image/webp"] }), `url=/test.png&w=8&f=${f}`); + // The deployed endpoint honors `url`, `w` and `q` and ignores everything else — + // verified against a Vercel-hosted `/_vercel/image`, where `f=image/webp` under + // `accept: image/png` still returns PNG. Anything that pinned the format here + // would be a dev-only transform that vanishes in production. + it.each(["f=image/webp", "f=webp", "h=8", "fit=cover", "blur=5", "unknown=1"])( + "ignores %s", + async (param) => { + const res = await get(makeHandler(), `url=/test.png&w=8&${param}`, { + accept: "image/png,*/*", + }); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("image/webp"); - } - }); + expect(res.headers.get("content-type")).toBe("image/png"); + }, + ); it.each(["//evil.example/x.png", "data:image/png;base64,AAAA", "ftp://x.example/a.png"])( "rejects a non-local, non-http url (%s)", @@ -284,6 +300,83 @@ describe("createVercelImageHandler", () => { expect(res.status).toBe(400); expect(await res.text()).toBe('"url" parameter is not allowed'); }); + + // The pattern is matched against the normalized path, not the raw string: + // `/assets/../test.png` lexically satisfies `/assets/**` but resolves to + // `/test.png` once fetched, which the same config denies outright + it("denies a traversal that escapes localPatterns", async () => { + const res = await get( + makeHandler({ localPatterns: [{ pathname: "/assets/**" }] }), + `url=${encodeURIComponent("/assets/../test.png")}&w=8`, + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + }); + + // Everything after the *first* `?` is the query, so a `search` rule cannot be + // satisfied by the first of two query strings + it("denies a search that only matches up to a second ?", async () => { + const config: VercelImageConfig = { + localPatterns: [{ pathname: "/test.png", search: "?v=1" }], + }; + const allowed = await get( + makeHandler(config), + `url=${encodeURIComponent("/test.png?v=1")}&w=8`, + ); + expect(allowed.status).toBe(200); + + const denied = await get( + makeHandler(config), + `url=${encodeURIComponent("/test.png?v=1?evil=2")}&w=8`, + ); + expect(denied.status).toBe(400); + expect(await denied.text()).toBe('"url" parameter is not allowed'); + }); + }); + + describe("malformed config patterns", () => { + // A `SyntaxError` from `new RegExp()` used to escape `handle()` here, since + // `validateLocalUrl()` has no `catch` of its own — every request for that + // config then failed as an unhandled throw rather than a `Response`. + it("denies rather than throwing on an invalid localPatterns regex", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = makeHandler({ localPatterns: [{ pathname: "^/assets/[a-z$" }] }); + + const res = await get(handler, "url=/assets/test.png&w=8"); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("invalid Vercel image pattern")); + + // Compiled patterns are cached, so the warning is not repeated per request + await get(handler, "url=/assets/test.png&w=8"); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + // `validateRemoteUrl()`'s `catch` already turned this into a deny, but silently + // — the warning is what makes a typo'd pattern diagnosable rather than a + // mystery "not allowed" on every remote image. + it("warns and denies on an invalid remotePatterns regex", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await get( + makeHandler({ remotePatterns: [{ hostname: "^127\\.0\\.[a-z$" }] }), + `url=${encodeURIComponent(`http://127.0.0.1:${port}/test.png`)}&w=8`, + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe('"url" parameter is not allowed'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("invalid Vercel image pattern")); + warn.mockRestore(); + }); + + it("still matches the valid patterns alongside an invalid one", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await get( + makeHandler({ localPatterns: [{ pathname: "^/bad/[a-z$" }, { pathname: "/assets/**" }] }), + "url=/assets/test.png&w=8", + ); + expect(res.status).toBe(200); + warn.mockRestore(); + }); }); describe("svg", () => { @@ -316,6 +409,23 @@ describe("createVercelImageHandler", () => { expect(Number(res.headers.get("content-length"))).toBe((await res.arrayBuffer()).byteLength); }); + // ipx nulls the body for HEAD while keeping the length it computed from the + // full image; buffering that empty body would report `content-length: 0`. + it("keeps the real content-length on a HEAD request", async () => { + const handler = makeHandler(); + const expected = Number( + (await get(handler, "url=/test.png&w=8")).headers.get("content-length"), + ); + expect(expected).toBeGreaterThan(0); + + const res = await handler.handle( + new Request("http://localhost/_vercel/image?url=/test.png&w=8", { method: "HEAD" }), + ); + expect(res.status).toBe(200); + expect(Number(res.headers.get("content-length"))).toBe(expected); + expect(await res.text()).toBe(""); + }); + it("sets a baseline CSP", async () => { const res = await get(makeHandler(), "url=/test.png&w=8"); expect(res.headers.get("content-security-policy")).toBe("default-src 'none'"); @@ -329,6 +439,26 @@ describe("createVercelImageHandler", () => { expect(res.headers.get("content-disposition")).toBe('attachment; filename="test.png"'); }); + // A raw non-Latin1 filename cannot go in a header value at all: `Headers.set()` + // throws, and that `TypeError` used to escape `handle()` entirely + it.each([ + ["local", () => `url=${encodeURIComponent("/日本.png")}&w=8`], + ["remote", () => `url=${encodeURIComponent(`http://127.0.0.1:${port}/日本.png`)}&w=8`], + ])("carries a non-ASCII %s filename in filename*", async (_kind, query) => { + const res = await get( + makeHandler({ + contentDispositionType: "attachment", + domains: ["127.0.0.1"], + blockPrivateIPs: false, + }), + query(), + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toBe( + `attachment; filename="__.png"; filename*=UTF-8''${encodeURIComponent("日本.png")}`, + ); + }); + it("strips quotes from the content-disposition filename", async () => { // An unescaped `"` would terminate the quoted filename and mangle the header const res = await get( @@ -493,6 +623,53 @@ describe("createVercelImageHandler without ipx", () => { expect(res.headers.get("content-security-policy")).toBe("default-src 'none'"); }); + // The allowlist gates only the requested URL, so following a redirect would let + // an allowlisted host serve an unvalidated address under the app's origin. The + // ipx path re-validates each hop; this path refuses them instead. + it("refuses to follow a redirect from a remote source", async () => { + const handler = await makeBareHandler({ domains: ["127.0.0.1"] }); + const res = await get( + handler, + `url=${encodeURIComponent(`http://127.0.0.1:${port}/redirect.png`)}&w=8`, + ); + expect(res.status).toBe(502); + expect(await res.text()).toBe('"url" parameter is valid but upstream response is invalid'); + }); + + it("still follows a redirect from a local source", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/redirect.png&w=8"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + }); + + // `fetch` has already decoded the body by the time it is re-served, so forwarding + // the upstream `content-encoding` left the client unable to decode it + it("drops content-encoding and the stale content-length", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/gzipped.png&w=8"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBeNull(); + const body = await res.arrayBuffer(); + expect(body.byteLength).toBe(PNG_1x1.byteLength); + const length = res.headers.get("content-length"); + if (length !== null) expect(Number(length)).toBe(body.byteLength); + }); + + it("does not forward set-cookie from the upstream", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/gzipped.png&w=8"); + expect(res.headers.getSetCookie()).toEqual([]); + }); + + // `new Response(body, { status: 304 })` throws, so the status cannot be reused + it("reports an upstream 304 as 502 rather than throwing", async () => { + const handler = await makeBareHandler(); + const res = await get(handler, "url=/notmodified.png&w=8"); + expect(res.status).toBe(502); + expect(await res.text()).toBe('"url" parameter is valid but upstream response is invalid'); + }); + it("rejects non-GET/HEAD before touching the upstream", async () => { const handler = await makeBareHandler(); const res = await handler.handle( diff --git a/test/vercel.test.ts b/test/vercel.test.ts index 222bee3..96de5d5 100644 --- a/test/vercel.test.ts +++ b/test/vercel.test.ts @@ -36,6 +36,22 @@ describe("VercelEnvRunner", () => { expect(runner.ready).toBe(true); }); + // `#initWorker()` calls `close()` synchronously from inside `super()` for a + // missing entry — before any field of this subclass exists. Dereferencing the + // image handler there threw out of `close()` before it had set `closed`, so the + // runner stayed "not ready, not closed" and every `fetch()` waited out the full + // `waitForReady()` timeout instead of answering 503. + it("closes synchronously when the worker entry is missing", async () => { + const broken = new VercelEnvRunner({ + name: "test-missing-entry", + workerEntry: "/non/existent/path.js", + }); + expect(broken.closed).toBe(true); + const res = await broken.fetch("http://localhost/"); + expect(res.status).toBe(503); + expect(res.headers.get("server")).toBe("Vercel"); + }); + it("fetches from runner", async () => { runner = new VercelEnvRunner({ name: "test-fetch", data: { entry: appEntry } }); await runner.waitForReady();