Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d2e8e57
fix(server): use weak comparison for If-None-Match
Boyeep Jul 26, 2026
932bfc8
chore: retrigger CI
Boyeep Jul 26, 2026
9ce704a
fix(server): validate conditional static requests
james-elicx Jul 27, 2026
f11c2f9
fix(server): tighten entity-tag parsing
james-elicx Jul 27, 2026
1c0b44e
test(server): document entity-tag comma behavior
james-elicx Jul 27, 2026
1e2d403
fix(dev): weak-match public asset etags
james-elicx Jul 27, 2026
233099b
fix(dev): normalize public etag lookup paths
james-elicx Jul 27, 2026
4c95813
fix(dev): canonicalize conditional public assets
james-elicx Jul 27, 2026
510f8e0
fix(dev): honor Vite public file identity
james-elicx Jul 27, 2026
1b3c835
fix(dev): mirror Vite public filesystem semantics
james-elicx Jul 27, 2026
b395c75
fix(dev): refresh public filesystem capabilities
james-elicx Jul 27, 2026
b4dfe41
fix(dev): verify public path aliases
james-elicx Jul 27, 2026
ecc9fad
fix(dev): index verified public request aliases
james-elicx Jul 27, 2026
0db6645
fix(dev): compose verified public path aliases
james-elicx Jul 27, 2026
99babf4
fix(dev): resolve Unicode public path classes
james-elicx Jul 27, 2026
bdb00b8
fix(dev): use Unicode simple case matching
james-elicx Jul 27, 2026
a8df551
fix(dev): match Unicode expansion tokens
james-elicx Jul 27, 2026
29d95eb
Merge remote-tracking branch 'origin/main' into codex/pr2710-ready
james-elicx Jul 27, 2026
4175bc8
fix(dev): keep public path node private
james-elicx Jul 27, 2026
aee5021
Merge remote-tracking branch 'origin/main' into codex/pr2710-ready
james-elicx Jul 27, 2026
14ada51
Merge remote-tracking branch 'origin/main' into codex/pr2710-ready
james-elicx Jul 27, 2026
26f315d
fix(dev): resolve public-root symlink redirects
james-elicx Jul 27, 2026
d0d7dbb
refactor(dev): leave public etag handling to Vite
james-elicx Jul 27, 2026
068bc78
Merge remote-tracking branch 'origin/main' into codex/pr2710-clean
james-elicx Jul 27, 2026
03998b5
test(server): reject malformed etag list tails
james-elicx Jul 27, 2026
6b9e0e1
fix(pages): share conditional etag matching
james-elicx Jul 27, 2026
a699cec
refactor(pages): remove reversed etag matcher
james-elicx Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/vinext/src/server/http-conditional.ts
Original file line number Diff line number Diff line change
@@ -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] === "*") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle semantic narrowing vs. the old matchesIfNoneMatchHeader: the wildcard branch is now gated behind the current-ETag validation on lines 10-11, so If-None-Match: * returns false when etag fails parseEntityTag. RFC 9110 defines * as matching whenever a current representation exists, independent of the ETag's exact form, and the old code returned true for * unconditionally.

This is not a live bug — every caller passes a well-formed W/"..." or "..." ETag (verified all producers), so the wildcard path is never reached with an invalid etag. But if a future caller ever passes a non-quoted or malformed ETag, * would silently stop matching. A one-line comment noting that * intentionally requires a valid current ETag here (or hoisting the * check above the current-tag parse) would preempt the question.

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;
Comment thread
james-elicx marked this conversation as resolved.

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;
}
4 changes: 2 additions & 2 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ import { buildPagesCacheValue, type ISRCacheEntry } from "./isr-cache.js";
import type { PagesPreviewData } from "./pages-preview.js";
import {
buildPagesNextDataScript,
etagMatches,
generatePagesETag,
isPagesStreamingBot,
requestsNoCache,
type PagesGsspResponse,
type PagesI18nRenderContext,
type PagesNextDataExtras,
} from "./pages-page-response.js";
import { matchesIfNoneMatch } from "./http-conditional.js";
import {
createPagesGetInitialPropsRouter,
hasPagesGetInitialProps,
Expand Down Expand Up @@ -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, {
Expand Down
25 changes: 2 additions & 23 deletions packages/vinext/src/server/pages-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 4 additions & 12 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -633,7 +625,7 @@ async function tryServeStatic(
if (
responseStatus === 200 &&
typeof ifNoneMatch === "string" &&
matchesIfNoneMatchHeader(ifNoneMatch, entry.etag)
matchesIfNoneMatch(ifNoneMatch, entry.etag)
Comment thread
james-elicx marked this conversation as resolved.
) {
const notModifiedHeaders = variesByEncoding
? mergeVaryHeader({ ...entry.notModifiedHeaders, ...extraHeaders }, "Accept-Encoding")
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -757,7 +749,7 @@ async function tryServeStatic(
if (
responseStatus === 200 &&
typeof ifNoneMatch === "string" &&
matchesIfNoneMatchHeader(ifNoneMatch, etag)
matchesIfNoneMatch(ifNoneMatch, etag)
) {
res.writeHead(
304,
Expand Down
69 changes: 69 additions & 0 deletions tests/http-conditional.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 3 additions & 12 deletions tests/pages-page-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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 =
Expand Down
5 changes: 3 additions & 2 deletions tests/serve-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading