fix(middleware): align unsafe matcher validation#2599
Conversation
commit: |
|
Performance benchmarksCompared 3 improved · 1 regressed · 2 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(middleware): align matcher validation
Reviewed all 30 files. This is a solid, security-focused refactor that replaces two hand-rolled ReDoS heuristics with a deterministic structural analyzer plus a faithful port of path-to-regexp 6.3, and validates matchers at config time. Test coverage is excellent — the new shims.test.ts case compiles every fixture pattern with next/dist/compiled/path-to-regexp and asserts vinext produces the identical RegExp.source/flags, and the auth fixture exercises dev, Node prod, and Workers. I ran tests/shims.test.ts (1214 pass) and tests/middleware-matcher-auth.test.ts (27 pass), plus vp check on the changed source files (clean).
Verified the key parity claims against Next.js canary (.nextjs-ref):
_matchConditionValue/ queryhassemantics matchmatchHasinprepare-destination.ts: empty/omitted value is a presence check requiring a non-empty actual value, duplicate query keys select the last element (value.slice(-1)[0]), and a duplicate key is truthy even when its final value is empty.middleware-path-to-regexp.tstoken output and compiled regex match the vendored path-to-regexp, including thenormalizeMiddlewarePathTokensslash-prefix retry.
No blocking issues found. A few observations:
Observations (non-blocking)
-
Two ReDoS analyzers now coexist.
unsafeTokenReasonruns both the AST-basedanalyzeRegexSafety(which already reports"overlapping sequential repetition") and the separate hand-rolled string scannerhasOverlappingSequentialRepetitioninmiddleware-matcher-pattern.ts:117. Both leaning toward false-positives (fail-closed) is fine as belt-and-suspenders, but it's a maintenance burden — two code paths that must stay in agreement. Worth a follow-up to consolidate on the AST analyzer if it can cover the string-scanner cases, or a comment explaining why both are retained. -
Config
headers/redirects/rewritesstill use the old tokenizer.matchConfigPatterninconfig-matchers.ts:724+retains the legacy hand-rolled tokenizer +extractConstraint, so only middleware matchers gained the path-to-regexp-parity implementation. This is consistent with the PR's scope ("middleware matcher validation"), and those config paths do benefit from the stricter sharedanalyzeRegexSafetyviasafeRegExp. Flagging only so it's a conscious decision — config-route matching still diverges from Next.js's path-to-regexp semantics. -
isSafeRegexis now strictly stricter for all callers. SincesafeRegExppowers config header/redirect/rewrite compilation too, patterns that previously passed the naive nested-quantifier heuristic may now be rejected (returningnull). All call sites I checked handlenullgracefully (fall back to linear scan / non-match / warn), and the rejected patterns are genuinely backtracking-prone, so the direction is safe. Callers relying on previously-accepted pathological patterns would see a behavior change, but that's the intended hardening. -
matchesMiddlewarefail-open shift is correct. Non-array matcher configs and invalid matcher objects now returntrue(middleware runs for all paths) instead offalse. This is the security-correct direction (don't silently bypass an auth guard) and Next.js validates these at build time anyway, so runtime only sees them as defense-in-depth.
Performance note
The benchmark comment reports ~+1.8–3.0% growth across server/client/RSC bundle sizes, attributable to the new 899-line regex-safety.ts + 312-line pattern module + 281-line path-to-regexp. Given AGENTS.md's guidance to keep the common request path lightweight, consider whether regex-safety.ts (only needed at matcher-compile time, which is cached per pattern) can be lazily imported off the hot request path rather than statically pulled into the shared RSC/SSR/worker closures. Not blocking, but the size regression is measurable.
Overall: correct, well-tested, and faithful to Next.js. Approving direction; the items above are follow-ups rather than fixes required for merge.
|
Review posted to PR #2599. SummaryI reviewed all 30 files in PR #2599 ( Verdict: No blocking issues. The PR is correct, well-tested, and faithful to Next.js. What I verified:
Non-blocking observations I raised:
|
95d62e5 to
591dac3
Compare
591dac3 to
b02592b
Compare
|
/bigbonk review for issues |
|
UnknownError: "Overloaded" |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
|
UnknownError: "Overloaded" |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(middleware): align unsafe matcher validation
Re-reviewed after the base was updated with new commits since the last /bigbonk pass (b02592b perf(runtime): lazy-load config matchers, 0382586 fix(navigation): preserve rewrite prefetch reuse, and the 82ef911 merge from main). Focused on what changed relative to the earlier review: the module extraction + lazy-import performance work, the isExternalUrl consolidation, and the HAS_CONFIG_* / HAS_CLIENT_REWRITES build-define gating.
Verdict: No blocking issues. The security-relevant matcher/regex-safety logic is unchanged from the previously-approved state; the new commits are correctness-preserving refactors and perf gating.
What I verified this pass
-
Tests green:
tests/middleware-matcher-auth.test.ts(27 pass, dev/Node/Worker),tests/shims.test.ts(1225 pass, including the byte-identical path-to-regexp parity assertion againstnext/dist/compiled/path-to-regexp),tests/link.test.ts(123 pass),tests/app-rsc-response-finalizer.test.ts+tests/request-pipeline.test.ts(122 pass).vp checkclean on all new source modules. -
isExternalUrlconsolidation is semantics-preserving. The newutils/external-url.ts(/^[a-z][a-z0-9+.-]*:/i+startsWith("//")) is equivalent to both prior implementations it replaces: theconfig-matchers.tscopy (identical regex) and the shims'isAbsoluteOrProtocolRelativeUrl(/^[a-zA-Z][a-zA-Z\d+\-.]*?:/requires a letter start, scheme chars, then a mandatory:— the?non-greedy flag is inert because:is required). All call sites inapp-rsc-handler.ts,app-middleware.ts,app-server-action-execution.ts,navigation.ts, androuter.tsnow route through the shared helper. -
finalizeAppRscResponseasync conversion is fully awaited. It becameasync(lazy-importsconfig-headers.ts). Its only non-test caller (app-rsc-handler.ts:1421) returns the promise from anasynccallback wrapped inrunWithPrerenderWorkUnit, so it's awaited correctly. All test call sites were updated toawait. -
HAS_CONFIG_*/HAS_CLIENT_REWRITESgating fails safe. Every define is read asprocess.env.X !== "false", so the feature defaults to enabled in dev/test where the define is absent. The gates only short-circuit config header/redirect/rewrite work when the app genuinely has none configured, and each is paired with a&& options.X.lengthguard, so behavior is identical to unconditional execution (matchHeaders/matchRedirectover an empty list is a no-op). -
link.tsxlazy hybrid-owner import is null-safe. The module is imported only whenHAS_PAGES_ROUTER || HAS_CLIENT_REWRITES, and everyhybridRouteOwner!dereference is guarded by the matching individual flag, so the non-null assertions can't fault. The newlinkPrefetchNavigationEpochcancellation correctly no-ops a pointer-intent prefetch that lost the module-load race to a click navigation in the same turn. -
extractMiddlewareMatcherConfigValuebuild validation is sound. Returning the raw static value (rejecting onlyUNSUPPORTED_STATIC_VALUE) letsvalidateMiddlewareMatcherPatternsthrow on malformed matcher objects at build time instead of silently dropping them. Arrays containing any non-static element (spread/computed) collapse toUNSUPPORTED_STATIC_VALUEas a whole, so no partial/misleading validation occurs.
Observations (non-blocking, carried forward)
-
Two ReDoS analyzers still coexist —
analyzeRegexSafety(AST) and the hand-rolledhasOverlappingSequentialRepetitionstring scanner inmiddleware-matcher-pattern.ts:117. Both fail-closed, which is fine as belt-and-suspenders, but it remains a maintenance burden worth consolidating in a follow-up. -
Config
headers/redirects/rewritesstill use the legacy tokenizer (matchConfigPatterninconfig-matchers.ts); only middleware matchers gained path-to-regexp parity. Consistent with the PR scope, and those paths do get the stricter sharedanalyzeRegexSafetyviasafeRegExp. -
Bundle-size delta improved since last pass. The perf refactor (lazy-loading
config-matchers.tsoff the hot RSC/SSR path) turned the earlier ~+1.8–3.0% regression into net wins on RSC entry closure (-3.9%), server bundle (-2.3%), and client entry (-2.3%); only the client bundle grew (+2.9%). This directly addresses the perf note from the prior review.
Overall: correct, well-tested, faithful to Next.js, and the new commits meaningfully improve the perf profile. The items above are follow-ups, not merge blockers.
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
|
Review posted to PR #2599. SummaryRe-reviewed PR #2599 after the base picked up new commits since the last Verdict: No blocking issues. The security-relevant matcher/regex-safety logic is unchanged from the previously-approved state; the new commits are correctness-preserving refactors plus perf gating. Verified this pass:
Non-blocking follow-ups carried forward: two ReDoS analyzers coexisting, config header/redirect/rewrite paths still on the legacy tokenizer, and a note that the perf refactor turned the earlier ~+1.8–3.0% bundle regression into net wins (RSC -3.9%, server -2.3%, client entry -2.3%). |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(middleware): align unsafe matcher validation
Verdict: One blocking issue — a reproducible concurrency crash in the built Node production server that intermittently turns a middleware auth guard into a 500 instead of enforcing it. The matcher/regex-safety logic and Next.js parity are otherwise correct.
Blocking: matchesMiddleware lazy import races module init under concurrency
tests/middleware-matcher-auth.test.ts — the test added by this PR — is flaky. Running the built Node production server case in isolation it fails on ~2 of every 3 runs:
[vinext] Server error: TypeError: Cannot read properties of undefined (reading 'get')
The failing path varies between runs (/archive/2024-07-10, /codes/ABCD, /bracket-shorthand/a1z9, …) because assertAuthGuard fires all 18 protected requests concurrently via Promise.all. Affected requests return 500 instead of 403, i.e. the middleware auth guard silently fails open under load right after server start.
Runs in isolation (each a fresh builder.buildApp() + prod server):
Run 1: 1 failed | 26 passed
Run 2: 27 passed
Run 3: 1 failed | 26 passed
Run 4: 1 failed (/codes/ABCD -> 500)
Run 5: 1 failed (/archive/2024-07-10 -> 500)
Root cause — circular-dependency initialization order exposed by the perf lazy-import refactor.
b02592b perf(runtime): lazy-load config matchers changed executeMiddleware in server/middleware-runtime.ts:302 to:
const { matchesMiddleware } = await import("./middleware-matcher.js");In the production build this puts middleware-matcher in its own chunk that statically imports the entry (import { N as e, W as t } from "../../index.js"), while index.js imports the chunk only dynamically. That is a cycle: index.js --(dynamic)--> matcher chunk --(static)--> index.js.
The chunk's module-scope cache is emitted as a hoisted var:
// middleware-matcher-*.js — decompiled
var C = {…}, w = Symbol("unsafe matcher pattern"), T = new Map; // T = _mwPatternCache, at byte ~7044/7991
function A(e,n){ … let i = T.get(r); … } // matchPattern -> T.get throws
export { E as matchesMiddleware };Under the first concurrent burst of await import(...), matchPattern (A) executes before the top-level T = new Map assignment runs, so T is still the hoisted undefined and T.get(r) throws. The throw is not caught by executeMiddleware's middleware try/catch (it happens in matchesMiddleware, before middlewareFn runs), so it propagates to the top-level handler and becomes a generic 500. Stack:
at A (middleware-matcher chunk) // matchPattern: T.get(r)
at D (middleware-matcher chunk) // matchMatcherPattern
at E (middleware-matcher chunk) // matchesMiddleware
at ma (index.js) // executeMiddleware, right after `await import(...)`
Confirmed fix. Reverting just this one call site to a static import (and dropping the dynamic import) makes the same isolated test pass 3/3:
// server/middleware-runtime.ts
import { type MatcherConfig, matchesMiddleware } from "./middleware-matcher.js";
// …and delete `const { matchesMiddleware } = await import("./middleware-matcher.js");`matchesMiddleware is on the middleware hot path and its transitive cost (matcher-pattern + regex-safety) is only pulled in when an app actually has middleware, so a static import here does not meaningfully expand the common no-middleware request path — but it does eliminate the entry⇄chunk cycle that causes the race. If lazy loading must be kept for bundle-size reasons, the alternative is to break the cycle so the matcher chunk does not statically import the entry (e.g. move matchesMiddleware + its cache into a leaf module with no back-edge to index.js), and to initialize _mwPatternCache in a way that cannot be observed as undefined (a hoisted function-scoped accessor rather than a top-level var read).
Note: the two prior /bigbonk passes reported this file as "27 pass". It passes single-shot often enough to look green; the failure only shows reliably when the case is run in isolation and/or repeatedly, so CI's serial full-suite run can easily miss it. This is a real dev/prod-parity defect (the dev server case is unaffected because there is no chunked cycle), so it should not merge as-is.
Non-blocking observations (carried forward + verified)
- Query
has/missingparity is correct. I re-verifiedmatchSingleCondition'squerybranch and_matchConditionValueagainst Next.jsmatchHasinprepare-destination.ts: omitted/empty value is a presence check requiring a non-empty actual value; duplicate keys are truthy even when the final element is empty; the value regex testsvalue.slice(-1)[0].URLSearchParams.getAllyields[""]for?foo=and["",""]for?foo=&foo=, so vinext'squeryValues.length > 1shortcut matches Node+Next.js semantics exactly. isExternalUrlconsolidation intoutils/external-url.tsis semantics-preserving vs both prior copies.- Two ReDoS analyzers still coexist (
analyzeRegexSafetyAST + the hand-rolledhasOverlappingSequentialRepetitionstring scanner). Both fail-closed; worth consolidating in a follow-up. - Config
headers/redirects/rewritesstill use the legacy tokenizer; only middleware matchers gained path-to-regexp parity. Consistent with PR scope.
tests/shims.test.ts (1225 pass, including the byte-identical path-to-regexp parity assertion) is green. The blocker above is the only thing preventing approval.
|
Review posted to PR #2599 requesting changes. SummaryI found one blocking issue that both prior Blocking: concurrency crash in the built Node production server. The auth-guard test added by this PR is flaky — it fails ~2 of 3 runs when the Root cause: the I confirmed the fix: reverting that single call site to a static I verified the rest of the PR is sound — the query All debug instrumentation and experimental edits were reverted; the working tree is clean. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(middleware): align unsafe matcher validation
Verdict: No blocking issues. The concurrency crash flagged in the previous /bigbonk pass is fixed.
Blocker from last pass is resolved
The prior review's blocker — the built Node production server auth test intermittently returning 500 instead of 403 because executeMiddleware lazy-import()ed middleware-matcher.js, creating an entry⇄chunk cycle whose top-level _mwPatternCache = new Map() could be observed as undefined under the first concurrent burst — has been addressed by commit 31f9049 fix(middleware): avoid matcher chunk initialization race. It reverts the single dynamic import back to a static one:
// server/middleware-runtime.ts
import { matchesMiddleware, type MatcherConfig } from "./middleware-matcher.js";
// (removed: const { matchesMiddleware } = await import("./middleware-matcher.js");)This is exactly the fix the prior review recommended: it breaks the cycle so the matcher cache can no longer be read before initialization, and since matchesMiddleware's transitive cost is only pulled in when an app actually has middleware, it does not meaningfully bloat the common no-middleware path.
Verified: I ran tests/middleware-matcher-auth.test.ts in isolation 4/4 times (each a fresh builder.buildApp() + prod server) — all 27 pass every run, including the concurrent 18-request assertAuthGuard burst that previously failed ~2/3 of the time. The race is gone.
What else I verified this pass
vp checkclean — format, lint, type-checks across all 1136 files.- Tests green:
tests/shims.test.ts(incl. the byte-identical path-to-regexp parity assertion vsnext/dist/compiled/path-to-regexp),tests/link.test.ts,tests/request-pipeline.test.ts,tests/app-rsc-response-finalizer.test.ts(1470 total), plustests/build-optimization.test.ts(173). isExternalUrlconsolidation is semantics-preserving. I empirically confirmedutils/external-url.ts(/^[a-z][a-z0-9+.-]*:/i+//prefix) is byte-for-byte equivalent to theisAbsoluteOrProtocolRelativeUrlit replaces across a 32-case matrix (schemes, protocol-relative,javascript:,data:, invalid leaders, etc.). The*?non-greedy in the old regex is inert because:is required.config-headers.tsextraction is complete. Every caller ofapplyConfigHeadersToResponse/applyConfigHeadersToHeaderRecord(app-rsc-handler, pages-request-pipeline, finalizer, and the test) now imports from the new module; the deleted request-pipeline copies left no dangling references.finalizeAppRscResponseasync conversion is fully awaited at its sole non-test caller (app-rsc-handler.ts:1421), returned from the asyncrunWithPrerenderWorkUnitcallback.HAS_CONFIG_*/HAS_CLIENT_REWRITESgating fails safe — each define is read!== "false"(defaults enabled in dev/test), paired with a length guard, so gated behavior is identical to unconditional execution.link.tsxlazy hybrid-owner import is null-safe — the module is loaded only underHAS_PAGES_ROUTER || HAS_CLIENT_REWRITES, and every!deref is guarded by the matching individual flag. The newlinkPrefetchNavigationEpochcorrectly no-ops a pointer-intent prefetch that lost the module-load race to a click nav in the same turn.- Query
has/missingparity with Next.jsmatchHas(prepare-destination.ts) is unchanged and correct: omitted/empty value is a presence check requiring a non-empty actual value; duplicate keys are truthy even when the final element is empty; the value regex tests the last element. - Build-time matcher validation is sound —
extractMiddlewareMatcherConfigValuereturns the raw static value sovalidateMiddlewareMatcherPatternsthrows on malformed matcher objects at build time; non-static values collapse to unsupported and skip validation cleanly. - ReDoS fail-closed coverage in
middleware-matcher-redos-child.tsis thorough (near-miss backtracking, overlapping sequential repetition, ambiguous alternatives, bounded-sequence quadratics, and a linear-time large-alternation assertion).
Non-blocking observations (carried forward)
- Two ReDoS analyzers still coexist —
analyzeRegexSafety(AST) and the hand-rolledhasOverlappingSequentialRepetitionstring scanner inmiddleware-matcher-pattern.ts:117. Both fail-closed; worth consolidating in a follow-up. - Config
headers/redirects/rewritesstill use the legacy tokenizer (matchConfigPatterninconfig-matchers.ts); only middleware matchers gained path-to-regexp parity. Consistent with PR scope, and those paths still get the stricter sharedanalyzeRegexSafetyviasafeRegExp. - Perf refactor is a net win on bundle size (RSC entry closure -3.9%, server -2.3%, client entry -2.3%; only the client bundle grew +2.9%), addressing the earlier size-regression note.
The blocker is fixed and I could not reproduce any regression. This is ready to merge.
|
Review posted to PR #2599. SummaryI re-reviewed PR #2599 focusing on the one blocking issue from the previous Verdict: No blocking issues. The blocker is fixed. Key findings:
Non-blocking follow-ups carried forward: two coexisting ReDoS analyzers, config header/redirect/rewrite paths still on the legacy tokenizer, and a note that the perf refactor is now a net bundle-size win. |
Summary
Testing
vp checkvp run vinext#build