From 57ac712772d5f5ea8e3ce81085fb2b2ac4d0254b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:39:58 -0300 Subject: [PATCH] feat(api): add Vary: Accept-Encoding to token-authenticated /v1* responses (#6737) (#7217) --- .../features/6737-vary-accept-encoding.md | 1 + docs/security/CORS.md | 6 ++- src/server/cors/origins.ts | 15 +++++++ tests/unit/cors/origins.test.ts | 45 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/6737-vary-accept-encoding.md diff --git a/changelog.d/features/6737-vary-accept-encoding.md b/changelog.d/features/6737-vary-accept-encoding.md new file mode 100644 index 0000000000..82ced9ae0a --- /dev/null +++ b/changelog.d/features/6737-vary-accept-encoding.md @@ -0,0 +1 @@ +- **feat(api):** add `Vary: Accept-Encoding` to token-authenticated `/v1*`/`/v1beta*` responses so downstream caches distinguish compressed vs uncompressed variants (RFC 9110 §12.5.5). (thanks @chirag127) diff --git a/docs/security/CORS.md b/docs/security/CORS.md index 557f7fa610..51f60d76c6 100644 --- a/docs/security/CORS.md +++ b/docs/security/CORS.md @@ -23,7 +23,11 @@ in this order: 1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's `Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin` - so caches stay correct. + so caches stay correct. The same `applyCorsHeaders()` chokepoint also appends + `Vary: Accept-Encoding` to every 2xx-with-body response on the token-authenticated + `/v1*`/`/v1beta*` surface (`relaxForTokenAuth`, RFC 9110 §12.5.5, issue #6737), so + downstream/shared caches can correctly distinguish compressed vs uncompressed + variants. 2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash stripped) and matched against the **merged allowlist**: - env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts index f6a748fb6a..d610297641 100644 --- a/src/server/cors/origins.ts +++ b/src/server/cors/origins.ts @@ -138,6 +138,11 @@ export function getCorsStatus(): CorsStatus { * is returned when there is no `Origin` header. This is NEVER paired with * `Access-Control-Allow-Credentials` (these routes are not cookie-authed), so * the echo/wildcard stays safe. + * + * On that same `relaxForTokenAuth` surface, also appends `Vary: Accept-Encoding` + * to every response with a body (RFC 9110 §12.5.5, issue #6737) — Next's built-in + * compression middleware only appends it conditionally, so shared caches can't + * otherwise reliably tell compressed vs uncompressed variants apart. */ export function applyCorsHeaders( response: Response, @@ -153,6 +158,16 @@ export function applyCorsHeaders( response.headers.set("Access-Control-Allow-Origin", allowed); response.headers.append("Vary", "Origin"); } + // RFC 9110 §12.5.5 (issue #6737): the token-authenticated /v1*/v1beta* surface + // (relaxForTokenAuth) negotiates content-encoding via Next's built-in + // compression middleware, which only appends `Vary: Accept-Encoding` + // conditionally (after its own content-type/threshold filter) — so shared + // caches (CDNs/proxies) can't reliably tell compressed vs uncompressed variants + // apart. Stamp it explicitly here, at the same chokepoint that already appends + // `Vary: Origin`, on every relaxed-CORS response with a body. + if (relaxForTokenAuth && response.status !== 204) { + response.headers.append("Vary", "Accept-Encoding"); + } response.headers.set("Access-Control-Allow-Methods", STANDARD_ALLOW_METHODS); response.headers.set("Access-Control-Allow-Headers", STANDARD_ALLOW_HEADERS); const requestedHeaders = request.headers.get("access-control-request-headers"); diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts index ff1502e02b..8fe8e78fd2 100644 --- a/tests/unit/cors/origins.test.ts +++ b/tests/unit/cors/origins.test.ts @@ -174,6 +174,51 @@ describe("cors/origins.applyCorsHeaders", () => { assert.match(res.headers.get("Vary") || "", /Origin/); }); + it("CLIENT_API: appends Vary: Accept-Encoding on a 2xx relaxForTokenAuth response (#6737)", () => { + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/models"); + applyCorsHeaders(res, req, true); + assert.match(res.headers.get("Vary") || "", /Accept-Encoding/); + }); + + it("CLIENT_API: combines with Vary: Origin into a single comma-joined header (#6737)", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com"; + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/models", { + headers: { Origin: "https://app.example.com" }, + }); + applyCorsHeaders(res, req, true); + const varyValues = res.headers.getSetCookie ? res.headers.get("Vary") : res.headers.get("Vary"); + assert.equal(varyValues, "Origin, Accept-Encoding"); + assert.equal([...res.headers.entries()].filter(([k]) => k.toLowerCase() === "vary").length, 1); + }); + + it("MANAGEMENT: does not append Vary: Accept-Encoding (relax off) (#6737)", () => { + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/keys"); + applyCorsHeaders(res, req); + assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/); + applyCorsHeaders(res, req, false); + assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/); + }); + + it("204 response: does not append Vary: Accept-Encoding even with relaxForTokenAuth (#6737)", () => { + const res = new NextResponse(null, { status: 204 }); + const req = new Request("https://server.example.com/api/v1/models", { + method: "OPTIONS", + }); + applyCorsHeaders(res, req, true); + assert.doesNotMatch(res.headers.get("Vary") || "", /Accept-Encoding/); + }); + + it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => { + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/models"); + applyCorsHeaders(res, req, true); + assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*"); + assert.match(res.headers.get("Vary") || "", /Accept-Encoding/); + }); + it("reflects requested headers from Access-Control-Request-Headers preflight", () => { process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com"; const res = NextResponse.json({ ok: true });