diff --git a/packages/vinext/src/server/http-conditional.ts b/packages/vinext/src/server/http-conditional.ts new file mode 100644 index 0000000000..97fa010acf --- /dev/null +++ b/packages/vinext/src/server/http-conditional.ts @@ -0,0 +1,72 @@ +/** + * Test an If-None-Match field value against a current entity tag. + * + * RFC 9110 requires weak comparison for If-None-Match: a weak and strong + * validator with the same opaque tag compare equal. + */ +export function matchesIfNoneMatch(ifNoneMatch: string | undefined, etag: string): boolean { + if (!ifNoneMatch) return false; + + const current = parseEntityTag(etag, 0); + if (!current || current.end !== etag.length) return false; + const currentOpaqueTag = etag.slice(current.opaqueStart, current.opaqueEnd); + + let index = skipOptionalWhitespace(ifNoneMatch, 0); + if (ifNoneMatch[index] === "*") { + return skipOptionalWhitespace(ifNoneMatch, index + 1) === ifNoneMatch.length; + } + + let matched = false; + let sawTag = false; + while (index < ifNoneMatch.length) { + // RFC 9110's list extension allows recipients to ignore empty members. + if (ifNoneMatch[index] === ",") { + index = skipOptionalWhitespace(ifNoneMatch, index + 1); + continue; + } + + const candidate = parseEntityTag(ifNoneMatch, index); + if (!candidate) return false; + sawTag = true; + matched ||= ifNoneMatch.slice(candidate.opaqueStart, candidate.opaqueEnd) === currentOpaqueTag; + + index = skipOptionalWhitespace(ifNoneMatch, candidate.end); + if (index === ifNoneMatch.length) break; + if (ifNoneMatch[index] !== ",") return false; + index = skipOptionalWhitespace(ifNoneMatch, index + 1); + } + + return sawTag && matched; +} + +type ParsedEntityTag = { + opaqueStart: number; + opaqueEnd: number; + end: number; +}; + +function parseEntityTag(value: string, start: number): ParsedEntityTag | null { + let index = start; + if (value.startsWith("W/", index)) index += 2; + if (value[index] !== '"') return null; + + const opaqueStart = ++index; + while (index < value.length && value[index] !== '"') { + const code = value.charCodeAt(index); + // etagc = %x21 / %x23-7E / obs-text. A backslash is an ordinary + // character here; entity tags do not use quoted-string escaping. + const isEtagCharacter = + code === 0x21 || (code >= 0x23 && code <= 0x7e) || (code >= 0x80 && code <= 0xff); + if (!isEtagCharacter) return null; + index++; + } + if (value[index] !== '"') return null; + + return { opaqueStart, opaqueEnd: index, end: index + 1 }; +} + +function skipOptionalWhitespace(value: string, start: number): number { + let index = start; + while (value[index] === " " || value[index] === "\t") index++; + return index; +} diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 10dca37dda..8dd5d3dc83 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -16,7 +16,6 @@ import { buildPagesCacheValue, type ISRCacheEntry } from "./isr-cache.js"; import type { PagesPreviewData } from "./pages-preview.js"; import { buildPagesNextDataScript, - etagMatches, generatePagesETag, isPagesStreamingBot, requestsNoCache, @@ -24,6 +23,7 @@ import { type PagesI18nRenderContext, type PagesNextDataExtras, } from "./pages-page-response.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; import { createPagesGetInitialPropsRouter, hasPagesGetInitialProps, @@ -973,7 +973,7 @@ function applyBotETagAndCheck( const etag = generatePagesETag(html); cachedResponse.headers.set("ETag", etag); const noCacheRequested = requestsNoCache(options.requestCacheControl); - if (!noCacheRequested && options.ifNoneMatch && etagMatches(etag, options.ifNoneMatch)) { + if (!noCacheRequested && options.ifNoneMatch && matchesIfNoneMatch(options.ifNoneMatch, etag)) { return { kind: "response", response: new Response(null, { diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index b8bfea62b4..efe0aaf7cf 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -33,6 +33,7 @@ import { } from "./pages-document-asset-props.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; import { NEXTJS_CACHE_HEADER } from "./headers.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; // --------------------------------------------------------------------------- // Bot / crawler detection for Pages Router edge-runtime SSR @@ -60,28 +61,6 @@ export function generatePagesETag(payload: string): string { return '"' + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"'; } -/** - * Mirrors Next.js `sendEtagResponse` semantics (weak/strong comparison). - * - * A weak ETag `W/"..."` matches both `W/"..."` and `"..."` in `If-None-Match`. - * A strong ETag `"..."` only matches the same strong token. - * `*` always matches. - */ -export function etagMatches(etag: string, ifNoneMatch: string): boolean { - if (ifNoneMatch === "*") return true; - // Normalise: strip the W/ prefix for comparison. Next.js's - // `sendEtagResponse` (packages/next/src/server/send-payload.ts) uses the - // `fresh` package, which treats a weak token in `If-None-Match` as matching - // the corresponding strong ETag and vice versa (RFC 7232 §2.3.2 weak - // comparison). We replicate that behaviour here. - const normalize = (t: string) => t.replace(/^W\//, ""); - const etagNorm = normalize(etag.trim()); - for (const token of ifNoneMatch.split(",")) { - if (normalize(token.trim()) === etagNorm) return true; - } - return false; -} - /** * Returns true when a request `Cache-Control` header asks to bypass the 304 * short-circuit. Mirrors the `fresh` package's check used by Next.js's @@ -768,7 +747,7 @@ export async function renderPagesPageResponse( const etag = generatePagesETag(fullHtml); responseHeaders.set("ETag", etag); const noCacheRequested = requestsNoCache(options.requestCacheControl); - if (!noCacheRequested && options.ifNoneMatch && etagMatches(etag, options.ifNoneMatch)) { + if (!noCacheRequested && options.ifNoneMatch && matchesIfNoneMatch(options.ifNoneMatch, etag)) { return new Response(null, { status: 304, headers: responseHeaders, diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 35a2bb2229..1bf28ff163 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -92,6 +92,7 @@ import { parseAcceptedEncodings, selectContentEncoding, } from "./accept-encoding.js"; +import { matchesIfNoneMatch } from "./http-conditional.js"; import type { NextI18nConfig } from "../config/next-config.js"; import { readTrustedRevalidationHostname } from "./revalidation-host.js"; @@ -410,15 +411,6 @@ function mergeVaryHeader( return merged; } -function matchesIfNoneMatchHeader(ifNoneMatch: string | undefined, etag: string): boolean { - if (!ifNoneMatch) return false; - if (ifNoneMatch === "*") return true; - return ifNoneMatch - .split(",") - .map((value) => value.trim()) - .some((value) => value === etag); -} - function installClientBuildManifestGlobals( clientDir: string, assetBase: string, @@ -633,7 +625,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, entry.etag) + matchesIfNoneMatch(ifNoneMatch, entry.etag) ) { const notModifiedHeaders = variesByEncoding ? mergeVaryHeader({ ...entry.notModifiedHeaders, ...extraHeaders }, "Accept-Encoding") @@ -721,7 +713,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, etag) + matchesIfNoneMatch(ifNoneMatch, etag) ) { const notModifiedHeaders = mergeVaryHeader(baseHeaders, "Accept-Encoding"); if (encoding !== "identity") notModifiedHeaders["Content-Encoding"] = encoding; @@ -757,7 +749,7 @@ async function tryServeStatic( if ( responseStatus === 200 && typeof ifNoneMatch === "string" && - matchesIfNoneMatchHeader(ifNoneMatch, etag) + matchesIfNoneMatch(ifNoneMatch, etag) ) { res.writeHead( 304, diff --git a/tests/http-conditional.test.ts b/tests/http-conditional.test.ts new file mode 100644 index 0000000000..e5e48ba2e6 --- /dev/null +++ b/tests/http-conditional.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { matchesIfNoneMatch } from "../packages/vinext/src/server/http-conditional.js"; + +describe("matchesIfNoneMatch", () => { + it("matches an identical entity tag", () => { + expect(matchesIfNoneMatch('"asset"', '"asset"')).toBe(true); + }); + + it("uses weak comparison in either direction", () => { + expect(matchesIfNoneMatch('"asset"', 'W/"asset"')).toBe(true); + expect(matchesIfNoneMatch('W/"asset"', '"asset"')).toBe(true); + }); + + it("matches a validator within a comma-separated field value", () => { + expect(matchesIfNoneMatch('"other", W/"asset", "last"', '"asset"')).toBe(true); + }); + + it("does not split commas inside an opaque tag", () => { + // RFC 9110 permits commas in opaque tags. This is intentionally stricter + // than Next.js 16.2.7's compiled `fresh` parser, which splits every comma. + expect(matchesIfNoneMatch('"other", W/"asset,part"', '"asset,part"')).toBe(true); + expect(matchesIfNoneMatch('"asset,part"', '"asset"')).toBe(false); + }); + + it("treats backslashes as opaque characters rather than escapes", () => { + expect(matchesIfNoneMatch('W/"asset\\part"', '"asset\\part"')).toBe(true); + }); + + it("matches a wildcard with optional whitespace", () => { + expect(matchesIfNoneMatch(" \t * \t ", 'W/"asset"')).toBe(true); + }); + + it("rejects a wildcard mixed into an entity-tag list", () => { + expect(matchesIfNoneMatch('*, "asset"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"other", *', '"asset"')).toBe(false); + }); + + it("does not match different opaque tags or case", () => { + expect(matchesIfNoneMatch('"other"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"ASSET"', '"asset"')).toBe(false); + }); + + it("does not treat a lowercase weak prefix as W/", () => { + expect(matchesIfNoneMatch('w/"asset"', '"asset"')).toBe(false); + }); + + it("rejects malformed entity tags", () => { + expect(matchesIfNoneMatch('asset, "match"', '"match"')).toBe(false); + expect(matchesIfNoneMatch('"match", garbage', '"match"')).toBe(false); + expect(matchesIfNoneMatch('"unterminated', '"unterminated"')).toBe(false); + expect(matchesIfNoneMatch('W/ "asset"', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"asset" suffix', '"asset"')).toBe(false); + expect(matchesIfNoneMatch('"asset\x7fpart"', '"asset\x7fpart"')).toBe(false); + }); + + it("ignores empty list members", () => { + expect(matchesIfNoneMatch(' , W/"asset", ', '"asset"')).toBe(true); + }); + + it("rejects an invalid current entity tag", () => { + expect(matchesIfNoneMatch('"asset"', "asset")).toBe(false); + expect(matchesIfNoneMatch('"asset"', 'W/ "asset"')).toBe(false); + }); + + it("rejects an absent or empty field value", () => { + expect(matchesIfNoneMatch(undefined, '"asset"')).toBe(false); + expect(matchesIfNoneMatch("", '"asset"')).toBe(false); + }); +}); diff --git a/tests/pages-page-response.test.ts b/tests/pages-page-response.test.ts index f3c739d7ab..37d47d78c4 100644 --- a/tests/pages-page-response.test.ts +++ b/tests/pages-page-response.test.ts @@ -4,7 +4,6 @@ import { renderPagesPageResponse, isPagesStreamingBot, generatePagesETag, - etagMatches, } from "../packages/vinext/src/server/pages-page-response.js"; import { resolvePagesPageData } from "../packages/vinext/src/server/pages-page-data.js"; @@ -1030,7 +1029,7 @@ describe("pages page response", () => { // If-None-Match / 304 handling and ISR cache-HIT ETag // --------------------------------------------------------------------------- - it("returns 304 when If-None-Match matches ETag on bot response (fresh-MISS path)", async () => { + it("returns 304 for a weak If-None-Match on a strong bot ETag", async () => { const common = createCommonOptions(); // First request: compute the ETag from a full bot render. @@ -1041,12 +1040,12 @@ describe("pages page response", () => { const etag = firstResponse.headers.get("etag"); expect(etag).toBeTruthy(); - // Second request: send If-None-Match matching the ETag. + // Second request: send the strong ETag as a weak validator. const common2 = createCommonOptions(); const notModifiedResponse = await renderPagesPageResponse({ ...common2.options, userAgent: "Googlebot", - ifNoneMatch: etag as string, + ifNoneMatch: `W/${etag}`, }); expect(notModifiedResponse.status).toBe(304); @@ -1095,14 +1094,6 @@ describe("pages page response", () => { expect(browserResponse.headers.get("etag")).toBeNull(); }); - it("ETag weak-comparison: W/ prefix is ignored when matching If-None-Match", async () => { - expect(etagMatches('"abc123"', 'W/"abc123"')).toBe(true); - expect(etagMatches('W/"abc123"', '"abc123"')).toBe(true); - expect(etagMatches('"abc123"', '"abc123"')).toBe(true); - expect(etagMatches('"abc123"', '"other"')).toBe(false); - expect(etagMatches('"abc123"', "*")).toBe(true); - }); - it("attaches ETag to ISR cache-HIT response for bot UAs", async () => { // Build a fake cached ISR entry and confirm that cache-HIT + bot UA yields ETag. const cachedHtml = diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 9f08e10c95..013a06b242 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -338,14 +338,15 @@ describe("tryServeStatic (with StaticFileCache)", () => { // ── 304 Not Modified (conditional requests) ──────────────────── - it("returns 304 when If-None-Match matches the ETag", async () => { + it("returns 304 when a strong If-None-Match matches a weak ETag", async () => { await writeFile(clientDir, "_next/static/cached-aaa111.js", "cached content"); const cache = await StaticFileCache.create(clientDir); const entry = cache.lookup("/_next/static/cached-aaa111.js"); const etag = entry!.etag; + expect(etag).toMatch(/^W\//); - const req = mockReq(undefined, { "if-none-match": etag }); + const req = mockReq(undefined, { "if-none-match": etag.slice(2) }); const { res, captured } = mockRes(); const served = await tryServeStatic(