feat(api): add Vary: Accept-Encoding to token-authenticated /v1* responses (#6737) (#7217)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:39:58 -03:00
committed by GitHub
parent a2df195d5e
commit 57ac712772
4 changed files with 66 additions and 1 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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");

View File

@@ -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 });