diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 05d8bcbf2..dff2fa855 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -14,6 +14,7 @@ const ROUTE_OWNED_CLIENT_SHIMS = new Set([ "dynamic-preload-chunks", "form", "image", + "internal/hybrid-client-route-owner", "layout-segment-context", "legacy-image", "link", diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index c64dc7539..db187927f 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -245,6 +245,17 @@ function extractStringFromConstInitializer(initializer: Expression | null): stri export function extractMiddlewareMatcherConfig( filePath: string, ): StaticMiddlewareMatcher | undefined { + const value = extractMiddlewareMatcherConfigValue(filePath); + return isStaticMiddlewareMatcher(value) ? value : undefined; +} + +/** + * Extract the statically analyzable `config.matcher` value without first + * narrowing it to vinext's runtime matcher type. Build validation needs the + * raw value so malformed matcher objects are rejected instead of disappearing + * as though no matcher had been configured. + */ +export function extractMiddlewareMatcherConfigValue(filePath: string): unknown { let code: string; try { code = fs.readFileSync(filePath, "utf8"); @@ -261,7 +272,7 @@ export function extractMiddlewareMatcherConfig( if (!matcherExpression) return undefined; const value = extractStaticJsonValue(matcherExpression); - return isStaticMiddlewareMatcher(value) ? value : undefined; + return value === UNSUPPORTED_STATIC_VALUE ? undefined : value; } function objectPropertyValue(object: ObjectExpression, key: string): Expression | null { diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 7208f96c5..89c906d52 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -20,7 +20,17 @@ import { VINEXT_PRERENDER_SECRET_HEADER, } from "../utils/protocol-headers.js"; import { buildRequestHeadersFromMiddlewareResponse } from "../utils/middleware-request-headers.js"; -import { parseCookieHeader } from "../utils/parse-cookie.js"; +import { analyzeRegexSafety } from "../utils/regex-safety.js"; +import { requestContextFromRequest, type RequestContext } from "./request-context.js"; +import { isExternalUrl } from "../utils/external-url.js"; + +export { + normalizeHost, + parseCookies, + requestContextFromRequest, + type RequestContext, +} from "./request-context.js"; +export { isExternalUrl } from "../utils/external-url.js"; /** * Cache for compiled regex patterns in matchConfigPattern. @@ -265,115 +275,16 @@ function stripHopByHopRequestHeaders(headers: Headers): void { /** * Detect regex patterns vulnerable to catastrophic backtracking (ReDoS). * - * Uses a lightweight heuristic: scans the pattern string for nested quantifiers - * (a quantifier applied to a group that itself contains a quantifier). This - * catches the most common pathological patterns like `(a+)+`, `(.*)*`, - * `([^/]+)+`, `(a|a+)+` without needing a full regex parser. + * Uses the same deterministic structural analysis as middleware matcher + * validation. Nested bounded repetition is accepted only when its repeated + * language has fixed width and unambiguous branches; a fixed outer count can + * otherwise still cause polynomially catastrophic backtracking on long near + * misses. * * Returns true if the pattern appears safe, false if it's potentially dangerous. */ -export function isSafeRegex(pattern: string): boolean { - // Track parenthesis nesting depth and whether we've seen a quantifier - // at each depth level. - const quantifierAtDepth: boolean[] = []; - let depth = 0; - let i = 0; - - while (i < pattern.length) { - const ch = pattern[i]; - - // Skip escaped characters - if (ch === "\\") { - i += 2; - continue; - } - - // Skip character classes [...] — quantifiers inside them are literal - if (ch === "[") { - i++; - while (i < pattern.length && pattern[i] !== "]") { - if (pattern[i] === "\\") i++; // skip escaped char in class - i++; - } - i++; // skip closing ] - continue; - } - - if (ch === "(") { - depth++; - // Initialize: no quantifier seen yet at this new depth - if (quantifierAtDepth.length <= depth) { - quantifierAtDepth.push(false); - } else { - quantifierAtDepth[depth] = false; - } - i++; - continue; - } - - if (ch === ")") { - const hadQuantifier = depth > 0 && quantifierAtDepth[depth]; - if (depth > 0) depth--; - - // Look ahead for a quantifier on this group: +, *, {n,m} - // Note: '?' after ')' means "zero or one" which does NOT cause catastrophic - // backtracking — it only allows 2 paths (match/skip), not exponential. - // Only unbounded repetition (+, *, {n,}) on a group with inner quantifiers is dangerous. - const next = pattern[i + 1]; - if (next === "+" || next === "*" || next === "{") { - if (hadQuantifier) { - // Nested quantifier detected: quantifier on a group that contains a quantifier - return false; - } - // Mark the enclosing depth as having a quantifier - if (depth >= 0 && depth < quantifierAtDepth.length) { - quantifierAtDepth[depth] = true; - } - } - i++; - continue; - } - - // Detect quantifiers: +, *, ?, {n,m} - // '?' is a quantifier (optional) unless it follows another quantifier (+, *, ?, }) - // in which case it's a non-greedy modifier. - if (ch === "+" || ch === "*") { - if (depth > 0) { - quantifierAtDepth[depth] = true; - } - i++; - continue; - } - - if (ch === "?") { - // '?' after +, *, ?, or } is a non-greedy modifier, not a quantifier - const prev = i > 0 ? pattern[i - 1] : ""; - if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") { - if (depth > 0) { - quantifierAtDepth[depth] = true; - } - } - i++; - continue; - } - - if (ch === "{") { - // Check if this is a quantifier {n}, {n,}, {n,m} - let j = i + 1; - while (j < pattern.length && /[\d,]/.test(pattern[j])) j++; - if (j < pattern.length && pattern[j] === "}" && j > i + 1) { - if (depth > 0) { - quantifierAtDepth[depth] = true; - } - i = j + 1; - continue; - } - } - - i++; - } - - return true; +export function isSafeRegex(pattern: string, flags?: string): boolean { + return analyzeRegexSafety(pattern, { ignoreCase: flags?.includes("i") }) === null; } /** @@ -383,11 +294,11 @@ export function isSafeRegex(pattern: string): boolean { * Logs a warning when a pattern is rejected so developers can fix their config. */ export function safeRegExp(pattern: string, flags?: string): RegExp | null { - if (!isSafeRegex(pattern)) { + if (!isSafeRegex(pattern, flags)) { console.warn( `[vinext] Rejecting potentially unsafe regex pattern (ReDoS risk): ${pattern}\n` + - ` Patterns with nested quantifiers (e.g. (a+)+) can cause catastrophic backtracking.\n` + - ` Simplify the pattern to avoid nested repetition.`, + ` Nested or ambiguous repetition can cause catastrophic backtracking.\n` + + ` Simplify the pattern to make repeated matches fixed and unambiguous.`, ); return null; } @@ -471,17 +382,6 @@ export function escapeHeaderSource(source: string): string { return result; } -/** - * Request context needed for evaluating has/missing conditions. - * Callers extract the relevant parts from the incoming Request. - */ -export type RequestContext = { - readonly headers: Headers; - readonly cookies: Record; - readonly query: URLSearchParams; - readonly host: string; -}; - /** * basePath gating state passed alongside the pathname to every matcher. * @@ -525,44 +425,6 @@ function shouldEvaluateRule(ruleBasePath: false | undefined, state: BasePathMatc return ruleBasePath === false ? !state.hadBasePath : state.hadBasePath; } -/** - * Parse a Cookie header string into a key-value record. - */ -export function parseCookies(cookieHeader: string | null): Record { - return parseCookieHeader(cookieHeader); -} - -/** - * Build a RequestContext from a Web Request object. - * - * `cookies` and `query` are lazy memoized getters: they are consumed only by - * `has`/`missing` condition evaluation (`checkHasConditions` / - * `matchesRuleConditions`), and most apps configure no such conditions. The - * cookie split and `searchParams` access are therefore deferred until first - * read and computed at most once. Mirrors `headersContextFromRequest` in - * `shims/headers.ts`. - */ -export function requestContextFromRequest(request: Request): RequestContext { - const url = new URL(request.url); - let cookies: Record | undefined; - let query: URLSearchParams | undefined; - return { - headers: request.headers, - get cookies() { - return (cookies ??= parseCookies(request.headers.get("cookie"))); - }, - get query() { - return (query ??= url.searchParams); - }, - host: normalizeHost(request.headers.get("host"), url.hostname), - }; -} - -export function normalizeHost(hostHeader: string | null, fallbackHostname: string): string { - const host = hostHeader ?? fallbackHostname; - return host.split(":", 1)[0].toLowerCase(); -} - /** * Unpack `x-middleware-request-*` headers from the collected middleware * response headers into the actual request, and strip all `x-middleware-*` @@ -619,7 +481,9 @@ function _matchConditionValue( actualValue: string, expectedValue: string | undefined, ): Record | null { - if (expectedValue === undefined) return _emptyParams(); + // Next.js treats an omitted or empty condition value as a presence check. + // Its matchHas helper also requires the actual value to be non-empty. + if (!expectedValue) return actualValue ? _emptyParams() : null; const re = _cachedConditionRegex(expectedValue); if (re) { @@ -658,9 +522,15 @@ function matchSingleCondition( return _matchConditionValue(cookieValue, condition.value); } case "query": { - const queryValue = ctx.query.get(condition.key); - if (queryValue === null) return null; - return _matchConditionValue(queryValue, condition.value); + const queryValues = ctx.query.getAll(condition.key); + if (queryValues.length === 0) return null; + // Next.js checks presence against the parsed value before selecting the + // last array element for a value regex. A duplicate key is represented + // as a truthy array even when its final value is empty. + if (!condition.value && queryValues.length > 1) return _emptyParams(); + // Node parses duplicate query keys as an array and Next.js matchHas + // explicitly tests its final value (`value.slice(-1)[0]`). + return _matchConditionValue(queryValues[queryValues.length - 1], condition.value); } case "host": { if (condition.value !== undefined) return _matchConditionValue(ctx.host, condition.value); @@ -1321,10 +1191,6 @@ export function sanitizeDestination(dest: string): string { * Detects any URL scheme (http:, https:, data:, javascript:, blob:, etc.) * per RFC 3986, plus protocol-relative URLs (//). */ -export function isExternalUrl(url: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//"); -} - /** * Merge the original request's query params into a config-redirect * destination, preserving them on the resulting `Location`. diff --git a/packages/vinext/src/config/request-context.ts b/packages/vinext/src/config/request-context.ts new file mode 100644 index 000000000..42cc38897 --- /dev/null +++ b/packages/vinext/src/config/request-context.ts @@ -0,0 +1,35 @@ +import { parseCookieHeader } from "../utils/parse-cookie.js"; + +/** Request context needed for evaluating has/missing conditions. */ +export type RequestContext = { + readonly headers: Headers; + readonly cookies: Record; + readonly query: URLSearchParams; + readonly host: string; +}; + +export function parseCookies(cookieHeader: string | null): Record { + return parseCookieHeader(cookieHeader); +} + +export function normalizeHost(hostHeader: string | null, fallbackHostname: string): string { + const host = hostHeader ?? fallbackHostname; + return host.split(":", 1)[0].toLowerCase(); +} + +/** Build a lazily parsed request context from a Web Request. */ +export function requestContextFromRequest(request: Request): RequestContext { + const url = new URL(request.url); + let cookies: Record | undefined; + let query: URLSearchParams | undefined; + return { + headers: request.headers, + get cookies() { + return (cookies ??= parseCookies(request.headers.get("cookie"))); + }, + get query() { + return (query ??= url.searchParams); + }, + host: normalizeHost(request.headers.get("host"), url.hostname), + }; +} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 4f83b2c83..f763c2da6 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -68,7 +68,11 @@ import { collectRouteClassificationManifest, type RouteClassificationManifest, } from "./build/route-classification-manifest.js"; -import { extractMiddlewareMatcherConfig, hasExportedName } from "./build/report.js"; +import { + extractMiddlewareMatcherConfig, + extractMiddlewareMatcherConfigValue, + hasExportedName, +} from "./build/report.js"; import { planRouteClassificationInjection } from "./build/route-classification-injector.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { hasBasePath, stripBasePath } from "./utils/base-path.js"; @@ -86,6 +90,7 @@ import { import { mergeServerExternalPackages } from "./config/server-external-packages.js"; import { findMiddlewareFile, isProxyFile, runMiddleware } from "./server/middleware.js"; +import { validateMiddlewareMatcherPatterns } from "./server/middleware-matcher-pattern.js"; import { encodeUrlParserIgnoredCharacters, isNextDataPathname, @@ -2034,6 +2039,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? path.join(root, "src") : root; middlewarePath = findMiddlewareFile(root, fileMatcher, middlewareConventionDir); + if (middlewarePath) { + const staticMatcher = extractMiddlewareMatcherConfigValue(middlewarePath); + if (staticMatcher !== undefined) { + validateMiddlewareMatcherPatterns(staticMatcher); + } + } const instrumentationClientInjects = nextConfig.instrumentationClientInject.map((spec) => spec.startsWith("./") || spec.startsWith("../") ? path.resolve(root, spec) : spec, ); @@ -2069,6 +2080,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Let shared client shims compile out Pages-only behavior in pure App // Router builds while retaining it for Pages and hybrid applications. defines["process.env.__VINEXT_HAS_PAGES_ROUTER"] = JSON.stringify(String(hasPagesDir)); + defines["process.env.__VINEXT_HAS_CLIENT_REWRITES"] = JSON.stringify( + String( + nextConfig.rewrites.beforeFiles.length > 0 || + nextConfig.rewrites.afterFiles.length > 0 || + nextConfig.rewrites.fallback.length > 0, + ), + ); + defines["process.env.__VINEXT_HAS_CONFIG_HEADERS"] = JSON.stringify( + String(nextConfig.headers.length > 0), + ); + defines["process.env.__VINEXT_HAS_CONFIG_REDIRECTS"] = JSON.stringify( + String(nextConfig.redirects.length > 0), + ); + defines["process.env.__VINEXT_HAS_CONFIG_REWRITES"] = JSON.stringify( + String( + nextConfig.rewrites.beforeFiles.length > 0 || + nextConfig.rewrites.afterFiles.length > 0 || + nextConfig.rewrites.fallback.length > 0, + ), + ); // Expose experimental.staleTimes to client-side code so full prefetches // and committed dynamic navigations use Next.js' distinct freshness // windows. Values are in seconds; matches Next.js' define-env plumbing. diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3cfe10386..c3c8bc1c2 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -35,12 +35,14 @@ import { getPrefetchCache, hasPrefetchCacheEntryForNavigation, invalidatePrefetchCache, + preloadHybridClientRouteOwner, seedPrefetchResponseSnapshot, decodeRedirectError, isRedirectError, pushHistoryStateWithoutNotify, replaceClientParamsWithoutNotify, replaceHistoryStateWithoutNotify, + resolveLoadedHybridClientRewriteHref, resolvePrefetchCacheEntryMountedSlotsHeader, restoreRscResponse, saveScrollPosition, @@ -68,7 +70,6 @@ import { consumeAppRouterScrollIntent, type AppRouterScrollIntent, } from "vinext/shims/app-router-scroll-state"; -import { resolveHybridClientRewriteHref } from "vinext/shims/internal/hybrid-client-route-owner"; import { installWindowNext, setWindowNextInternalSourcePage } from "../client/window-next.js"; import { chunksToReadableStream, @@ -187,6 +188,8 @@ import { } from "./navigation-planner.js"; import { hasServerActions, loadServerActionClient } from "virtual:vinext-app-capabilities"; +const HAS_CLIENT_REWRITES = process.env.__VINEXT_HAS_CLIENT_REWRITES !== "false"; + type SearchParamInput = ConstructorParameters[0]; type DevErrorOverlayModule = typeof import("../client/dev-error-overlay.js"); @@ -1479,6 +1482,7 @@ async function main(): Promise { if (hasServerActions) registerServerActionCallback(); installAppNavigationFailureListeners(); + if (HAS_CLIENT_REWRITES) await preloadHybridClientRouteOwner(); let devErrorOverlay: DevErrorOverlayModule | null = null; if (import.meta.env.DEV) { @@ -1772,8 +1776,8 @@ function bootstrapHydration( }); const rscUrl = await createRscRequestUrl(url.pathname + url.search, requestHeaders); const rewrittenNavigationHref = - navigationKind === "navigate" - ? resolveHybridClientRewriteHref(currentHref, __basePath) + navigationKind === "navigate" && HAS_CLIENT_REWRITES + ? resolveLoadedHybridClientRewriteHref(currentHref, __basePath) : null; const additionalPrefetchRscUrls = rewrittenNavigationHref && rewrittenNavigationHref !== currentHref diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index 49de68f03..f6b61a9a5 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -1,5 +1,5 @@ import type { NextI18nConfig } from "../config/next-config.js"; -import { isExternalUrl, proxyExternalRequest } from "../config/config-matchers.js"; +import { isExternalUrl } from "../utils/external-url.js"; import { applyMiddlewareRequestHeaders, setHeadersContext } from "vinext/shims/headers"; import { setNavigationContext } from "vinext/shims/navigation"; import { FLIGHT_HEADERS, VINEXT_MW_CTX_HEADER } from "./headers.js"; @@ -150,6 +150,7 @@ export async function proxyExternalMiddlewareRewrite( setHeadersContext(null); setNavigationContext(null); + const { proxyExternalRequest } = await import("../config/config-matchers.js"); const proxyResponse = await proxyExternalRequest(proxyRequest, rewriteUrl); const headers = new Headers(proxyResponse.headers); processMiddlewareHeaders(headers); diff --git a/packages/vinext/src/server/app-post-middleware-context.ts b/packages/vinext/src/server/app-post-middleware-context.ts index afd4a7c75..b493679c4 100644 --- a/packages/vinext/src/server/app-post-middleware-context.ts +++ b/packages/vinext/src/server/app-post-middleware-context.ts @@ -1,6 +1,9 @@ import { getHeadersContext } from "vinext/shims/headers"; -import { normalizeHost, requestContextFromRequest } from "../config/config-matchers.js"; -import type { RequestContext } from "../config/config-matchers.js"; +import { + normalizeHost, + requestContextFromRequest, + type RequestContext, +} from "../config/request-context.js"; /** * Build a request context from the live ALS HeadersContext, which reflects diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 6f3749595..2fd25f283 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -4,16 +4,9 @@ import type { NextRedirect, NextRewrite, } from "../config/next-config.js"; -import { - isExternalUrl, - matchRedirect, - matchRewrite, - preserveRedirectDestinationQuery, - proxyExternalRequest, - requestContextFromRequest, - sanitizeDestination, - type BasePathMatchState, -} from "../config/config-matchers.js"; +import type { BasePathMatchState } from "../config/config-matchers.js"; +import { requestContextFromRequest } from "../config/request-context.js"; +import { isExternalUrl } from "../utils/external-url.js"; import { headersContextFromRequest } from "vinext/shims/headers"; import { ACTION_REVALIDATED_HEADER, @@ -77,7 +70,6 @@ import { cloneRequestWithHeaders, cloneRequestWithUrl, filterInternalHeaders, - applyConfigHeadersToResponse, normalizeTrailingSlash, resolvePublicFileRoute, } from "./request-pipeline.js"; @@ -100,6 +92,9 @@ import { type AppPageParams = Record; type RequestContext = ReturnType; const STATIC_METADATA_CONFIG_HEADER_OVERRIDES = new Set(["cache-control"]); +const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; +const HAS_CONFIG_REDIRECTS = process.env.__VINEXT_HAS_CONFIG_REDIRECTS !== "false"; +const HAS_CONFIG_REWRITES = process.env.__VINEXT_HAS_CONFIG_REWRITES !== "false"; type StaticParamsMap = AppPrerenderStaticParamsMap; type RootParamNamesMap = AppPrerenderRootParamNamesMap; @@ -393,10 +388,11 @@ async function applyRewrite( }, cleanPathname: string, ): Promise { - if (!options.rewrites.length) return null; + if (!HAS_CONFIG_REWRITES || !options.rewrites.length) return null; const sourcePathname = options.paramsPathname ?? cleanPathname; - const rewritten = matchRewrite( + const configMatchers = await import("../config/config-matchers.js"); + const rewritten = configMatchers.matchRewrite( sourcePathname, options.rewrites, options.requestContext, @@ -407,7 +403,7 @@ async function applyRewrite( if (isExternalUrl(rewritten)) { options.clearRequestContext(); - return proxyExternalRequest(options.request, rewritten); + return configMatchers.proxyExternalRequest(options.request, rewritten); } return rewritten; @@ -430,7 +426,7 @@ function pathnameForResolvedUrl(resolvedUrl: string): string { return resolvedUrl.split("#", 1)[0].split("?", 1)[0]; } -function applyConfigHeadersToMiddlewareRedirect( +async function applyConfigHeadersToMiddlewareRedirect( response: Response, options: { basePathState: BasePathMatchState; @@ -438,13 +434,14 @@ function applyConfigHeadersToMiddlewareRedirect( pathname: string; requestContext: RequestContext; }, -): Response { +): Promise { // Non-redirect middleware responses still pass through finalization, where // config headers are applied once. Redirects skip finalization to avoid // mutating immutable redirect headers, so they need the earlier header layer here. if (response.status < 300 || response.status >= 400) return response; - if (!options.configHeaders.length) return response; + if (!HAS_CONFIG_HEADERS || !options.configHeaders.length) return response; + const { applyConfigHeadersToResponse } = await import("./config-headers.js"); const headers = new Headers(); applyConfigHeadersToResponse(headers, { configHeaders: options.configHeaders, @@ -594,14 +591,20 @@ async function handleAppRscRequest( // `/rewrite`, unlike Next.js. Dynamic captures must likewise retain their // original percent-encoding for Location substitution. const redirectPathname = matchPathname(requestCleanPathname); - const redirect = matchRedirect( - redirectPathname, - options.configRedirects, - preMiddlewareRequestContext, - basePathState, - ); - if (redirect) { - const destination = sanitizeDestination( + const configMatchers = + HAS_CONFIG_REDIRECTS && options.configRedirects.length + ? await import("../config/config-matchers.js") + : null; + const redirect = configMatchers + ? configMatchers.matchRedirect( + redirectPathname, + options.configRedirects, + preMiddlewareRequestContext, + basePathState, + ) + : null; + if (configMatchers && redirect) { + const destination = configMatchers.sanitizeDestination( redirectDestinationWithBasePath(redirect.destination, options.basePath, hadBasePath), ); // For RSC navigations `createRscRedirectLocation` recomputes the @@ -611,7 +614,7 @@ async function handleAppRscRequest( const location = isRscRequest && request.headers.get(RSC_HEADER) === "1" ? await createRscRedirectLocation(destination, request) - : preserveRedirectDestinationQuery(destination, url.search); + : configMatchers.preserveRedirectDestinationQuery(destination, url.search); return new Response(null, { status: redirect.permanent ? 308 : 307, headers: { Location: location }, @@ -800,7 +803,8 @@ async function handleAppRscRequest( if (filesystemRouteEligible && options.handleMetadataRouteRequest) { const metadataRouteResponse = await options.handleMetadataRouteRequest(cleanPathname); - if (metadataRouteResponse) { + if (metadataRouteResponse && HAS_CONFIG_HEADERS && options.configHeaders.length) { + const { applyConfigHeadersToResponse } = await import("./config-headers.js"); applyConfigHeadersToResponse(metadataRouteResponse.headers, { basePathState, configHeaders: options.configHeaders, @@ -810,6 +814,8 @@ async function handleAppRscRequest( ), requestContext: preMiddlewareRequestContext, }); + } + if (metadataRouteResponse) { return applyMiddlewareContextToResponse(metadataRouteResponse, middlewareContext); } } diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index 726ef7812..43021c29b 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -1,8 +1,7 @@ import type { NextHeader, NextI18nConfig } from "../config/next-config.js"; -import type { RequestContext } from "../config/config-matchers.js"; +import type { RequestContext } from "../config/request-context.js"; import { VINEXT_STATIC_FILE_HEADER } from "./headers.js"; import { applyCdnResponseHeaders } from "./cache-control.js"; -import { applyConfigHeadersToResponse } from "./request-pipeline.js"; import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; @@ -27,6 +26,8 @@ type FinalizeAppRscResponseOptions = { requestContext: RequestContext; }; +const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; + /** * Apply App Router response finalization that must happen outside individual * route dispatchers. @@ -39,11 +40,11 @@ type FinalizeAppRscResponseOptions = { * headers that throw on mutation, and Next.js does not apply config headers * to redirects regardless. */ -export function finalizeAppRscResponse( +export async function finalizeAppRscResponse( response: Response, request: Request, options: FinalizeAppRscResponseOptions, -): Response { +): Promise { // 3xx responses: Response.redirect() headers are immutable (throws on write), // and Next.js deliberately excludes config headers from redirect responses. if (response.status >= 300 && response.status < 400) { @@ -71,7 +72,7 @@ export function finalizeAppRscResponse( applyCdnResponseHeaders(response.headers, { cacheControl: "" }); } - if (!options.configHeaders.length) { + if (!HAS_CONFIG_HEADERS || !options.configHeaders.length) { return response; } @@ -93,6 +94,7 @@ export function finalizeAppRscResponse( ? normalizeDefaultLocalePathname(pathname, options.i18nConfig, { hostname: url.hostname }) : pathname; + const { applyConfigHeadersToResponse } = await import("./config-headers.js"); applyConfigHeadersToResponse(response.headers, { configHeaders: options.configHeaders, pathname: matchPathname, diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 136aee4cf..a5ce3794f 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -22,7 +22,7 @@ import { runWithRootParamsScope, runWithRootParamsUsage, } from "vinext/shims/root-params"; -import { isExternalUrl } from "../config/config-matchers.js"; +import { isExternalUrl } from "../utils/external-url.js"; import { splitPathSegments } from "../routing/utils.js"; import { addBasePathToPathname, hasBasePath, stripBasePath } from "../utils/base-path.js"; import { diff --git a/packages/vinext/src/server/config-headers.ts b/packages/vinext/src/server/config-headers.ts new file mode 100644 index 000000000..3e93903e9 --- /dev/null +++ b/packages/vinext/src/server/config-headers.ts @@ -0,0 +1,100 @@ +import type { NextHeader } from "../config/next-config.js"; +import { + matchHeaders, + type BasePathMatchState, + type RequestContext, +} from "../config/config-matchers.js"; +import type { HeaderRecord } from "./request-pipeline.js"; + +type ApplyConfigHeadersOptions = { + configHeaders: NextHeader[]; + pathname: string; + requestContext: RequestContext; + /** + * basePath gating state. When omitted, every rule is treated as a default + * (basePath: true) rule for backward compatibility — callers that need to + * support `basePath: false` headers must pass this in. + */ + basePathState?: BasePathMatchState; + /** Existing framework-generated headers that matching config rules may replace. */ + overwriteExisting?: ReadonlySet; +}; + +function findHeaderRecordKey(headers: HeaderRecord, lowerName: string): string | undefined { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lowerName) return key; + } + return undefined; +} + +function appendHeaderRecord(headers: HeaderRecord, lowerName: string, value: string): void { + const key = findHeaderRecordKey(headers, lowerName) ?? lowerName; + const existing = headers[key]; + if (existing === undefined) { + headers[key] = value; + return; + } + if (Array.isArray(existing)) { + existing.push(value); + return; + } + headers[key] = [existing, value]; +} + +function appendVaryHeaderRecord(headers: HeaderRecord, value: string): void { + const key = findHeaderRecordKey(headers, "vary") ?? "vary"; + const existing = headers[key]; + if (existing === undefined) { + headers[key] = value; + return; + } + if (Array.isArray(existing)) { + existing.push(value); + return; + } + headers[key] = existing + ", " + value; +} + +/** Apply matched next.config.js headers to a Web Headers object. */ +export function applyConfigHeadersToResponse( + responseHeaders: Headers, + options: ApplyConfigHeadersOptions, +): void { + const matched = matchHeaders( + options.pathname, + options.configHeaders, + options.requestContext, + options.basePathState, + ); + for (const header of matched) { + const lowerName = header.key.toLowerCase(); + if (lowerName === "vary" || lowerName === "set-cookie") { + responseHeaders.append(header.key, header.value); + } else if (options.overwriteExisting?.has(lowerName) || !responseHeaders.has(lowerName)) { + responseHeaders.set(header.key, header.value); + } + } +} + +/** Apply matched next.config.js headers to an early response header record. */ +export function applyConfigHeadersToHeaderRecord( + headers: HeaderRecord, + options: ApplyConfigHeadersOptions, +): void { + const matched = matchHeaders( + options.pathname, + options.configHeaders, + options.requestContext, + options.basePathState, + ); + for (const header of matched) { + const lowerName = header.key.toLowerCase(); + if (lowerName === "set-cookie") { + appendHeaderRecord(headers, lowerName, header.value); + } else if (lowerName === "vary") { + appendVaryHeaderRecord(headers, header.value); + } else if (findHeaderRecordKey(headers, lowerName) === undefined) { + headers[lowerName] = header.value; + } + } +} diff --git a/packages/vinext/src/server/middleware-matcher-pattern.ts b/packages/vinext/src/server/middleware-matcher-pattern.ts new file mode 100644 index 000000000..a74f40325 --- /dev/null +++ b/packages/vinext/src/server/middleware-matcher-pattern.ts @@ -0,0 +1,312 @@ +import type { HasCondition } from "../config/next-config.js"; +import { analyzeRegexSafety, regexAtomsMayOverlap } from "../utils/regex-safety.js"; +import { + middlewarePathTokensToRegExp, + normalizeMiddlewarePathTokens, + parseMiddlewarePath, + type MiddlewarePathKey, + type MiddlewarePathToken, +} from "./middleware-path-to-regexp.js"; + +export type CompiledMiddlewareMatcherPattern = + | { regexp: RegExp; error?: never } + | { regexp?: never; error: string; kind: "invalid" | "unsafe" }; + +export type MiddlewareMatcherObject = { + source: string; + locale?: false; + has?: HasCondition[]; + missing?: HasCondition[]; +}; + +const MATCHER_OBJECT_KEYS = new Set(["source", "locale", "has", "missing"]); +const CONDITION_TYPES_WITH_KEY = new Set(["header", "query", "cookie"]); + +function invalidConditionReason(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return "has and missing entries must be objects"; + } + + const condition = value as Record; + const type = condition.type; + if (typeof type === "string" && CONDITION_TYPES_WITH_KEY.has(type)) { + for (const key of Object.keys(condition)) { + if (key !== "type" && key !== "key" && key !== "value") { + return `condition contains unsupported field "${key}"`; + } + } + if (typeof condition.key !== "string") { + return `condition type "${type}" requires a string key`; + } + if (condition.value !== undefined && typeof condition.value !== "string") { + return `condition type "${type}" requires value to be a string`; + } + return null; + } + + if (type === "host") { + for (const key of Object.keys(condition)) { + if (key !== "type" && key !== "value") { + return `host condition contains unsupported field "${key}"`; + } + } + return typeof condition.value === "string" ? null : "host condition requires a string value"; + } + + return "condition type must be header, query, cookie, or host"; +} + +function invalidConditionsReason(value: unknown, field: "has" | "missing"): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return `${field} must be an array`; + for (const condition of value) { + const reason = invalidConditionReason(condition); + if (reason) return `${field} ${reason}`; + } + return null; +} + +function matcherObjectSource(value: unknown): { source?: string; error?: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { error: "matcher entries must be strings or objects" }; + } + + const matcher = value as Record; + for (const key of Object.keys(matcher)) { + if (!MATCHER_OBJECT_KEYS.has(key)) { + return { error: `matcher object contains unsupported field "${key}"` }; + } + } + if (typeof matcher.source !== "string") { + return { error: "matcher object requires a string source" }; + } + if (matcher.locale !== undefined && matcher.locale !== false) { + return { error: "matcher object locale must be false when provided" }; + } + const invalidHas = invalidConditionsReason(matcher.has, "has"); + if (invalidHas) return { error: invalidHas }; + const invalidMissing = invalidConditionsReason(matcher.missing, "missing"); + if (invalidMissing) return { error: invalidMissing }; + return { source: matcher.source }; +} + +export function isValidMiddlewareMatcherObjectConfig( + value: unknown, +): value is MiddlewareMatcherObject { + return matcherObjectSource(value).error === undefined; +} + +function patternMatches(pattern: string, value: string): boolean { + try { + return new RegExp(`^(?:${pattern})$`).test(value); + } catch { + return false; + } +} + +function atomsOverlap(left: string, right: string): boolean { + return regexAtomsMayOverlap(left, right, true); +} + +function groupCanMatchEmpty(group: string): boolean { + if (/^\?(?:[=!]|<[=!])/.test(group)) return true; + const body = group.startsWith("?:") ? group.slice(2) : group; + return patternMatches(body, ""); +} + +function hasOverlappingSequentialRepetition(pattern: string): boolean { + const repeatedAtDepth: string[][] = [[]]; + const groupStarts: number[] = []; + let depth = 0; + + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + if (character === "(") { + groupStarts.push(index); + depth++; + repeatedAtDepth[depth] = []; + if (pattern[index + 1] === "?") { + // Skip the non-capturing/lookaround marker. Its punctuation is not a + // consuming atom, and the existing validator handles quantified + // lookaround groups conservatively. + index++; + if (pattern[index + 1] === "<" && /[=!]/.test(pattern[index + 2] ?? "")) index += 2; + else if (/[=:!]/.test(pattern[index + 1] ?? "")) index++; + } + continue; + } + if (character === ")") { + const groupStart = groupStarts.pop(); + repeatedAtDepth[depth] = []; + depth = Math.max(0, depth - 1); + if (groupStart !== undefined) { + const modifier = pattern[index + 1]; + const optionalModifier = + modifier === "*" || + modifier === "?" || + (modifier === "{" && /^\{0(?:,\d*)?\}/.test(pattern.slice(index + 1))); + if (!optionalModifier && !groupCanMatchEmpty(pattern.slice(groupStart + 1, index))) { + repeatedAtDepth[depth] = []; + } + } + continue; + } + if (character === "|") { + repeatedAtDepth[depth] = []; + continue; + } + if (character === "^" || character === "$") continue; + if (character === "*" || character === "+" || character === "?" || character === "{") { + continue; + } + + let atom = character; + if (character === "\\") { + if (index + 1 >= pattern.length) return true; + atom += pattern[++index]; + } else if (character === "[") { + let classEnd = index + 1; + if (pattern[classEnd] === "^") classEnd++; + while (classEnd < pattern.length && pattern[classEnd] !== "]") { + if (pattern[classEnd] === "\\") classEnd++; + classEnd++; + } + if (classEnd >= pattern.length) return true; + atom = pattern.slice(index, classEnd + 1); + index = classEnd; + } else if (character === ".") { + atom = "."; + } + + const quantifierStart = index + 1; + const quantifier = pattern[quantifierStart]; + let unbounded = quantifier === "*" || quantifier === "+"; + let canBeEmpty = quantifier === "*"; + let quantifierEnd = quantifierStart; + if (quantifier === "{") { + const match = /^\{(\d+),\}/.exec(pattern.slice(quantifierStart)); + if (match) { + unbounded = true; + canBeEmpty = Number(match[1]) === 0; + quantifierEnd += match[0].length - 1; + } + } + + if (!unbounded) { + // An optional atom does not separate the repetitions on either side; + // every other non-repeated atom is a mandatory separator. + if (quantifier !== "?") repeatedAtDepth[depth] = []; + if (quantifier === "?") index = quantifierStart; + continue; + } + + if (repeatedAtDepth[depth].some((previous) => atomsOverlap(previous, atom))) { + return true; + } + repeatedAtDepth[depth] = canBeEmpty ? [...repeatedAtDepth[depth], atom] : [atom]; + index = quantifierEnd; + } + + return false; +} + +function unsafeTokenReason(token: MiddlewarePathKey): string | null { + const regexSafetyIssue = analyzeRegexSafety(token.pattern, { ignoreCase: true }); + if (regexSafetyIssue) { + if (regexSafetyIssue === "analysis budget exceeded") { + return `parameter "${token.name}" exceeds the regex analysis budget`; + } + return `parameter "${token.name}" contains ${regexSafetyIssue}`; + } + if (hasOverlappingSequentialRepetition(token.pattern)) { + return `parameter "${token.name}" contains overlapping sequential repetition`; + } + + if (token.modifier !== "*" && token.modifier !== "+") return null; + + // Repeating parameters are joined by the token prefix/suffix. If their own + // constraint can also consume a slash (or the empty string), the same input + // has many equivalent partitions. Patterns such as `/:path(.*)*/end` then + // backtrack exponentially on a near miss. Ordinary repeats use a constraint + // that cannot cross the path delimiter, so each segment has one owner. + if (patternMatches(token.pattern, "") || patternMatches(token.pattern, "/")) { + return `repeated parameter "${token.name}" may match an empty value or path delimiter`; + } + + return null; +} + +function validateTokens(tokens: MiddlewarePathToken[]): string | null { + for (const token of tokens) { + if (typeof token === "string") continue; + const reason = unsafeTokenReason(token); + if (reason) return reason; + } + return null; +} + +export function compileMiddlewareMatcherPattern(source: string): CompiledMiddlewareMatcherPattern { + if (!source.startsWith("/")) { + return { kind: "invalid", error: "source must start with /" }; + } + if (source.length > 4096) { + return { kind: "invalid", error: "source exceeds max built length of 4096" }; + } + + let tokens: MiddlewarePathToken[]; + try { + tokens = parseMiddlewarePath(source); + } catch (error) { + return { + kind: "invalid", + error: error instanceof Error ? error.message : "matcher could not be parsed", + }; + } + + const unsafeReason = validateTokens(tokens); + if (unsafeReason) return { kind: "unsafe", error: unsafeReason }; + + try { + return { regexp: middlewarePathTokensToRegExp(tokens) }; + } catch { + // Match Next.js 16.2.7's path-to-regexp 6.3 normalization: repeating + // tokens without a prefix/suffix receive a slash prefix and are retried. + const normalizedTokens = normalizeMiddlewarePathTokens(tokens); + const normalizedUnsafeReason = validateTokens(normalizedTokens); + if (normalizedUnsafeReason) return { kind: "unsafe", error: normalizedUnsafeReason }; + try { + return { regexp: middlewarePathTokensToRegExp(normalizedTokens) }; + } catch (error) { + return { + kind: "invalid", + error: error instanceof Error ? error.message : "matcher could not be compiled", + }; + } + } +} + +export function validateMiddlewareMatcherPatterns(value: unknown): void { + const sources: string[] = []; + if (typeof value === "string") { + sources.push(value); + } else if (Array.isArray(value)) { + for (const matcher of value) { + if (typeof matcher === "string") sources.push(matcher); + else { + const result = matcherObjectSource(matcher); + if (result.error) throw new Error(`Invalid middleware matcher config: ${result.error}.`); + sources.push(result.source!); + } + } + } else { + throw new Error( + "Invalid middleware matcher config: matcher must be a string or an array of strings or objects.", + ); + } + + for (const source of sources) { + const result = compileMiddlewareMatcherPattern(source); + if (result.regexp) continue; + throw new Error(`Invalid middleware matcher "${source}": ${result.error}.`); + } +} diff --git a/packages/vinext/src/server/middleware-matcher.ts b/packages/vinext/src/server/middleware-matcher.ts index 069980057..d32674593 100644 --- a/packages/vinext/src/server/middleware-matcher.ts +++ b/packages/vinext/src/server/middleware-matcher.ts @@ -1,19 +1,15 @@ import { checkHasConditions, - isSafeRegex, requestContextFromRequest, - safeRegExp, type RequestContext, } from "../config/config-matchers.js"; -import type { HasCondition, NextI18nConfig } from "../config/next-config.js"; +import type { NextI18nConfig } from "../config/next-config.js"; import { removeTrailingSlash } from "../utils/base-path.js"; - -type MiddlewareMatcherObject = { - source: string; - locale?: false; - has?: HasCondition[]; - missing?: HasCondition[]; -}; +import { + compileMiddlewareMatcherPattern, + isValidMiddlewareMatcherObjectConfig, + type MiddlewareMatcherObject, +} from "./middleware-matcher-pattern.js"; export type MatcherConfig = string | Array; @@ -25,7 +21,7 @@ const EMPTY_MIDDLEWARE_REQUEST_CONTEXT: RequestContext = { }; const UNSAFE_MATCHER_PATTERN = Symbol("unsafe matcher pattern"); -type CompiledMatcherPattern = RegExp | null | typeof UNSAFE_MATCHER_PATTERN; +type CompiledMatcherPattern = RegExp | typeof UNSAFE_MATCHER_PATTERN; const _mwPatternCache = new Map(); @@ -43,7 +39,7 @@ export function matchesMiddleware( return matchMatcherPattern(pathname, matcher, i18nConfig); } if (!Array.isArray(matcher)) { - return false; + return true; } const requestContext = request @@ -58,40 +54,21 @@ export function matchesMiddleware( continue; } - if (isValidMiddlewareMatcherObject(m)) { - if (!matchObjectMatcher(pathname, m, i18nConfig)) { - continue; - } - - if (!checkHasConditions(m.has, m.missing, requestContext)) { - continue; - } - + if (!isValidMiddlewareMatcherObjectConfig(m)) { return true; } - } - - return false; -} - -function isValidMiddlewareMatcherObject(value: unknown): value is MiddlewareMatcherObject { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - - if (!("source" in value) || typeof value.source !== "string") return false; + if (!matchObjectMatcher(pathname, m, i18nConfig)) { + continue; + } - for (const key of Object.keys(value)) { - if (key !== "source" && key !== "locale" && key !== "has" && key !== "missing") { - return false; + if (!checkHasConditions(m.has, m.missing, requestContext)) { + continue; } - } - if ("locale" in value && value.locale !== undefined && value.locale !== false) return false; - if ("has" in value && value.has !== undefined && !Array.isArray(value.has)) return false; - if ("missing" in value && value.missing !== undefined && !Array.isArray(value.missing)) { - return false; + return true; } - return true; + return false; } function matchMatcherPattern( @@ -136,75 +113,19 @@ export function matchPattern(pathname: string, pattern: string): boolean { _mwPatternCache.set(normalizedPattern, cached); } if (cached === UNSAFE_MATCHER_PATTERN) return true; - if (cached === null) return removeTrailingSlash(pathname) === normalizedPattern; if (cached.test(pathname)) return true; return pathname.endsWith("/") && cached.test(removeTrailingSlash(pathname)); } -function extractConstraint(str: string, re: RegExp): string | null { - if (str[re.lastIndex] !== "(") return null; - const start = re.lastIndex + 1; - let depth = 1; - let i = start; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - i++; - } - if (depth !== 0) return null; - re.lastIndex = i; - return str.slice(start, i - 1); -} - function compileMatcherPattern(pattern: string): CompiledMatcherPattern { - const hasConstraints = /:[\w-]+[*+]?\(/.test(pattern); - - if (!hasConstraints && (pattern.includes("(") || pattern.includes("\\"))) { - return compileMatcherRegExp("^" + pattern + "$", pattern); - } - - let regexStr = ""; - const tokenRe = /\/:([\w-]+)\*|\/:([\w-]+)\+|:([\w-]+)|[.]|[^/:.]+|./g; - let tok: RegExpExecArray | null; - while ((tok = tokenRe.exec(pattern)) !== null) { - if (tok[1] !== undefined) { - const constraint = hasConstraints ? extractConstraint(pattern, tokenRe) : null; - regexStr += constraint !== null ? `(?:/(${constraint}))?` : "(?:/.*)?"; - } else if (tok[2] !== undefined) { - const constraint = hasConstraints ? extractConstraint(pattern, tokenRe) : null; - regexStr += constraint !== null ? `(?:/(${constraint}))` : "(?:/.+)"; - } else if (tok[3] !== undefined) { - const constraint = hasConstraints ? extractConstraint(pattern, tokenRe) : null; - const isOptional = pattern[tokenRe.lastIndex] === "?"; - if (isOptional) tokenRe.lastIndex += 1; - - const group = constraint !== null ? `(${constraint})` : "([^/]+)"; - - if (isOptional && regexStr.endsWith("/")) { - regexStr = regexStr.slice(0, -1) + `(?:/${group})?`; - } else if (isOptional) { - regexStr += `${group}?`; - } else { - regexStr += group; - } - } else if (tok[0] === ".") { - regexStr += "\\."; - } else { - regexStr += tok[0]; - } - } - - return compileMatcherRegExp("^" + regexStr + "$", pattern); -} - -function compileMatcherRegExp(regexPattern: string, sourcePattern: string): CompiledMatcherPattern { - if (!isSafeRegex(regexPattern)) { - console.warn( - `[vinext] Rejecting potentially unsafe middleware matcher (ReDoS risk): ${sourcePattern}\n` + - ` Middleware will run for all paths to avoid bypassing request guards.\n` + - ` Simplify the matcher to avoid nested repetition.`, - ); - return UNSAFE_MATCHER_PATTERN; - } - return safeRegExp(regexPattern); + const result = compileMiddlewareMatcherPattern(pattern); + if (result.regexp) return result.regexp; + + const problem = result.kind === "unsafe" ? "potentially unsafe" : "invalid"; + console.warn( + `[vinext] Rejecting ${problem} middleware matcher: ${pattern}\n` + + ` ${result.error}.\n` + + ` Middleware will run for all paths to avoid bypassing request guards.`, + ); + return UNSAFE_MATCHER_PATTERN; } diff --git a/packages/vinext/src/server/middleware-path-to-regexp.ts b/packages/vinext/src/server/middleware-path-to-regexp.ts new file mode 100644 index 000000000..2f5e0133d --- /dev/null +++ b/packages/vinext/src/server/middleware-path-to-regexp.ts @@ -0,0 +1,281 @@ +// Derived from path-to-regexp 6.3.0: +// https://github.com/pillarjs/path-to-regexp/tree/v6.3.0 +// +// The MIT License (MIT) +// Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +export type MiddlewarePathToken = string | MiddlewarePathKey; + +export type MiddlewarePathKey = { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +}; + +type LexerToken = { + type: "MODIFIER" | "ESCAPED_CHAR" | "OPEN" | "CLOSE" | "NAME" | "PATTERN" | "CHAR" | "END"; + index: number; + value: string; +}; + +function lexer(value: string): LexerToken[] { + const tokens: LexerToken[] = []; + let index = 0; + + while (index < value.length) { + const character = value[index]; + if (character === "*" || character === "+" || character === "?") { + tokens.push({ type: "MODIFIER", index, value: value[index++] }); + continue; + } + if (character === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: index++, value: value[index++] }); + continue; + } + if (character === "{") { + tokens.push({ type: "OPEN", index, value: value[index++] }); + continue; + } + if (character === "}") { + tokens.push({ type: "CLOSE", index, value: value[index++] }); + continue; + } + if (character === ":") { + let name = ""; + let nameEnd = index + 1; + while (nameEnd < value.length) { + const code = value.charCodeAt(nameEnd); + if ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 95 + ) { + name += value[nameEnd++]; + continue; + } + break; + } + if (!name) throw new TypeError(`Missing parameter name at ${index}`); + tokens.push({ type: "NAME", index, value: name }); + index = nameEnd; + continue; + } + if (character === "(") { + let depth = 1; + let pattern = ""; + let patternEnd = index + 1; + if (value[patternEnd] === "?") { + throw new TypeError(`Pattern cannot start with "?" at ${patternEnd}`); + } + while (patternEnd < value.length) { + if (value[patternEnd] === "\\") { + pattern += value[patternEnd++] + value[patternEnd++]; + continue; + } + if (value[patternEnd] === ")") { + depth--; + if (depth === 0) { + patternEnd++; + break; + } + } else if (value[patternEnd] === "(") { + depth++; + if (value[patternEnd + 1] !== "?") { + throw new TypeError(`Capturing groups are not allowed at ${patternEnd}`); + } + } + pattern += value[patternEnd++]; + } + if (depth) throw new TypeError(`Unbalanced pattern at ${index}`); + if (!pattern) throw new TypeError(`Missing pattern at ${index}`); + tokens.push({ type: "PATTERN", index, value: pattern }); + index = patternEnd; + continue; + } + tokens.push({ type: "CHAR", index, value: value[index++] }); + } + + tokens.push({ type: "END", index, value: "" }); + return tokens; +} + +export function parseMiddlewarePath(value: string): MiddlewarePathToken[] { + const tokens = lexer(value); + const result: MiddlewarePathToken[] = []; + const prefixes = "./"; + const delimiter = "/#?"; + let key = 0; + let index = 0; + let path = ""; + + const tryConsume = (type: LexerToken["type"]): string | undefined => { + if (index < tokens.length && tokens[index].type === type) { + return tokens[index++].value; + } + return undefined; + }; + + const mustConsume = (type: LexerToken["type"]): string => { + const consumed = tryConsume(type); + if (consumed !== undefined) return consumed; + const next = tokens[index]; + throw new TypeError(`Unexpected ${next.type} at ${next.index}, expected ${type}`); + }; + + const consumeText = (): string => { + let text = ""; + let consumed: string | undefined; + while ((consumed = tryConsume("CHAR") ?? tryConsume("ESCAPED_CHAR")) !== undefined) { + text += consumed; + } + return text; + }; + + const containsDelimiter = (text: string): boolean => { + for (const character of delimiter) { + if (text.includes(character)) return true; + } + return false; + }; + + const defaultPattern = (prefix: string): string => { + const previous = result[result.length - 1]; + const previousText = prefix || (typeof previous === "string" ? previous : ""); + if (previous && !previousText) { + const name = typeof previous === "string" ? previous : previous.name; + throw new TypeError(`Must have text between two parameters, missing text after "${name}"`); + } + if (!previousText || containsDelimiter(previousText)) return "[^\\/#\\?]+?"; + return `(?:(?!${escapeRegex(previousText)})[^\\/#\\?])+?`; + }; + + while (index < tokens.length) { + const character = tryConsume("CHAR"); + const name = tryConsume("NAME"); + const pattern = tryConsume("PATTERN"); + if (name !== undefined || pattern !== undefined) { + let prefix = character ?? ""; + if (!prefixes.includes(prefix)) { + path += prefix; + prefix = ""; + } + if (path) { + result.push(path); + path = ""; + } + result.push({ + name: name ?? key++, + prefix, + suffix: "", + pattern: pattern ?? defaultPattern(prefix), + modifier: tryConsume("MODIFIER") ?? "", + }); + continue; + } + + const text = character ?? tryConsume("ESCAPED_CHAR"); + if (text !== undefined) { + path += text; + continue; + } + if (path) { + result.push(path); + path = ""; + } + if (tryConsume("OPEN") !== undefined) { + const prefix = consumeText(); + const groupName = tryConsume("NAME") ?? ""; + const groupPattern = tryConsume("PATTERN") ?? ""; + const suffix = consumeText(); + mustConsume("CLOSE"); + result.push({ + name: groupName || (groupPattern ? key++ : ""), + pattern: groupName && !groupPattern ? defaultPattern(prefix) : groupPattern, + prefix, + suffix, + modifier: tryConsume("MODIFIER") ?? "", + }); + continue; + } + mustConsume("END"); + } + + return result; +} + +function escapeRegex(value: string): string { + return value.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} + +export function normalizeMiddlewarePathTokens( + tokens: MiddlewarePathToken[], +): MiddlewarePathToken[] { + return tokens.map((token) => { + if ( + typeof token === "object" && + (token.modifier === "*" || token.modifier === "+") && + token.prefix === "" && + token.suffix === "" + ) { + return { ...token, prefix: "/" }; + } + return token; + }); +} + +export function middlewarePathTokensToRegExp(tokens: MiddlewarePathToken[]): RegExp { + const delimiter = "/#?"; + const delimiterRegex = `[${escapeRegex(delimiter)}]`; + let route = "^"; + + for (const token of tokens) { + if (typeof token === "string") { + route += escapeRegex(token); + continue; + } + + const prefix = escapeRegex(token.prefix); + const suffix = escapeRegex(token.suffix); + if (token.pattern) { + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + const optional = token.modifier === "*" ? "?" : ""; + route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${optional}`; + } else { + route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; + } + } else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError(`Can not repeat "${token.name}" without a prefix and suffix`); + } + route += `(${token.pattern})${token.modifier}`; + } + } else { + route += `(?:${prefix}${suffix})${token.modifier}`; + } + } + + route += `${delimiterRegex}?$`; + return new RegExp(route, "i"); +} diff --git a/packages/vinext/src/server/middleware-runtime.ts b/packages/vinext/src/server/middleware-runtime.ts index 669e48bdb..a86217ed9 100644 --- a/packages/vinext/src/server/middleware-runtime.ts +++ b/packages/vinext/src/server/middleware-runtime.ts @@ -14,7 +14,7 @@ import { MIDDLEWARE_NEXT_HEADER, MIDDLEWARE_REWRITE_HEADER, } from "./headers.js"; -import { MatcherConfig, matchesMiddleware } from "./middleware-matcher.js"; +import { matchesMiddleware, type MatcherConfig } from "./middleware-matcher.js"; import { shouldKeepMiddlewareHeader } from "../utils/middleware-request-headers.js"; import { processMiddlewareHeaders } from "./request-pipeline.js"; import { badRequestResponse, internalServerErrorResponse } from "./http-error-responses.js"; diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 7ef46fa99..cf3557f69 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -30,11 +30,8 @@ import { sanitizeDestination, } from "../config/config-matchers.js"; import { buildMiddlewarePrefetchSkipResponse } from "./pages-data-route.js"; -import { - applyConfigHeadersToHeaderRecord, - cloneRequestWithUrl, - normalizeTrailingSlash, -} from "./request-pipeline.js"; +import { cloneRequestWithUrl, normalizeTrailingSlash } from "./request-pipeline.js"; +import { applyConfigHeadersToHeaderRecord } from "./config-headers.js"; import type { HeaderRecord } from "./request-pipeline.js"; import { mergeHeaders } from "./worker-utils.js"; import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./pages-i18n.js"; diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index 934953111..b20bcb7ea 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -1,7 +1,4 @@ import { hasBasePath, stripBasePath, removeTrailingSlash } from "../utils/base-path.js"; -import type { NextHeader } from "../config/next-config.js"; -import type { BasePathMatchState, RequestContext } from "../config/config-matchers.js"; -import { matchHeaders } from "../config/config-matchers.js"; import { INTERNAL_HEADERS, MIDDLEWARE_HEADER_PREFIX, @@ -100,20 +97,6 @@ export { hasBasePath, stripBasePath }; export type HeaderRecord = Record; -type ApplyConfigHeadersOptions = { - configHeaders: NextHeader[]; - pathname: string; - requestContext: RequestContext; - /** - * basePath gating state. When omitted, every rule is treated as a default - * (basePath: true) rule for backward compatibility — callers that need to - * support `basePath: false` headers must pass this in. - */ - basePathState?: BasePathMatchState; - /** Existing framework-generated headers that matching config rules may replace. */ - overwriteExisting?: ReadonlySet; -}; - type StaticFileSignalContext = { headers: Headers | null; status: number | null; @@ -133,94 +116,6 @@ function isWellKnownPathname(pathname: string): boolean { return pathname === "/.well-known" || pathname.startsWith("/.well-known/"); } -function findHeaderRecordKey(headers: HeaderRecord, lowerName: string): string | undefined { - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === lowerName) return key; - } - return undefined; -} - -function appendHeaderRecord(headers: HeaderRecord, lowerName: string, value: string): void { - const key = findHeaderRecordKey(headers, lowerName) ?? lowerName; - const existing = headers[key]; - if (existing === undefined) { - headers[key] = value; - return; - } - if (Array.isArray(existing)) { - existing.push(value); - return; - } - headers[key] = [existing, value]; -} - -function appendVaryHeaderRecord(headers: HeaderRecord, value: string): void { - const key = findHeaderRecordKey(headers, "vary") ?? "vary"; - const existing = headers[key]; - if (existing === undefined) { - headers[key] = value; - return; - } - if (Array.isArray(existing)) { - existing.push(value); - return; - } - headers[key] = existing + ", " + value; -} - -/** - * Apply matched next.config.js headers to a Web Headers object. - * - * Next.js evaluates config header match conditions against the original - * request snapshot. Middleware response headers still win for the same - * response key, while multi-value headers are additive. - */ -export function applyConfigHeadersToResponse( - responseHeaders: Headers, - options: ApplyConfigHeadersOptions, -): void { - const matched = matchHeaders( - options.pathname, - options.configHeaders, - options.requestContext, - options.basePathState, - ); - for (const header of matched) { - const lowerName = header.key.toLowerCase(); - if (lowerName === "vary" || lowerName === "set-cookie") { - responseHeaders.append(header.key, header.value); - } else if (options.overwriteExisting?.has(lowerName) || !responseHeaders.has(lowerName)) { - responseHeaders.set(header.key, header.value); - } - } -} - -/** - * Apply matched next.config.js headers to the early response header record used - * by Node and Worker Pages Router pipelines before a concrete response exists. - */ -export function applyConfigHeadersToHeaderRecord( - headers: HeaderRecord, - options: ApplyConfigHeadersOptions, -): void { - const matched = matchHeaders( - options.pathname, - options.configHeaders, - options.requestContext, - options.basePathState, - ); - for (const header of matched) { - const lowerName = header.key.toLowerCase(); - if (lowerName === "set-cookie") { - appendHeaderRecord(headers, lowerName, header.value); - } else if (lowerName === "vary") { - appendVaryHeaderRecord(headers, header.value); - } else if (findHeaderRecordKey(headers, lowerName) === undefined) { - headers[lowerName] = header.value; - } - } -} - export function createStaticFileSignal( pathname: string, context: StaticFileSignalContext, diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 491d38f6f..6b53332c6 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -76,6 +76,7 @@ type NavigateEvent = { }; const HAS_PAGES_ROUTER = process.env.__VINEXT_HAS_PAGES_ROUTER !== "false"; +const HAS_CLIENT_REWRITES = process.env.__VINEXT_HAS_CLIENT_REWRITES !== "false"; export type LinkProps<_RouteInferType = unknown> = { href: string | UrlObject; @@ -147,6 +148,13 @@ export function useLinkStatus(): LinkStatusContextValue { return useContext(LinkStatusContext); } +let linkPrefetchNavigationEpoch = 0; + +function notifyLinkNavigationStartAndCancelPrefetchSetup(): void { + linkPrefetchNavigationEpoch += 1; + notifyLinkNavigationStart(); +} + // Register the link-status reset hook on the navigation runtime as soon as this // module evaluates on the client. `navigateClientSide` calls it at the start of // every App Router navigation (including router.push and shallow routing), so a @@ -155,7 +163,7 @@ export function useLinkStatus(): LinkStatusContextValue { // it can be unit-tested without rendering a . if (typeof window !== "undefined") { registerNavigationRuntimeFunctions({ - notifyLinkNavigationStart, + notifyLinkNavigationStart: notifyLinkNavigationStartAndCancelPrefetchSetup, }); } @@ -424,6 +432,7 @@ function prefetchUrl( locale?: string | false, ): void { if (typeof window === "undefined") return; + const navigationEpoch = linkPrefetchNavigationEpoch; const prefetchHref = getLinkPrefetchHref({ href, @@ -469,15 +478,21 @@ function prefetchUrl( APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, }, headersModule, - { resolveHybridClientRewriteHref, resolveHybridClientRouteOwner }, + hybridRouteOwner, ] = await Promise.all([ import("./navigation.js"), import("../server/app-elements.js"), import("../server/app-rsc-cache-busting.js"), import("../server/app-rsc-render-mode.js"), import("../server/headers.js"), - import("./internal/hybrid-client-route-owner.js"), + HAS_PAGES_ROUTER || HAS_CLIENT_REWRITES + ? import("./internal/hybrid-client-route-owner.js") + : null, ]); + // A pointer-intent prefetch and its click navigation can start in the + // same event turn. If navigation won the module-loading race, do not + // begin a second request after it consumes an equivalent cached route. + if (navigationEpoch !== linkPrefetchNavigationEpoch) return; const { getPrefetchInterceptionContext, getPrefetchCache, @@ -503,11 +518,15 @@ function prefetchUrl( // stream is never consumed (the click path now hard-navigates to // Pages) and would also race the request the browser will issue on // the actual navigation. - const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + const hybridOwner = HAS_PAGES_ROUTER + ? hybridRouteOwner!.resolveHybridClientRouteOwner(prefetchHref, __basePath) + : null; if (hybridOwner === "pages" || hybridOwner === "document") { return; } - const rewrittenPrefetchHref = resolveHybridClientRewriteHref(fullHref, __basePath); + const rewrittenPrefetchHref = HAS_CLIENT_REWRITES + ? hybridRouteOwner!.resolveHybridClientRewriteHref(fullHref, __basePath) + : null; const prefetchPolicyHref = rewrittenPrefetchHref ?? prefetchHref; const autoPrefetch = mode === "auto" diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 6bcb89663..e9513e560 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -44,21 +44,20 @@ import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../server/headers.js"; -import { - isAbsoluteOrProtocolRelativeUrl, - toBrowserNavigationHref, - toSameOriginAppPath, - withBasePath, -} from "./url-utils.js"; +import { toBrowserNavigationHref, toSameOriginAppPath, withBasePath } from "./url-utils.js"; import { navigationPlanner } from "../server/navigation-planner.js"; import { stripBasePath } from "../utils/base-path.js"; import { isBotUserAgent } from "../utils/html-limited-bots.js"; +import { isExternalUrl } from "../utils/external-url.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.js"; import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js"; import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js"; -import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js"; +import { + resolveDirectHybridClientRouteOwner, + type HybridClientOwner, +} from "./internal/hybrid-client-route-owner-direct.js"; import { retryScrollTo, scrollToHashTarget } from "./hash-scroll.js"; import { beginAppRouterScrollIntent, @@ -81,6 +80,32 @@ import { scheduleAppPrefetchFetch, } from "./internal/app-prefetch-fetch-queue.js"; +const HAS_PAGES_ROUTER = process.env.__VINEXT_HAS_PAGES_ROUTER !== "false"; +type HybridClientRouteOwnerModule = typeof import("./internal/hybrid-client-route-owner.js"); +let hybridClientRouteOwnerModule: HybridClientRouteOwnerModule | null = null; +let hybridClientRouteOwnerModulePromise: Promise | null = null; + +/** Load rewrite-aware hybrid route ownership before navigation becomes interactive. */ +export async function preloadHybridClientRouteOwner(): Promise { + if (hybridClientRouteOwnerModule) return; + hybridClientRouteOwnerModulePromise ??= import("./internal/hybrid-client-route-owner.js"); + hybridClientRouteOwnerModule = await hybridClientRouteOwnerModulePromise; +} + +export function resolveLoadedHybridClientRewriteHref( + href: string, + basePath: string, +): string | null { + return hybridClientRouteOwnerModule?.resolveHybridClientRewriteHref(href, basePath) ?? null; +} + +function resolveHybridClientRouteOwner(href: string): HybridClientOwner | null { + if (!HAS_PAGES_ROUTER) return null; + return hybridClientRouteOwnerModule + ? hybridClientRouteOwnerModule.resolveHybridClientRouteOwner(href, __basePath) + : resolveDirectHybridClientRouteOwner(href, __basePath); +} + export { type NavigationContext, type NavigationStateAccessors, @@ -1694,13 +1719,6 @@ export function useParams(): T | null { } /* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ -/** - * Check if a href is an external URL (any URL scheme per RFC 3986, or protocol-relative). - */ -function isExternalUrl(href: string): boolean { - return isAbsoluteOrProtocolRelativeUrl(href); -} - // --------------------------------------------------------------------------- // History method wrappers — suppress notifications for internal updates // --------------------------------------------------------------------------- @@ -1958,7 +1976,7 @@ export async function navigateClientSide( // requests) or render the App catch-all's path array. This is the // programmatic equivalent of the link click / prefetch check in // `link.tsx`. - const hybridOwner = resolveHybridClientRouteOwner(normalizedHref, __basePath); + const hybridOwner = resolveHybridClientRouteOwner(normalizedHref); if (hybridOwner === "pages" || hybridOwner === "document") { const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); notifyAppRouterTransitionStart(fullHref, mode); @@ -2194,7 +2212,7 @@ const _appRouter: AppRouterInstance = { // origins so we don't pollute the prefetch cache with a same-path .rsc on // the current origin. Mirrors Link's prefetchUrl and navigateClientSide. let prefetchHref = href; - if (isAbsoluteOrProtocolRelativeUrl(href)) { + if (isExternalUrl(href)) { const localPath = toSameOriginAppPath(href, __basePath); if (localPath == null) return; prefetchHref = localPath; @@ -2206,7 +2224,7 @@ const _appRouter: AppRouterInstance = { // an unusable cache entry. The matching `push`/`replace` call will // hard-navigate via `window.location`, so a no-op here is correct — // the document prefetch the link shim emits on hover still runs. - const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + const hybridOwner = resolveHybridClientRouteOwner(prefetchHref); if (hybridOwner === "pages" || hybridOwner === "document") { return; } @@ -2275,7 +2293,7 @@ if (process.env.__NEXT_GESTURE_TRANSITION) { // inline check exists to *no-op* on external hrefs instead of falling // through to its hard window.location.assign. let appHref = href; - if (isAbsoluteOrProtocolRelativeUrl(href)) { + if (isExternalUrl(href)) { const localPath = toSameOriginAppPath(href, __basePath); if (localPath === null) return; appHref = localPath; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index ffedd52bc..d6e858d2a 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -56,6 +56,7 @@ import { import { resolveDirectHybridClientRouteOwner } from "./internal/hybrid-client-route-owner-direct.js"; import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js"; import { isUnknownRecord } from "../utils/record.js"; +import { isExternalUrl } from "../utils/external-url.js"; import { splitPathSegments } from "../routing/utils.js"; import { isAbsoluteOrProtocolRelativeUrl, @@ -794,10 +795,7 @@ function getPagesHtmlFetchUrl(browserUrl: string, locale: string | undefined): s ); } -/** Check if a URL is external (any URL scheme per RFC 3986, or protocol-relative) */ -export function isExternalUrl(url: string): boolean { - return isAbsoluteOrProtocolRelativeUrl(url); -} +export { isExternalUrl }; /** Resolve a hash URL to a basePath-stripped app URL for event payloads */ function resolveHashUrl(url: string): string { @@ -1666,7 +1664,7 @@ async function resolveClientConfigRedirect(href: string): Promise const routeContext = getClientConfigRouteContext(href); if (!routeContext) return null; - const { isExternalUrl, matchRedirect, preserveRedirectDestinationQuery } = + const { matchRedirect, preserveRedirectDestinationQuery } = await import("../config/config-matchers.js"); const redirect = matchRedirect( routeContext.pathname, @@ -1693,7 +1691,7 @@ async function applyClientConfigRewrite( const routeContext = getClientConfigRouteContext(href); if (!routeContext) return null; - const { isExternalUrl, matchRewrite } = await import("../config/config-matchers.js"); + const { matchRewrite } = await import("../config/config-matchers.js"); const rewritten = matchRewrite( routeContext.pathname, [rewrite], diff --git a/packages/vinext/src/utils/external-url.ts b/packages/vinext/src/utils/external-url.ts new file mode 100644 index 000000000..54d902e0d --- /dev/null +++ b/packages/vinext/src/utils/external-url.ts @@ -0,0 +1,4 @@ +/** Detect an absolute URL with any scheme, or a protocol-relative URL. */ +export function isExternalUrl(url: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//"); +} diff --git a/packages/vinext/src/utils/regex-safety.ts b/packages/vinext/src/utils/regex-safety.ts new file mode 100644 index 000000000..3d9e54dbf --- /dev/null +++ b/packages/vinext/src/utils/regex-safety.ts @@ -0,0 +1,858 @@ +/** + * Deterministic structural analysis for request-facing regular expressions. + * + * The parser derives exact widths and finite branch words without executing + * attacker-sized probes. Repeated finite languages are checked with a prefix + * trie, so literal alternatives are linear in their total source length. + * Unsupported intersections fail closed behind explicit node, word, symbol, + * comparison, and nesting budgets. + */ +type RegexNode = + | { kind: "atom"; symbol: RegexSymbol | null; fixedWidth: boolean } + | { kind: "assertion"; child: RegexNode } + | { kind: "sequence"; children: RegexNode[] } + | { kind: "alternation"; branches: RegexNode[] } + | { kind: "repeat"; child: RegexNode; min: number; max: number }; + +type RegexSymbol = + | { kind: "literal"; key: string; value: string } + | { + kind: "class"; + key: string; + values: ReadonlySet; + nonAscii: NonAsciiDomain; + } + | { kind: "opaque"; key: string; pattern: string; ignoreCase: boolean }; + +type NonAsciiDomain = "none" | "whitespace" | "non-whitespace" | "all"; + +export type RegexSafetyIssue = + | "nested repetition" + | "ambiguous alternatives under repetition" + | "ambiguous sequence expansion" + | "overlapping sequential repetition" + | "analysis budget exceeded"; + +const MAX_NODES = 16_384; +const MAX_NESTING_DEPTH = 256; +const MAX_PATTERN_LENGTH = 65_536; +const MAX_WORDS = 4_096; +const MAX_WORD_SYMBOLS = 32_768; +const MAX_OPAQUE_COMPARISONS = 4_096; +const MAX_SEQUENCE_EXPANSIONS = 256; +const MAX_SAFE_OVERLAPPING_VARIABLE_BOUNDARIES = 1; + +function canonicalizeIgnoreCase(character: string): string { + const upper = character.toUpperCase(); + // ECMAScript's non-Unicode Canonicalize operation keeps the original UTF-16 + // code unit when uppercasing expands it or maps a non-ASCII character to + // ASCII. Middleware regexes are compiled with `i`, but not `u`. + if (upper.length !== 1) return character; + if (character.charCodeAt(0) >= 0x80 && upper.charCodeAt(0) < 0x80) return character; + return upper; +} + +function literalSymbol(character: string, ignoreCase: boolean): RegexSymbol { + const key = ignoreCase ? canonicalizeIgnoreCase(character) : character; + return { kind: "literal", key, value: character }; +} + +function createClassSymbol(values: ReadonlySet, nonAscii: NonAsciiDomain): RegexSymbol { + const key = [...values].sort().join(""); + return { kind: "class", key: `class:${nonAscii}:${key}`, values, nonAscii }; +} + +function shorthandClassSymbol(shorthand: string, ignoreCase: boolean): RegexSymbol | null { + if (!"dDwWsS".includes(shorthand)) return null; + const regexp = new RegExp(`\\${shorthand}`); + const values = new Set(); + for (let code = 0; code <= 0x7f; code++) { + const character = String.fromCharCode(code); + if (regexp.test(character)) { + values.add(ignoreCase ? canonicalizeIgnoreCase(character) : character); + } + } + const nonAscii: NonAsciiDomain = + shorthand === "d" || shorthand === "w" + ? "none" + : shorthand === "s" + ? "whitespace" + : shorthand === "S" + ? "non-whitespace" + : "all"; + return createClassSymbol(values, nonAscii); +} + +function unionNonAscii(left: NonAsciiDomain, right: NonAsciiDomain): NonAsciiDomain { + if (left === "none") return right; + if (right === "none" || left === right) return left; + return "all"; +} + +function simpleClassSymbol(raw: string, ignoreCase: boolean): RegexSymbol | null { + const end = raw.length - 1; + if (raw[0] !== "[" || raw[end] !== "]" || raw[1] === "^") return null; + const values = new Set(); + let nonAscii: NonAsciiDomain = "none"; + + const add = (character: string): boolean => { + if (character.charCodeAt(0) > 0x7f) return false; + values.add(ignoreCase ? canonicalizeIgnoreCase(character) : character); + return true; + }; + + const addClass = (symbol: RegexSymbol): boolean => { + if (symbol.kind !== "class") return false; + for (const value of symbol.values) values.add(value); + nonAscii = unionNonAscii(nonAscii, symbol.nonAscii); + return true; + }; + + for (let index = 1; index < end; index++) { + const start = raw[index]; + if (start === "\\") { + const escaped = raw[++index]; + if (escaped === undefined) return null; + const shorthand = shorthandClassSymbol(escaped, ignoreCase); + if (shorthand) { + if (!addClass(shorthand)) return null; + } else if ("\\-]".includes(escaped)) { + if (!add(escaped)) return null; + } else { + return null; + } + continue; + } + if (index + 2 < end && raw[index + 1] === "-") { + const rangeEnd = raw[index + 2]; + if (rangeEnd === "\\") return null; + const startCode = start.charCodeAt(0); + const endCode = rangeEnd.charCodeAt(0); + if (startCode > endCode || endCode > 0x7f) return null; + for (let code = startCode; code <= endCode; code++) { + if (!add(String.fromCharCode(code))) return null; + } + index += 2; + } else if (!add(start)) { + return null; + } + } + + if (values.size === 0 && nonAscii === "none") return null; + return createClassSymbol(values, nonAscii); +} + +class RegexParser { + index = 0; + nodes = 0; + depth = 0; + exceededBudget = false; + + constructor( + private readonly pattern: string, + private readonly ignoreCase: boolean, + ) {} + + parse(): RegexNode { + return this.parseAlternation(); + } + + private node(node: T): T { + this.nodes++; + if (this.nodes > MAX_NODES) this.exceededBudget = true; + return node; + } + + private parseAlternation(): RegexNode { + const branches = [this.parseSequence()]; + while (this.pattern[this.index] === "|") { + this.index++; + branches.push(this.parseSequence()); + } + return branches.length === 1 ? branches[0] : this.node({ kind: "alternation", branches }); + } + + private parseSequence(): RegexNode { + const children: RegexNode[] = []; + while (this.index < this.pattern.length) { + const character = this.pattern[this.index]; + if (character === "|" || character === ")") break; + const term = this.parseTerm(); + // Parentheses around a sequence do not change which terms can consume + // adjacent input. Flatten those transparent wrappers so empty groups + // cannot hide overlapping repetitions from sequence analysis. + if (term.kind === "sequence") children.push(...term.children); + else children.push(term); + } + return children.length === 1 ? children[0] : this.node({ kind: "sequence", children }); + } + + private parseTerm(): RegexNode { + const atom = this.parseAtom(); + const quantifier = this.parseQuantifier(); + if (!quantifier) return atom; + if (this.pattern[this.index] === "?") this.index++; + // An exact-one quantifier does not change the language or consumption of + // its child. Remove it so it cannot hide a variable repetition from the + // surrounding sequence analysis. + if (quantifier.min === 1 && quantifier.max === 1) return atom; + return this.node({ kind: "repeat", child: atom, ...quantifier }); + } + + private parseAtom(): RegexNode { + const character = this.pattern[this.index++]; + if (character === "(") return this.parseGroup(); + if (character === "[") return this.parseClass(); + if (character === "\\") return this.parseEscape(); + if (character === "^" || character === "$") { + return this.node({ kind: "assertion", child: this.node({ kind: "sequence", children: [] }) }); + } + if (character === ".") { + return this.node({ + kind: "atom", + symbol: { kind: "opaque", key: ".", pattern: ".", ignoreCase: this.ignoreCase }, + fixedWidth: true, + }); + } + return this.node({ + kind: "atom", + symbol: literalSymbol(character, this.ignoreCase), + fixedWidth: true, + }); + } + + private parseGroup(): RegexNode { + this.depth++; + if (this.depth > MAX_NESTING_DEPTH) { + this.exceededBudget = true; + this.skipGroup(); + this.depth--; + return this.node({ kind: "atom", symbol: null, fixedWidth: false }); + } + let assertion = false; + if (this.pattern[this.index] === "?") { + const marker = this.pattern[this.index + 1]; + if (marker === ":") { + this.index += 2; + } else if (marker === "=" || marker === "!") { + assertion = true; + this.index += 2; + } else if ( + marker === "<" && + (this.pattern[this.index + 2] === "=" || this.pattern[this.index + 2] === "!") + ) { + assertion = true; + this.index += 3; + } else if (marker === "<") { + const nameEnd = this.pattern.indexOf(">", this.index + 2); + this.index = nameEnd === -1 ? this.pattern.length : nameEnd + 1; + } else { + // Unsupported group prefixes will be rejected by RegExp compilation. + // Keep analysis conservative if this parser is asked to inspect one. + while (this.index < this.pattern.length && this.pattern[this.index] !== ")") this.index++; + if (this.pattern[this.index] === ")") this.index++; + this.depth--; + return this.node({ kind: "atom", symbol: null, fixedWidth: false }); + } + } + + const child = this.parseAlternation(); + if (this.pattern[this.index] === ")") this.index++; + this.depth--; + return assertion ? this.node({ kind: "assertion", child }) : child; + } + + private skipGroup(): void { + let depth = 1; + let inClass = false; + while (this.index < this.pattern.length && depth > 0) { + const character = this.pattern[this.index++]; + if (character === "\\") { + this.index++; + continue; + } + if (character === "[") inClass = true; + else if (character === "]") inClass = false; + else if (!inClass && character === "(") depth++; + else if (!inClass && character === ")") depth--; + } + } + + private parseClass(): RegexNode { + const start = this.index - 1; + while (this.index < this.pattern.length) { + const character = this.pattern[this.index++]; + if (character === "\\") this.index++; + else if (character === "]") break; + } + const raw = this.pattern.slice(start, this.index); + return this.node({ + kind: "atom", + symbol: + simpleClassSymbol(raw, this.ignoreCase) ?? + ({ kind: "opaque", key: raw, pattern: raw, ignoreCase: this.ignoreCase } as const), + fixedWidth: true, + }); + } + + private parseEscape(): RegexNode { + const escaped = this.pattern[this.index++]; + if (escaped === undefined) { + return this.node({ kind: "atom", symbol: null, fixedWidth: false }); + } + if (escaped === "b" || escaped === "B") { + return this.node({ kind: "assertion", child: this.node({ kind: "sequence", children: [] }) }); + } + const shorthand = shorthandClassSymbol(escaped, this.ignoreCase); + if (shorthand) { + return this.node({ kind: "atom", symbol: shorthand, fixedWidth: true }); + } + if (/\d/.test(escaped)) { + return this.node({ kind: "atom", symbol: null, fixedWidth: false }); + } + + let literal: string | null = null; + if (escaped === "x" && /^[\da-fA-F]{2}/.test(this.pattern.slice(this.index, this.index + 2))) { + literal = String.fromCharCode( + Number.parseInt(this.pattern.slice(this.index, this.index + 2), 16), + ); + this.index += 2; + } else if ( + escaped === "u" && + /^[\da-fA-F]{4}/.test(this.pattern.slice(this.index, this.index + 4)) + ) { + literal = String.fromCharCode( + Number.parseInt(this.pattern.slice(this.index, this.index + 4), 16), + ); + this.index += 4; + } else if ("nrtvf0".includes(escaped)) { + literal = ({ n: "\n", r: "\r", t: "\t", v: "\v", f: "\f", 0: "\0" } as const)[ + escaped as "n" | "r" | "t" | "v" | "f" | "0" + ]; + } else if (!/[A-Za-z]/.test(escaped)) { + literal = escaped; + } + + if (literal !== null) { + return this.node({ + kind: "atom", + symbol: literalSymbol(literal, this.ignoreCase), + fixedWidth: true, + }); + } + const raw = `\\${escaped}`; + return this.node({ + kind: "atom", + symbol: { kind: "opaque", key: raw, pattern: raw, ignoreCase: this.ignoreCase }, + fixedWidth: true, + }); + } + + private parseQuantifier(): { min: number; max: number } | null { + const character = this.pattern[this.index]; + if (character === "*") { + this.index++; + return { min: 0, max: Infinity }; + } + if (character === "+") { + this.index++; + return { min: 1, max: Infinity }; + } + if (character === "?") { + this.index++; + return { min: 0, max: 1 }; + } + if (character !== "{") return null; + + const start = this.index; + let cursor = start + 1; + while (/\d/.test(this.pattern[cursor] ?? "")) cursor++; + if (cursor === start + 1) return null; + const min = Number(this.pattern.slice(start + 1, cursor)); + if (this.pattern[cursor] === "}") { + this.index = cursor + 1; + return { min, max: min }; + } + if (this.pattern[cursor] !== ",") return null; + cursor++; + const maxStart = cursor; + while (/\d/.test(this.pattern[cursor] ?? "")) cursor++; + if (this.pattern[cursor] !== "}") return null; + const max = cursor === maxStart ? Infinity : Number(this.pattern.slice(maxStart, cursor)); + this.index = cursor + 1; + return { min, max }; + } +} + +function exactWidth(node: RegexNode): number | null { + switch (node.kind) { + case "atom": + return node.fixedWidth ? 1 : null; + case "assertion": + return 0; + case "sequence": { + let width = 0; + for (const child of node.children) { + const childWidth = exactWidth(child); + if (childWidth === null) return null; + width += childWidth; + } + return width; + } + case "alternation": { + let width: number | null | undefined; + for (const branch of node.branches) { + const branchWidth = exactWidth(branch); + if (branchWidth === null) return null; + if (width === undefined) width = branchWidth; + else if (width !== branchWidth) return null; + } + return width ?? 0; + } + case "repeat": { + if (node.min !== node.max || !Number.isFinite(node.max)) return null; + const childWidth = exactWidth(node.child); + return childWidth === null ? null : childWidth * node.min; + } + } +} + +function containsConsumingRepetition(node: RegexNode): boolean { + switch (node.kind) { + case "atom": + return false; + case "assertion": + return false; + case "sequence": + return node.children.some(containsConsumingRepetition); + case "alternation": + return node.branches.some(containsConsumingRepetition); + case "repeat": + return node.min !== 1 || node.max !== 1 || containsConsumingRepetition(node.child); + } +} + +function containsConsumingAlternation(node: RegexNode): boolean { + switch (node.kind) { + case "atom": + return false; + case "assertion": + return false; + case "sequence": + return node.children.some(containsConsumingAlternation); + case "alternation": + return true; + case "repeat": + return containsConsumingAlternation(node.child); + } +} + +type WordBudget = { words: number; symbols: number; exceeded: boolean }; + +function fixedWords(node: RegexNode, budget: WordBudget): RegexSymbol[][] | null { + if (budget.exceeded) return null; + switch (node.kind) { + case "atom": + return node.symbol ? [[node.symbol]] : null; + case "assertion": + return [[]]; + case "sequence": { + let words: RegexSymbol[][] = [[]]; + for (const child of node.children) { + const childWords = fixedWords(child, budget); + if (!childWords) return null; + const next: RegexSymbol[][] = []; + for (const prefix of words) { + for (const suffix of childWords) { + if (++budget.words > MAX_WORDS) { + budget.exceeded = true; + return null; + } + const word = [...prefix, ...suffix]; + budget.symbols += word.length; + if (budget.symbols > MAX_WORD_SYMBOLS) { + budget.exceeded = true; + return null; + } + next.push(word); + } + } + words = next; + } + return words; + } + case "alternation": { + const words: RegexSymbol[][] = []; + for (const branch of node.branches) { + const branchWords = fixedWords(branch, budget); + if (!branchWords) return null; + words.push(...branchWords); + if ((budget.words += branchWords.length) > MAX_WORDS) { + budget.exceeded = true; + return null; + } + } + return words; + } + case "repeat": { + if (node.min !== node.max || !Number.isFinite(node.max)) return null; + let words: RegexSymbol[][] = [[]]; + const childWords = fixedWords(node.child, budget); + if (!childWords) return null; + for (let count = 0; count < node.min; count++) { + const next: RegexSymbol[][] = []; + for (const prefix of words) { + for (const suffix of childWords) { + if (++budget.words > MAX_WORDS) { + budget.exceeded = true; + return null; + } + const word = [...prefix, ...suffix]; + budget.symbols += word.length; + if (budget.symbols > MAX_WORD_SYMBOLS) { + budget.exceeded = true; + return null; + } + next.push(word); + } + } + words = next; + } + return words; + } + } +} + +type TrieEdge = { symbol: RegexSymbol; node: TrieNode }; +type TrieNode = { + terminal: boolean; + edges: Map; + complexEdges: TrieEdge[]; +}; + +function createTrieNode(): TrieNode { + return { terminal: false, edges: new Map(), complexEdges: [] }; +} + +function opaqueMatchesLiteral(opaque: RegexSymbol, literal: RegexSymbol): boolean { + if (opaque.kind !== "opaque" || literal.kind !== "literal") return false; + try { + return new RegExp(`^(?:${opaque.pattern})$`, opaque.ignoreCase ? "i" : "").test(literal.value); + } catch { + return true; + } +} + +function classMatchesLiteral(characterClass: RegexSymbol, literal: RegexSymbol): boolean { + if (characterClass.kind !== "class" || literal.kind !== "literal") return false; + if (literal.value.charCodeAt(0) <= 0x7f) return characterClass.values.has(literal.key); + if (characterClass.nonAscii === "all") return true; + const whitespace = /\s/.test(literal.value); + return whitespace + ? characterClass.nonAscii === "whitespace" + : characterClass.nonAscii === "non-whitespace"; +} + +function nonAsciiDomainsOverlap(left: NonAsciiDomain, right: NonAsciiDomain): boolean { + if (left === "none" || right === "none") return false; + if (left === "all" || right === "all") return true; + return left === right; +} + +function symbolsMayOverlap(left: RegexSymbol, right: RegexSymbol): boolean { + if (left.kind === "literal" && right.kind === "literal") return left.key === right.key; + if (left.kind === "class" && right.kind === "literal") return classMatchesLiteral(left, right); + if (left.kind === "literal" && right.kind === "class") return classMatchesLiteral(right, left); + if (left.kind === "class" && right.kind === "class") { + const [smaller, larger] = + left.values.size <= right.values.size + ? [left.values, right.values] + : [right.values, left.values]; + for (const value of smaller) { + if (larger.has(value)) return true; + } + return nonAsciiDomainsOverlap(left.nonAscii, right.nonAscii); + } + if (left.kind === "opaque" && right.kind === "literal") { + return opaqueMatchesLiteral(left, right); + } + if (left.kind === "literal" && right.kind === "opaque") { + return opaqueMatchesLiteral(right, left); + } + return true; +} + +function insertPrefixFreeWord( + root: TrieNode, + word: RegexSymbol[], + comparisons: { count: number }, +): boolean { + let node = root; + for (const symbol of word) { + if (node.terminal) return false; + let edge = node.edges.get(symbol.key); + if (!edge) { + const candidates = symbol.kind === "literal" ? node.complexEdges : node.edges.values(); + for (const candidate of candidates) { + if (++comparisons.count > MAX_OPAQUE_COMPARISONS) return false; + if (symbolsMayOverlap(candidate.symbol, symbol)) return false; + } + edge = { symbol, node: createTrieNode() }; + node.edges.set(symbol.key, edge); + if (symbol.kind !== "literal") node.complexEdges.push(edge); + } + node = edge.node; + } + if (node.terminal || node.edges.size > 0) return false; + node.terminal = true; + return true; +} + +function hasPrefixFreeFiniteLanguage(node: RegexNode): { + safe: boolean; + budgetExceeded: boolean; + wordCount: number; +} { + const budget: WordBudget = { words: 0, symbols: 0, exceeded: false }; + const words = fixedWords(node, budget); + if (!words) return { safe: false, budgetExceeded: budget.exceeded, wordCount: 0 }; + const root = createTrieNode(); + const comparisons = { count: 0 }; + for (const word of words) { + if (!insertPrefixFreeWord(root, word, comparisons)) { + return { + safe: false, + budgetExceeded: comparisons.count > MAX_OPAQUE_COMPARISONS, + wordCount: words.length, + }; + } + } + return { safe: true, budgetExceeded: false, wordCount: words.length }; +} + +function ambiguousExpansionFactor(node: RegexNode): number { + switch (node.kind) { + case "atom": + case "assertion": + return 1; + case "alternation": { + const result = hasPrefixFreeFiniteLanguage(node); + if (result.safe) return 1; + if (result.budgetExceeded || result.wordCount === 0) return MAX_SEQUENCE_EXPANSIONS + 1; + return result.wordCount; + } + case "sequence": { + let factor = 1; + for (const child of node.children) { + factor *= ambiguousExpansionFactor(child); + if (factor > MAX_SEQUENCE_EXPANSIONS) return factor; + } + return factor; + } + case "repeat": { + if (node.min !== node.max || !Number.isFinite(node.max)) return 1; + const childFactor = ambiguousExpansionFactor(node.child); + let factor = 1; + for (let count = 0; count < node.max; count++) { + factor *= childFactor; + if (factor > MAX_SEQUENCE_EXPANSIONS) return factor; + } + return factor; + } + } +} + +function isNullable(node: RegexNode): boolean { + switch (node.kind) { + case "atom": + return !node.fixedWidth; + case "assertion": + return true; + case "sequence": + return node.children.every(isNullable); + case "alternation": + return node.branches.some(isNullable); + case "repeat": + return node.min === 0 || isNullable(node.child); + } +} + +function firstSymbols(node: RegexNode): RegexSymbol[] | null { + switch (node.kind) { + case "atom": + return node.symbol ? [node.symbol] : null; + case "assertion": + return []; + case "repeat": + return firstSymbols(node.child); + case "alternation": { + const symbols: RegexSymbol[] = []; + for (const branch of node.branches) { + const branchSymbols = firstSymbols(branch); + if (!branchSymbols) return null; + symbols.push(...branchSymbols); + } + return symbols; + } + case "sequence": { + const symbols: RegexSymbol[] = []; + for (const child of node.children) { + const childSymbols = firstSymbols(child); + if (!childSymbols) return null; + symbols.push(...childSymbols); + if (!isNullable(child)) break; + } + return symbols; + } + } +} + +function lastSymbols(node: RegexNode): RegexSymbol[] | null { + switch (node.kind) { + case "atom": + return node.symbol ? [node.symbol] : null; + case "assertion": + return []; + case "repeat": + return lastSymbols(node.child); + case "alternation": { + const symbols: RegexSymbol[] = []; + for (const branch of node.branches) { + const branchSymbols = lastSymbols(branch); + if (!branchSymbols) return null; + symbols.push(...branchSymbols); + } + return symbols; + } + case "sequence": { + const symbols: RegexSymbol[] = []; + for (let index = node.children.length - 1; index >= 0; index--) { + const child = node.children[index]; + const childSymbols = lastSymbols(child); + if (!childSymbols) return null; + symbols.push(...childSymbols); + if (!isNullable(child)) break; + } + return symbols; + } + } +} + +function boundariesMayOverlap( + left: RegexSymbol[] | null, + right: RegexSymbol[] | null, + comparisons: { count: number }, +): boolean { + if (!left || !right) return true; + for (const leftSymbol of left) { + for (const rightSymbol of right) { + if (++comparisons.count > MAX_OPAQUE_COMPARISONS) return true; + if (symbolsMayOverlap(leftSymbol, rightSymbol)) return true; + } + } + return false; +} + +function findSequenceIssue( + node: Extract, +): RegexSafetyIssue | null { + if (ambiguousExpansionFactor(node) > MAX_SEQUENCE_EXPANSIONS) { + return "ambiguous sequence expansion"; + } + + let pendingRepetitionEnds: Array = []; + const comparisons = { count: 0 }; + let overlappingBoundaryCount = 0; + for (const child of node.children) { + const variableRepetition = + child.kind === "repeat" && (child.min !== child.max || !Number.isFinite(child.max)); + if (variableRepetition) { + const starts = firstSymbols(child); + const overlappingBoundaries = pendingRepetitionEnds.filter((ends) => + boundariesMayOverlap(ends, starts, comparisons), + ).length; + if (overlappingBoundaries > 0) { + overlappingBoundaryCount++; + } else if (!isNullable(child)) { + overlappingBoundaryCount = 0; + } + // One overlapping boundary has linearly many partitions over the input + // length. A second makes that search quadratic, and every additional + // boundary raises the degree again. Preserve common two-repeat patterns, + // but reject longer overlapping chains before compiling them. + if (overlappingBoundaryCount > MAX_SAFE_OVERLAPPING_VARIABLE_BOUNDARIES) { + return "overlapping sequential repetition"; + } + const ends = lastSymbols(child); + pendingRepetitionEnds = isNullable(child) ? [...pendingRepetitionEnds, ends] : [ends]; + } else if (!isNullable(child)) { + pendingRepetitionEnds = []; + overlappingBoundaryCount = 0; + } + } + return null; +} + +function findSafetyIssue(node: RegexNode): RegexSafetyIssue | null { + switch (node.kind) { + case "atom": + return null; + case "assertion": + return findSafetyIssue(node.child); + case "sequence": { + const sequenceIssue = findSequenceIssue(node); + if (sequenceIssue) return sequenceIssue; + for (const child of node.children) { + const issue = findSafetyIssue(child); + if (issue) return issue; + } + return null; + } + case "alternation": + for (const branch of node.branches) { + const issue = findSafetyIssue(branch); + if (issue) return issue; + } + return null; + case "repeat": { + const nestedRepetition = containsConsumingRepetition(node.child); + if (node.max > 1 && nestedRepetition && exactWidth(node.child) === null) { + return "nested repetition"; + } + if (node.max > 1 && containsConsumingAlternation(node.child)) { + const prefixFree = hasPrefixFreeFiniteLanguage(node.child); + if (!prefixFree.safe) { + return prefixFree.budgetExceeded + ? "analysis budget exceeded" + : "ambiguous alternatives under repetition"; + } + } + return findSafetyIssue(node.child); + } + } +} + +export function analyzeRegexSafety( + pattern: string, + options: { ignoreCase?: boolean } = {}, +): RegexSafetyIssue | null { + if (pattern.length > MAX_PATTERN_LENGTH) return "analysis budget exceeded"; + const parser = new RegexParser(pattern, options.ignoreCase === true); + const node = parser.parse(); + if (parser.exceededBudget) return "analysis budget exceeded"; + return findSafetyIssue(node); +} + +export function regexAtomsMayOverlap(left: string, right: string, ignoreCase = false): boolean { + const leftParser = new RegexParser(left, ignoreCase); + const rightParser = new RegexParser(right, ignoreCase); + const leftNode = leftParser.parse(); + const rightNode = rightParser.parse(); + const leftWords = fixedWords(leftNode, { words: 0, symbols: 0, exceeded: false }); + const rightWords = fixedWords(rightNode, { words: 0, symbols: 0, exceeded: false }); + if (!leftWords || !rightWords || leftWords.length !== 1 || rightWords.length !== 1) return true; + const leftSymbol = leftWords[0][0]; + const rightSymbol = rightWords[0][0]; + if (!leftSymbol || !rightSymbol) return true; + return symbolsMayOverlap(leftSymbol, rightSymbol); +} diff --git a/tests/app-rsc-response-finalizer.test.ts b/tests/app-rsc-response-finalizer.test.ts index fa633199a..162271928 100644 --- a/tests/app-rsc-response-finalizer.test.ts +++ b/tests/app-rsc-response-finalizer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; -import type { RequestContext } from "../packages/vinext/src/config/config-matchers.js"; +import type { RequestContext } from "../packages/vinext/src/config/request-context.js"; function makeRequestContext(headers: Headers = new Headers()): RequestContext { return { @@ -15,13 +15,13 @@ function makeRequestContext(headers: Headers = new Headers()): RequestContext { // ── config headers applied to non-redirect responses ──────────────────── describe("finalizeAppRscResponse — config header application", () => { - it("applies a matching config header to a 200 response", () => { + it("applies a matching config header to a 200 response", async () => { // Behavior: /about page response gets x-added header from next.config.js headers[]. // Regression: expected null to be "config" const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }], i18nConfig: null, @@ -31,7 +31,7 @@ describe("finalizeAppRscResponse — config header application", () => { expect(response.headers.get("x-added")).toBe("config"); }); - it("adds the App Router RSC vary header when no config headers are configured", () => { + it("adds the App Router RSC vary header when no config headers are configured", async () => { // Behavior: App Router responses always carry the RSC vary key, even when // no next.config.js headers match. This covers app route handlers that // return their own Response object instead of using app page helpers. @@ -40,7 +40,7 @@ describe("finalizeAppRscResponse — config header application", () => { const response = new Response("body", { status: 200, headers: { "x-existing": "keep" } }); const request = new Request("http://example.com/about"); - const result = finalizeAppRscResponse(response, request, { + const result = await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [], i18nConfig: null, @@ -52,13 +52,13 @@ describe("finalizeAppRscResponse — config header application", () => { expect(result.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER); }); - it("does not apply config headers when source pattern does not match", () => { + it("does not apply config headers when source pattern does not match", async () => { // Behavior: /blog response is unaffected by a config header scoped to /about. // Regression: expected "config" to be null. const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/blog"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }], i18nConfig: null, @@ -68,11 +68,11 @@ describe("finalizeAppRscResponse — config header application", () => { expect(response.headers.get("x-added")).toBeNull(); }); - it("does not apply config headers through percent-encoded static aliases", () => { + it("does not apply config headers through percent-encoded static aliases", async () => { const response = new Response("body", { status: 404 }); const request = new Request("http://example.com/%61bout"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }], i18nConfig: null, @@ -86,11 +86,11 @@ describe("finalizeAppRscResponse — config header application", () => { // ── App Router RSC vary header ────────────────────────────────────────── describe("finalizeAppRscResponse — App Router RSC vary header", () => { - it("preserves custom Vary values while appending the internal RSC vary key", () => { + it("preserves custom Vary values while appending the internal RSC vary key", async () => { const response = new Response("body", { status: 200, headers: { Vary: "User-Agent" } }); const request = new Request("http://example.com/normal"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [], i18nConfig: null, @@ -100,14 +100,14 @@ describe("finalizeAppRscResponse — App Router RSC vary header", () => { expect(response.headers.get("vary")).toBe(`User-Agent, ${VINEXT_RSC_VARY_HEADER}`); }); - it("does not duplicate RSC vary tokens already set by app page helpers", () => { + it("does not duplicate RSC vary tokens already set by app page helpers", async () => { const response = new Response("body", { status: 200, headers: { Vary: VINEXT_RSC_VARY_HEADER }, }); const request = new Request("http://example.com/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [], i18nConfig: null, @@ -117,11 +117,11 @@ describe("finalizeAppRscResponse — App Router RSC vary header", () => { expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER); }); - it("preserves wildcard Vary semantics", () => { + it("preserves wildcard Vary semantics", async () => { const response = new Response("body", { status: 200, headers: { Vary: "*" } }); const request = new Request("http://example.com/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [], i18nConfig: null, @@ -135,30 +135,30 @@ describe("finalizeAppRscResponse — App Router RSC vary header", () => { // ── redirect responses skipped ────────────────────────────────────────── describe("finalizeAppRscResponse — redirect responses are not mutated", () => { - it("does not throw when called with an immutable 307 redirect response", () => { + it("does not throw when called with an immutable 307 redirect response", async () => { // Behavior: Response.redirect() creates immutable headers; calling finalizeAppRscResponse // on such a response must never throw "Cannot modify immutable headers". // Regression: TypeError: Cannot modify immutable headers const response = Response.redirect("http://example.com/new", 307); const request = new Request("http://example.com/old"); - expect(() => + await expect( finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }], i18nConfig: null, requestContext: makeRequestContext(), }), - ).not.toThrow(); + ).resolves.toBe(response); }); - it("does not apply config headers to a mutable 308 permanent redirect", () => { + it("does not apply config headers to a mutable 308 permanent redirect", async () => { // Behavior: 308 redirect responses skip config header application regardless of mutability. // Regression: expected "yes" to be null — header applied to redirect response. const response = new Response(null, { status: 308, headers: { Location: "/new" } }); const request = new Request("http://example.com/old"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }], i18nConfig: null, @@ -172,13 +172,13 @@ describe("finalizeAppRscResponse — redirect responses are not mutated", () => // ── basePath stripping ────────────────────────────────────────────────── describe("finalizeAppRscResponse — basePath stripping before pattern matching", () => { - it("strips basePath before matching config header source patterns", () => { + it("strips basePath before matching config header source patterns", async () => { // Behavior: config header source "/about" applies to request "/app/about" when basePath="/app". // Regression: expected null to be "config" — header not matched because /app/about ≠ /about. const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/app/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "/app", configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }], i18nConfig: null, @@ -188,7 +188,7 @@ describe("finalizeAppRscResponse — basePath stripping before pattern matching" expect(response.headers.get("x-added")).toBe("config"); }); - it("does not strip basePath when pathname only shares a string prefix (segment boundary)", () => { + it("does not strip basePath when pathname only shares a string prefix (segment boundary)", async () => { // Behavior: /app2/page with basePath /app must not strip /app, because /app2 is a // different path segment. The config header source "/2/page" must not match. // Regression: expected "yes" to be null — basePath incorrectly stripped past segment @@ -196,7 +196,7 @@ describe("finalizeAppRscResponse — basePath stripping before pattern matching" const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/app2/page"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "/app", configHeaders: [{ source: "/2/page", headers: [{ key: "x-wrong-strip", value: "yes" }] }], i18nConfig: null, @@ -206,13 +206,13 @@ describe("finalizeAppRscResponse — basePath stripping before pattern matching" expect(response.headers.get("x-wrong-strip")).toBeNull(); }); - it("strips nested basePath correctly", () => { + it("strips nested basePath correctly", async () => { // Behavior: config header source "/guide" applies to /docs/v2/guide when basePath="/docs/v2". // Regression: expected null to be "config". const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/docs/v2/guide"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "/docs/v2", configHeaders: [{ source: "/guide", headers: [{ key: "x-added", value: "config" }] }], i18nConfig: null, @@ -226,7 +226,7 @@ describe("finalizeAppRscResponse — basePath stripping before pattern matching" // ── request context snapshot ──────────────────────────────────────────── describe("finalizeAppRscResponse — has/missing conditions use original request context", () => { - it("applies header only when has-condition matches the provided request context", () => { + it("applies header only when has-condition matches the provided request context", async () => { // Behavior: config header with has[type=header] applies only when the original request // carries the expected header. The requestContext is the pre-middleware snapshot. // Regression: header applied unconditionally (requestContext ignored). @@ -234,7 +234,7 @@ describe("finalizeAppRscResponse — has/missing conditions use original request const request = new Request("http://example.com/about"); const reqCtxWithFlag = makeRequestContext(new Headers({ "x-preview": "1" })); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [ { @@ -250,13 +250,13 @@ describe("finalizeAppRscResponse — has/missing conditions use original request expect(response.headers.get("x-conditional")).toBe("yes"); }); - it("does not apply header when has-condition does not match the request context", () => { + it("does not apply header when has-condition does not match the request context", async () => { // Behavior: header skipped when the has-condition fails for the original request. // Regression: expected "yes" to be null — condition bypassed. const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [ { @@ -276,7 +276,7 @@ describe("finalizeAppRscResponse — has/missing conditions use original request // ── default-locale path normalisation (issue #1336, item 4) ──────────── describe("finalizeAppRscResponse — default-locale path normalisation", () => { - it("matches a config header rule with a :locale placeholder against an unprefixed request", () => { + it("matches a config header rule with a :locale placeholder against an unprefixed request", async () => { // Behavior: a header rule sourced at "/:locale/about" must match a request to // "/about" when the i18n default locale is "en", because Next.js splices the // default locale into unprefixed paths before config header matching. @@ -284,7 +284,7 @@ describe("finalizeAppRscResponse — default-locale path normalisation", () => { const response = new Response("body", { status: 200 }); const request = new Request("http://example.com/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [ { source: "/:locale/about", headers: [{ key: "x-localized", value: "yes" }] }, @@ -296,7 +296,7 @@ describe("finalizeAppRscResponse — default-locale path normalisation", () => { expect(response.headers.get("x-localized")).toBe("yes"); }); - it("matches a domain-mapped default locale, not the global one, when the host matches", () => { + it("matches a domain-mapped default locale, not the global one, when the host matches", async () => { // Behavior: when the request host matches a domain entry, that domain's // defaultLocale wins over the global default. A rule for "/:locale/about" // on example.fr (defaultLocale "fr") must match "/about" by treating it @@ -304,7 +304,7 @@ describe("finalizeAppRscResponse — default-locale path normalisation", () => { const response = new Response("body", { status: 200 }); const request = new Request("http://example.fr/about"); - finalizeAppRscResponse(response, request, { + await finalizeAppRscResponse(response, request, { basePath: "", configHeaders: [ { source: "/fr/about", headers: [{ key: "x-fr", value: "yes" }] }, diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 92213bdb6..be277eb3d 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -116,6 +116,9 @@ describe("clientManualChunks", () => { expect(appClientManualChunks("/vinext/shims/link.js")).toBeUndefined(); expect(appClientManualChunks("/vinext/shims/router.ts")).toBeUndefined(); expect(appClientManualChunks("/vinext/shims/image.tsx?client")).toBeUndefined(); + expect( + appClientManualChunks("/vinext/shims/internal/hybrid-client-route-owner.js"), + ).toBeUndefined(); expect(appClientManualChunks("/vinext/shims/legacy-image.tsx")).toBeUndefined(); expect(appClientManualChunks("/vinext/shims/layout-segment-context.js")).toBeUndefined(); expect(appClientManualChunks("/vinext/shims/web-vitals.ts")).toBeUndefined(); diff --git a/tests/fixtures/middleware-matcher-auth/app/admin/secrets/page.tsx b/tests/fixtures/middleware-matcher-auth/app/admin/secrets/page.tsx new file mode 100644 index 000000000..620b0e8c9 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/admin/secrets/page.tsx @@ -0,0 +1,3 @@ +export default function AdminSecretsPage() { + return
admin secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/archive/[date]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/archive/[date]/page.tsx new file mode 100644 index 000000000..f1cf1053c --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/archive/[date]/page.tsx @@ -0,0 +1,3 @@ +export default function ArchivePage() { + return
archive secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/bar/secret/page.tsx b/tests/fixtures/middleware-matcher-auth/app/bar/secret/page.tsx new file mode 100644 index 000000000..d2c2d8d3d --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/bar/secret/page.tsx @@ -0,0 +1,3 @@ +export default function MixedGroupPage() { + return
mixed group secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/bracket-shorthand/[value]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/bracket-shorthand/[value]/page.tsx new file mode 100644 index 000000000..27059eeb8 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/bracket-shorthand/[value]/page.tsx @@ -0,0 +1,3 @@ +export default function BracketShorthandPage() { + return
bracket shorthand secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/codes/[value]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/codes/[value]/page.tsx new file mode 100644 index 000000000..5dc59c3f9 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/codes/[value]/page.tsx @@ -0,0 +1,3 @@ +export default function CodesPage() { + return
codes secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/conditioned/page.tsx b/tests/fixtures/middleware-matcher-auth/app/conditioned/page.tsx new file mode 100644 index 000000000..c6adaa3da --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/conditioned/page.tsx @@ -0,0 +1,3 @@ +export default function ConditionedPage() { + return
conditioned page
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/dashboard/users/page.tsx b/tests/fixtures/middleware-matcher-auth/app/dashboard/users/page.tsx new file mode 100644 index 000000000..c5afba725 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/dashboard/users/page.tsx @@ -0,0 +1,3 @@ +export default function DashboardUsersPage() { + return
dashboard users secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/de/page.tsx b/tests/fixtures/middleware-matcher-auth/app/de/page.tsx new file mode 100644 index 000000000..d3dd202fc --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/de/page.tsx @@ -0,0 +1,3 @@ +export default function GermanPage() { + return
german locale secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/docs/[[...lang]]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/docs/[[...lang]]/page.tsx new file mode 100644 index 000000000..d3ac57892 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/docs/[[...lang]]/page.tsx @@ -0,0 +1,3 @@ +export default function DocsPage() { + return
docs secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/en/profile/page.tsx b/tests/fixtures/middleware-matcher-auth/app/en/profile/page.tsx new file mode 100644 index 000000000..0a21ef38f --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/en/profile/page.tsx @@ -0,0 +1,3 @@ +export default function EnglishProfilePage() { + return
english profile secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/layout.tsx b/tests/fixtures/middleware-matcher-auth/app/layout.tsx new file mode 100644 index 000000000..f3ef34cd8 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/middleware-matcher-auth/app/manual/[...lang]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/manual/[...lang]/page.tsx new file mode 100644 index 000000000..c6e43d147 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/manual/[...lang]/page.tsx @@ -0,0 +1,3 @@ +export default function ManualPage() { + return
manual secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/mixed/[value]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/mixed/[value]/page.tsx new file mode 100644 index 000000000..52808b8ec --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/mixed/[value]/page.tsx @@ -0,0 +1,3 @@ +export default function MixedClassPage() { + return
mixed class secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/public/page.tsx b/tests/fixtures/middleware-matcher-auth/app/public/page.tsx new file mode 100644 index 000000000..9ed6396f0 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/public/page.tsx @@ -0,0 +1,3 @@ +export default function PublicPage() { + return
public page
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/report.json/page.tsx b/tests/fixtures/middleware-matcher-auth/app/report.json/page.tsx new file mode 100644 index 000000000..8247f61f6 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/report.json/page.tsx @@ -0,0 +1,3 @@ +export default function ReportPage() { + return
report secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/shared/[value]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/shared/[value]/page.tsx new file mode 100644 index 000000000..349f5d47e --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/shared/[value]/page.tsx @@ -0,0 +1,3 @@ +export default function SharedPrefixPage() { + return
shared prefix secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/app/shorthand/[value]/page.tsx b/tests/fixtures/middleware-matcher-auth/app/shorthand/[value]/page.tsx new file mode 100644 index 000000000..781f7620e --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/app/shorthand/[value]/page.tsx @@ -0,0 +1,3 @@ +export default function ShorthandPage() { + return
shorthand secret
; +} diff --git a/tests/fixtures/middleware-matcher-auth/middleware.ts b/tests/fixtures/middleware-matcher-auth/middleware.ts new file mode 100644 index 000000000..752fdfcf8 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/middleware.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from "next/server"; + +export function middleware(_request: NextRequest) { + return new Response("blocked by middleware", { + status: 403, + headers: { "x-auth-guard": "blocked" }, + }); +} + +export const config = { + matcher: [ + "/(admin|dashboard)/:path*", + "/", + "/(de|en)/:path*", + "/docs/:lang(en|fr)*", + "/manual/:lang(en|fr)+", + "/(foo.*|bar)/:path*", + "/report{.:ext}", + "/archive/:date(\\d{4}(?:-\\d{2}){2})", + "/codes/:value((?:[A-Z]{2})+)", + "/shared/:value((?:ab|ac)+)", + "/mixed/:value((?:[a-z]|[0-9])+)", + "/shorthand/:value((?:\\d|[a-z])+)", + "/bracket-shorthand/:value((?:[\\d]|[a-z])+)", + { + source: "/conditioned", + has: [ + { type: "query", key: "role", value: "admin" }, + { type: "query", key: "present", value: "" }, + { type: "header", key: "x-present", value: "" }, + { type: "cookie", key: "session", value: "" }, + { type: "host", value: "" }, + ], + missing: [{ type: "query", key: "blocked", value: "1" }], + }, + ], +}; diff --git a/tests/fixtures/middleware-matcher-auth/package.json b/tests/fixtures/middleware-matcher-auth/package.json new file mode 100644 index 000000000..d4d192b8e --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/package.json @@ -0,0 +1,5 @@ +{ + "name": "vinext-middleware-matcher-auth-fixture", + "private": true, + "type": "module" +} diff --git a/tests/fixtures/middleware-matcher-auth/wrangler.jsonc b/tests/fixtures/middleware-matcher-auth/wrangler.jsonc new file mode 100644 index 000000000..ed4bb2487 --- /dev/null +++ b/tests/fixtures/middleware-matcher-auth/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "name": "vinext-middleware-matcher-auth-fixture", + "compatibility_date": "2026-04-01", + "compatibility_flags": ["nodejs_compat"], + "main": "vinext/server/fetch-handler", + "assets": { + "not_found_handling": "none", + "binding": "ASSETS", + }, +} diff --git a/tests/fixtures/middleware-matcher-redos-child.ts b/tests/fixtures/middleware-matcher-redos-child.ts new file mode 100644 index 000000000..232862e80 --- /dev/null +++ b/tests/fixtures/middleware-matcher-redos-child.ts @@ -0,0 +1,81 @@ +import { performance } from "node:perf_hooks"; +import { matchPattern } from "../../packages/vinext/src/server/middleware-matcher.ts"; +import { analyzeRegexSafety } from "../../packages/vinext/src/utils/regex-safety.ts"; + +const nearMiss = `/${"a/".repeat(2_000)}not-end`; +for (const modifier of ["*", "+"]) { + // lgtm[js/redos] — deliberately hostile matcher executed in a timed child. + const matcher = `/:path(.*)${modifier}/end`; + if (!matchPattern(nearMiss, matcher)) { + throw new Error(`Unsafe matcher did not fail closed: ${matcher}`); + } +} + +// Sequential repetitions that can consume the same text can also produce +// catastrophic backtracking without a quantifier directly wrapping a group. +const overlappingRepetition = "/:path(a+.*a+)"; +if (!matchPattern(`/${"a".repeat(3_000)}b`, overlappingRepetition)) { + throw new Error(`Unsafe matcher did not fail closed: ${overlappingRepetition}`); +} + +// Alternations where one branch prefixes another have exponentially many +// partitions under an unbounded group repetition. +const ambiguousAlternative = "/:path((?:a|aa)+)"; +if (!matchPattern(`/${"a".repeat(3_000)}b`, ambiguousAlternative)) { + throw new Error(`Unsafe matcher did not fail closed: ${ambiguousAlternative}`); +} + +for (const matcher of ["/:path((?:a+){10})", "/:path((?:a|A)+)"]) { + if (!matchPattern(`/${"a".repeat(3_000)}b`, matcher)) { + throw new Error(`Unsafe matcher did not fail closed: ${matcher}`); + } +} + +for (const matcher of [ + ...[6, 7, 8, 9, 10].map((count) => `/:path(${"(?:a+)".repeat(count)})`), + ...[6, 7, 8].map((count) => `/:path(${"(?:a+(?:))".repeat(count)})`), + ...[6, 8].map((count) => `/:path(${"(?:a+){1}".repeat(count)})`), + ...[6, 8].map((count) => `/:path(${"(?:a+){1,1}".repeat(count)})`), + `/:path(${"(?:a+){1}?".repeat(6)})`, + ...[6, 8].map((count) => `/:path(${"(?:(?:a+){1}){1,1}".repeat(count)})`), + `/:path(${"(?:a|aa)".repeat(26)})`, +]) { + if (!matchPattern(`/${"a".repeat(3_000)}b`, matcher)) { + throw new Error(`Unsafe bounded sequence did not fail closed: ${matcher}`); + } +} + +// A single overlapping boundary is kept for common two-repeat matchers, and +// non-overlapping repetitions do not compound the partition search. +for (const pattern of [ + "(?:a+)(?:a+)", + "(?:a+(?:))(?:a+(?:))", + "(?:a+){1}(?:a+){1,1}", + "(?:a+){1}?(?:a+){1}", + "(?:a+)(?:b+)(?:a+)", + "(?:a+(?:))(?:b+(?:))(?:a+(?:))", + "(?:(?:a+){1}){1,1}(?:b+){1}(?:(?:a+){1,1}){1}", + "[^/]+.*", +]) { + const issue = analyzeRegexSafety(pattern, { ignoreCase: true }); + if (issue) throw new Error(`Safe sequence was rejected: ${pattern} (${issue})`); +} +if (!matchPattern(`/${"a".repeat(3_000)}`, "/:path((?:a+)(?:a+))")) { + throw new Error("Safe two-repeat matcher did not match"); +} +if (matchPattern(`/${"a".repeat(3_000)}b`, "/:path((?:a+)(?:a+))")) { + throw new Error("Safe two-repeat matcher matched a near miss"); +} + +// Keep the analysis itself linear for large, disjoint literal alternations. +// CJK literals have stable, distinct non-Unicode ignore-case canonical forms. +const alternatives = Array.from({ length: 2_000 }, (_, index) => + String.fromCharCode(0x4e00 + index), +).join("|"); +const analysisStart = performance.now(); +const analysisIssue = analyzeRegexSafety(`(?:${alternatives})+`, { ignoreCase: true }); +const analysisDuration = performance.now() - analysisStart; +if (analysisIssue) throw new Error(`Safe large alternation was rejected: ${analysisIssue}`); +if (analysisDuration > 1_000) { + throw new Error(`Large alternation analysis took ${analysisDuration.toFixed(1)}ms`); +} diff --git a/tests/middleware-matcher-auth.test.ts b/tests/middleware-matcher-auth.test.ts new file mode 100644 index 000000000..dc51987b5 --- /dev/null +++ b/tests/middleware-matcher-auth.test.ts @@ -0,0 +1,380 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import type http from "node:http"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { createBuilder, type ViteDevServer } from "vite"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; +import { createIsolatedFixture, startFixtureServer } from "./helpers.js"; + +const FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/middleware-matcher-auth"); +const REDOS_CHILD = path.resolve( + import.meta.dirname, + "./fixtures/middleware-matcher-redos-child.ts", +); +const WORKSPACE_ROOT = path.resolve(import.meta.dirname, ".."); +const CLOUDFLARE_NODE_MODULES = path.resolve( + import.meta.dirname, + "./fixtures/cf-app-basic/node_modules", +); +const execFileAsync = promisify(execFile); + +const PROTECTED_PATHS = [ + "/", + "/admin/secrets", + "/dashboard/users", + "/de", + "/en/profile", + "/docs", + "/docs/en", + "/docs/en/fr", + "/manual/en", + "/manual/en/fr", + "/bar/secret", + "/report.json", + "/archive/2024-07-10", + "/codes/ABCD", + "/shared/abac", + "/mixed/a1z9", + "/shorthand/a1z9", + "/bracket-shorthand/a1z9", +] as const; + +async function assertAuthGuard(baseUrl: string): Promise { + const protectedResponses = await Promise.all( + PROTECTED_PATHS.map(async (pathname) => { + const response = await fetch(`${baseUrl}${pathname}`); + return { + pathname, + status: response.status, + guard: response.headers.get("x-auth-guard"), + body: await response.text(), + }; + }), + ); + expect(protectedResponses.map(({ pathname, status }) => ({ pathname, status }))).toEqual( + PROTECTED_PATHS.map((pathname) => ({ pathname, status: 403 })), + ); + for (const { pathname, guard, body } of protectedResponses) { + expect(guard, pathname).toBe("blocked"); + expect(body, pathname).toBe("blocked by middleware"); + } + + const publicResponse = await fetch(`${baseUrl}/public`); + const publicBody = await publicResponse.text(); + expect(publicResponse.status, publicBody).toBe(200); + expect(publicResponse.headers.get("x-auth-guard")).toBeNull(); + expect(publicBody).toContain("public page"); + + const constrainedMiss = await fetch(`${baseUrl}/manual/de`); + const constrainedMissBody = await constrainedMiss.text(); + expect(constrainedMiss.status, constrainedMissBody).toBe(200); + expect(constrainedMiss.headers.get("x-auth-guard")).toBeNull(); + expect(constrainedMissBody).toContain("manual secret"); + + const conditionedHeaders = { + "x-present": "yes", + cookie: "session=active", + }; + const conditioned = await fetch( + `${baseUrl}/conditioned?role=guest&role=admin&present=yes&present=&blocked=1&blocked=0`, + { headers: conditionedHeaders }, + ); + expect(conditioned.status, await conditioned.text()).toBe(403); + expect(conditioned.headers.get("x-auth-guard")).toBe("blocked"); + + const wrongLastHas = await fetch( + `${baseUrl}/conditioned?role=admin&role=guest&present=yes&present=&blocked=1&blocked=0`, + { headers: conditionedHeaders }, + ); + const wrongLastHasBody = await wrongLastHas.text(); + expect(wrongLastHas.status, wrongLastHasBody).toBe(200); + expect(wrongLastHas.headers.get("x-auth-guard")).toBeNull(); + expect(wrongLastHasBody).toContain("conditioned page"); + + const wrongLastMissing = await fetch( + `${baseUrl}/conditioned?role=guest&role=admin&present=yes&present=&blocked=0&blocked=1`, + { headers: conditionedHeaders }, + ); + const wrongLastMissingBody = await wrongLastMissing.text(); + expect(wrongLastMissing.status, wrongLastMissingBody).toBe(200); + expect(wrongLastMissing.headers.get("x-auth-guard")).toBeNull(); + expect(wrongLastMissingBody).toContain("conditioned page"); +} + +async function closeHttpServer(server: http.Server | undefined): Promise { + if (!server) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function writeMatcherFixture(root: string, matcher: string): Promise { + await fs.writeFile( + path.join(root, "middleware.ts"), + `export function middleware() { return new Response("blocked", { status: 403 }) } +export const config = { matcher: ${JSON.stringify(matcher)} } +`, + ); +} + +async function findTsxCli(): Promise { + const pnpmStore = path.join(WORKSPACE_ROOT, "node_modules/.pnpm"); + const entry = (await fs.readdir(pnpmStore)).find((name) => name.startsWith("tsx@")); + if (!entry) throw new Error("tsx is not installed in the workspace dependency store"); + return path.join(pnpmStore, entry, "node_modules/tsx/dist/cli.mjs"); +} + +describe("valid middleware matcher auth guards", () => { + describe("development server", () => { + let root = ""; + let server: ViteDevServer | undefined; + let baseUrl = ""; + + beforeAll(async () => { + root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-dev-", + (source) => path.basename(source) !== "wrangler.jsonc", + ); + ({ server, baseUrl } = await startFixtureServer(root)); + }, 30_000); + + afterAll(async () => { + await server?.close(); + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + + it("blocks every path selected by group and constrained-repeat matchers", async () => { + await assertAuthGuard(baseUrl); + }); + }); + + describe("built Node production server", () => { + let root = ""; + let server: http.Server | undefined; + let baseUrl = ""; + + beforeAll(async () => { + root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-node-", + (source) => path.basename(source) !== "wrangler.jsonc", + ); + const builder = await createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root })], + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const started = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: path.join(root, "dist"), + noCompression: true, + }); + server = started.server; + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Middleware matcher Node fixture did not bind to a port"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }, 120_000); + + afterAll(async () => { + await closeHttpServer(server); + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + + it("blocks every path selected by group and constrained-repeat matchers", async () => { + await assertAuthGuard(baseUrl); + }); + }); + + describe("built Cloudflare Worker", () => { + let root = ""; + let worker: { url: Promise; dispose(): Promise } | undefined; + let baseUrl = ""; + + beforeAll(async () => { + root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-worker-", + undefined, + CLOUDFLARE_NODE_MODULES, + ); + const cloudflarePluginPath = path.join( + root, + "node_modules/@cloudflare/vite-plugin/dist/index.mjs", + ); + const { cloudflare } = (await import(pathToFileURL(cloudflarePluginPath).href)) as { + cloudflare: (options: { + viteEnvironment: { name: string; childEnvironments: string[] }; + }) => import("vite").Plugin; + }; + const builder = await createBuilder({ + root, + configFile: false, + plugins: [ + vinext({ appDir: root }), + cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }), + ], + logLevel: "silent", + }); + await builder.buildApp(); + + const wranglerPath = path.join(root, "node_modules/wrangler/wrangler-dist/cli.js"); + const wrangler = (await import(pathToFileURL(wranglerPath).href)) as { + unstable_startWorker(options: { + config: string; + dev: { + remote: false; + persist: false; + logLevel: "none"; + watch: false; + server: { port: 0 }; + }; + }): Promise<{ url: Promise; dispose(): Promise }>; + }; + worker = await wrangler.unstable_startWorker({ + config: path.join(root, "dist/server/wrangler.json"), + dev: { + remote: false, + persist: false, + logLevel: "none", + watch: false, + server: { port: 0 }, + }, + }); + await worker.url; + baseUrl = (await worker.url).origin; + }, 180_000); + + afterAll(async () => { + await worker?.dispose(); + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + + it("blocks every path selected by group and constrained-repeat matchers", async () => { + await assertAuthGuard(baseUrl); + }); + }); +}); + +describe("unsafe middleware matcher rejection", () => { + it.each([ + ["/:path(.*)*/end", /may match an empty value or path delimiter/], + ["/:path(.*)+/end", /may match an empty value or path delimiter/], + ["/:path((?:a+)+)", /contains nested repetition/], + ["/:path((?:a|aa)+)", /contains ambiguous alternatives under repetition/], + ["/:path((?:a|A)+)", /contains ambiguous alternatives under repetition/], + ["/:path((?:a+){10})", /contains nested repetition/], + [`/:path(${"(?:a+)".repeat(6)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+)".repeat(7)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+(?:))".repeat(6)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+(?:))".repeat(7)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+(?:))".repeat(8)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+){1}".repeat(6)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+){1}".repeat(8)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+){1,1}".repeat(6)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:(?:a+){1}){1,1}".repeat(6)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+)".repeat(8)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+)".repeat(9)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a+)".repeat(10)})`, /contains overlapping sequential repetition/], + [`/:path(${"(?:a|aa)".repeat(26)})`, /contains ambiguous sequence expansion/], + ["/:path(a+.*a+)", /contains overlapping sequential repetition/], + ["/:path(a+(?:b*)a+)", /contains overlapping sequential repetition/], + ] as const)( + "rejects unsafe matcher %s during the build config phase", + async (matcher, reason) => { + const root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-unsafe-build-", + (source) => path.basename(source) !== "wrangler.jsonc", + ); + try { + await writeMatcherFixture(root, matcher); + await expect( + createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root })], + logLevel: "silent", + }), + ).rejects.toThrow(reason); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + ); + + it("rejects an ambiguous constrained repeat during dev config processing", async () => { + const root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-unsafe-dev-", + (source) => path.basename(source) !== "wrangler.jsonc", + ); + try { + await writeMatcherFixture(root, "/:path(.*)*/end"); + await expect(startFixtureServer(root)).rejects.toThrow( + /Invalid middleware matcher.*may match an empty value or path delimiter/, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("fails closed on ambiguous repeats without catastrophic backtracking", async () => { + const tsxCli = await findTsxCli(); + await expect( + execFileAsync(process.execPath, [tsxCli, REDOS_CHILD], { + cwd: WORKSPACE_ROOT, + timeout: 5_000, + }), + ).resolves.toMatchObject({ + stderr: expect.stringContaining("Middleware will run for all paths"), + stdout: "", + }); + }); +}); + +describe("invalid middleware matcher object auth guards", () => { + it("rejects an object matcher with an unsupported field before production build", async () => { + const root = await createIsolatedFixture( + FIXTURE_DIR, + "vinext-middleware-matcher-invalid-object-", + (source) => path.basename(source) !== "wrangler.jsonc", + ); + try { + await fs.writeFile( + path.join(root, "middleware.ts"), + `export function middleware() { + return new Response("blocked by middleware", { + status: 403, + headers: { "x-auth-guard": "blocked" }, + }) +} +export const config = { + matcher: [{ source: "/admin/:path*", typo: true }], +} +`, + ); + + await expect( + createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root })], + logLevel: "silent", + }), + ).rejects.toThrow(/matcher object contains unsupported field "typo"/); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/request-pipeline.test.ts b/tests/request-pipeline.test.ts index 28988713e..3feece25c 100644 --- a/tests/request-pipeline.test.ts +++ b/tests/request-pipeline.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect } from "vite-plus/test"; import { - applyConfigHeadersToHeaderRecord, - applyConfigHeadersToResponse, canonicalizeRequestPathname, canonicalizeRequestUrlPathname, cloneRequestWithHeaders, @@ -20,6 +18,10 @@ import { processMiddlewareHeaders, VINEXT_INTERNAL_HEADERS, } from "../packages/vinext/src/server/request-pipeline.js"; +import { + applyConfigHeadersToHeaderRecord, + applyConfigHeadersToResponse, +} from "../packages/vinext/src/server/config-headers.js"; import { VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c579035fb..69383959e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test"; import { spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -7474,6 +7475,59 @@ describe("middleware/proxy export validation", () => { // matchPattern / matchesMiddleware unit tests describe("middleware matcher patterns", () => { + it("matches Next.js 16.2.7 path-to-regexp tokens and compiled regexes", async () => { + const { compileMiddlewareMatcherPattern } = + await import("../packages/vinext/src/server/middleware-matcher-pattern.js"); + const { parseMiddlewarePath } = + await import("../packages/vinext/src/server/middleware-path-to-regexp.js"); + type Token = ReturnType[number]; + const nextPathToRegexp = createRequire(import.meta.url)( + "next/dist/compiled/path-to-regexp", + ) as { + parse(source: string): Token[]; + tokensToRegexp(tokens: Token[]): RegExp; + }; + const patterns = [ + "/foo\\:bar", + "/(foo.*|bar)/:path*", + "/foo/:id(a\\)b)?", + "/:id((?:foo|bar)-\\d+)?", + "/docs/:lang(en|fr)*", + "/cdn/:path*(api|static)", + "/foo-:path*", + "/foo{/:id}?", + "/files/:name{.:ext}?", + "/foo{-:bar}+", + "/x/:id(a+(?:b)a+)", + "/archive/:date(\\d{4}(?:-\\d{2}){2})", + ]; + + for (const pattern of patterns) { + const nextTokens = nextPathToRegexp.parse(pattern); + expect(parseMiddlewarePath(pattern), pattern).toEqual(nextTokens); + + let nextRegexp: RegExp; + try { + nextRegexp = nextPathToRegexp.tokensToRegexp(nextTokens); + } catch { + nextRegexp = nextPathToRegexp.tokensToRegexp( + nextTokens.map((token) => + typeof token === "object" && + (token.modifier === "*" || token.modifier === "+") && + token.prefix === "" && + token.suffix === "" + ? { ...token, prefix: "/" } + : token, + ), + ); + } + + const compiled = compileMiddlewareMatcherPattern(pattern); + expect(compiled.regexp?.source, pattern).toBe(nextRegexp.source); + expect(compiled.regexp?.flags, pattern).toBe(nextRegexp.flags); + } + }); + it("matchPattern: exact path match", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); expect(matchPattern("/about", "/about")).toBe(true); @@ -7510,6 +7564,150 @@ describe("middleware matcher patterns", () => { expect(matchPattern("/de/about", "/:locale(en|es|fr)?/about")).toBe(false); }); + it("matchPattern: accepts bounded nested repetition", async () => { + const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); + // Mirrors Next.js/path-to-regexp: finite repetition inside a finite group + // is not an unbounded backtracking risk. + const matcher = "/archive/:date(\\d{4}(?:-\\d{2}){2})"; + expect(matchPattern("/archive/2024-07-10", matcher)).toBe(true); + expect(matchPattern("/archive/2024-7-10", matcher)).toBe(false); + expect(matchPattern("/archive/2024-07-10-11", matcher)).toBe(false); + }); + + it("rejects prefix-ambiguous alternatives under repetition", async () => { + const { compileMiddlewareMatcherPattern } = + await import("../packages/vinext/src/server/middleware-matcher-pattern.js"); + + for (const matcher of [ + "/:path((?:a|aa)+)", + "/:path((?:aa|a)*)", + "/:path((?:[a]|aa){1,})", + "/:path((?:a|aa){2})", + "/:path((?:a|A)+)", + "/:path((?:é|É)+)", + ]) { + expect(compileMiddlewareMatcherPattern(matcher), matcher).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("ambiguous alternatives"), + }); + } + + expect(compileMiddlewareMatcherPattern("/:path((?:foo|bar)+)").regexp).toBeInstanceOf(RegExp); + expect(compileMiddlewareMatcherPattern("/:path((?:ab|ac)+)").regexp).toBeInstanceOf(RegExp); + expect(compileMiddlewareMatcherPattern("/:path((?:ab|ac){10})").regexp).toBeInstanceOf(RegExp); + // Non-Unicode ECMAScript ignore-case canonicalization deliberately does + // not fold a non-ASCII character to ASCII. + expect(compileMiddlewareMatcherPattern("/:path((?:k|K)+)").regexp).toBeInstanceOf(RegExp); + }); + + it("handles fixed-width nested repeats and simple class intersections", async () => { + const { compileMiddlewareMatcherPattern } = + await import("../packages/vinext/src/server/middleware-matcher-pattern.js"); + + for (const matcher of ["/codes/:value((?:[A-Z]{2})+)", "/mixed/:value((?:[a-z]|[0-9])+)"]) { + expect(compileMiddlewareMatcherPattern(matcher).regexp, matcher).toBeInstanceOf(RegExp); + } + + for (const matcher of [ + "/shorthand/:value((?:\\d|[a-z])+)", + "/bracket-shorthand/:value((?:[\\d]|[a-z])+)", + "/space-or-word/:value((?:\\s|[a-z])+)", + "/digit-or-not/:value((?:\\d|\\D)+)", + "/two-repeats/:value((?:a+)(?:a+))", + "/wrapped-two-repeats/:value((?:a+(?:))(?:a+(?:)))", + "/exact-one-two-repeats/:value((?:a+){1}(?:a+){1,1})", + "/disjoint-repeats/:value((?:a+)(?:b+)(?:a+))", + "/wrapped-disjoint-repeats/:value((?:a+(?:))(?:b+(?:))(?:a+(?:)))", + "/exact-one-disjoint-repeats/:value((?:(?:a+){1}){1,1}(?:b+){1}(?:(?:a+){1,1}){1})", + ]) { + expect(compileMiddlewareMatcherPattern(matcher).regexp, matcher).toBeInstanceOf(RegExp); + } + + for (const matcher of [ + "/:path((?:[a-z]|[A-Z])+)", + "/:path((?:[a-f]|[d-z])+)", + "/:path((?:[a-z]|m)+)", + "/:path((?:\\w|[a-z])+)", + "/:path((?:[\\w]|[A-Z])+)", + "/:path((?:\\D|[a-z])+)", + ]) { + expect(compileMiddlewareMatcherPattern(matcher), matcher).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("ambiguous alternatives"), + }); + } + }); + + it("rejects bounded sequence-level ambiguity within the analysis budget", async () => { + const { compileMiddlewareMatcherPattern } = + await import("../packages/vinext/src/server/middleware-matcher-pattern.js"); + const expandingChoices = `/:path(${"(?:a|aa)".repeat(26)})`; + + for (const count of [3, 6, 7, 8, 9, 10]) { + const adjacentRepetitions = `/:path(${"(?:a+)".repeat(count)})`; + expect( + compileMiddlewareMatcherPattern(adjacentRepetitions), + `${count} repeats`, + ).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("overlapping sequential repetition"), + }); + } + for (const count of [3, 6, 7, 8]) { + const wrappedRepetitions = `/:path(${"(?:a+(?:))".repeat(count)})`; + expect( + compileMiddlewareMatcherPattern(wrappedRepetitions), + `${count} wrapped repeats`, + ).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("overlapping sequential repetition"), + }); + } + for (const wrapper of ["(?:a+){1}", "(?:a+){1,1}", "(?:a+){1}?", "(?:(?:a+){1}){1,1}"]) { + const exactOneRepetitions = `/:path(${wrapper.repeat(6)})`; + expect(compileMiddlewareMatcherPattern(exactOneRepetitions), wrapper).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("overlapping sequential repetition"), + }); + } + expect(compileMiddlewareMatcherPattern(expandingChoices)).toMatchObject({ + kind: "unsafe", + error: expect.stringContaining("ambiguous sequence expansion"), + }); + }); + + // Ported from Next.js path-to-regexp matcher semantics: + // packages/next/src/lib/try-to-parse-path.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/try-to-parse-path.ts + it("matchPattern: unnamed regex groups compose with named params", async () => { + const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); + const pattern = "/(admin|dashboard)/:path*"; + + expect(matchPattern("/admin", pattern)).toBe(true); + expect(matchPattern("/admin/secrets", pattern)).toBe(true); + expect(matchPattern("/dashboard/users/active", pattern)).toBe(true); + expect(matchPattern("/public", pattern)).toBe(false); + }); + + // path-to-regexp applies a modifier after a constraint to the entire + // slash-prefixed param and permits repeated constraint-matching segments. + it("matchPattern: constrained params repeat across path segments", async () => { + const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); + + const zeroOrMore = "/docs/:lang(en|fr)*"; + expect(matchPattern("/docs", zeroOrMore)).toBe(true); + expect(matchPattern("/docs/en", zeroOrMore)).toBe(true); + expect(matchPattern("/docs/en/fr", zeroOrMore)).toBe(true); + expect(matchPattern("/docs/de", zeroOrMore)).toBe(false); + expect(matchPattern("/docs/en/de", zeroOrMore)).toBe(false); + + const oneOrMore = "/docs/:lang(en|fr)+"; + expect(matchPattern("/docs", oneOrMore)).toBe(false); + expect(matchPattern("/docs/en", oneOrMore)).toBe(true); + expect(matchPattern("/docs/en/fr", oneOrMore)).toBe(true); + expect(matchPattern("/docs/de", oneOrMore)).toBe(false); + }); + it("matchPattern: wildcard (:path*) matches zero or more segments", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); expect(matchPattern("/dashboard", "/dashboard/:path*")).toBe(true); @@ -7518,21 +7716,48 @@ describe("middleware matcher patterns", () => { expect(matchPattern("/other", "/dashboard/:path*")).toBe(false); }); - it("matchPattern: :param*(constraint) and :param+(constraint)", async () => { + it("matchPattern: a group after :param* is a separate suffix token", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); - // /:path*(api|static) — optional segment constrained to api or static - expect(matchPattern("/cdn", "/cdn/:path*(api|static)")).toBe(true); - expect(matchPattern("/cdn/api", "/cdn/:path*(api|static)")).toBe(true); - expect(matchPattern("/cdn/static", "/cdn/:path*(api|static)")).toBe(true); + // path-to-regexp parses this as a repeated `path` param followed by an + // unnamed `(api|static)` suffix. A constrained repeat is written with the + // modifier after the group: `:path(api|static)*`. + expect(matchPattern("/cdn", "/cdn/:path*(api|static)")).toBe(false); + expect(matchPattern("/cdnapi", "/cdn/:path*(api|static)")).toBe(true); + expect(matchPattern("/cdn/fooapi", "/cdn/:path*(api|static)")).toBe(true); + expect(matchPattern("/cdn/api", "/cdn/:path*(api|static)")).toBe(false); expect(matchPattern("/cdn/other", "/cdn/:path*(api|static)")).toBe(false); - // /:path+(api|static) — required segment constrained + // `+` requires the path token, so the adjacent zero-segment form differs. expect(matchPattern("/cdn", "/cdn/:path+(api|static)")).toBe(false); - expect(matchPattern("/cdn/api", "/cdn/:path+(api|static)")).toBe(true); - expect(matchPattern("/cdn/static", "/cdn/:path+(api|static)")).toBe(true); + expect(matchPattern("/cdnapi", "/cdn/:path+(api|static)")).toBe(false); + expect(matchPattern("/cdn/fooapi", "/cdn/:path+(api|static)")).toBe(true); + expect(matchPattern("/cdn/api", "/cdn/:path+(api|static)")).toBe(false); expect(matchPattern("/cdn/other", "/cdn/:path+(api|static)")).toBe(false); }); + it("matchPattern: preserves escaped and nested path-to-regexp syntax", async () => { + const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); + + expect(matchPattern("/foo:bar", "/foo\\:bar")).toBe(true); + expect(matchPattern("/foo/bar", "/foo\\:bar")).toBe(false); + + const mixed = "/(foo.*|bar)/:path*"; + expect(matchPattern("/foo-value/child", mixed)).toBe(true); + expect(matchPattern("/bar", mixed)).toBe(true); + expect(matchPattern("/baz/child", mixed)).toBe(false); + + const escapedClose = "/foo/:id(a\\)b)?"; + expect(matchPattern("/foo", escapedClose)).toBe(true); + expect(matchPattern("/foo/a)b", escapedClose)).toBe(true); + expect(matchPattern("/foo/ab", escapedClose)).toBe(false); + + const nested = "/:id((?:foo|bar)-\\d+)?"; + expect(matchPattern("/", nested)).toBe(true); + expect(matchPattern("/foo-123", nested)).toBe(true); + expect(matchPattern("/bar-9", nested)).toBe(true); + expect(matchPattern("/baz-9", nested)).toBe(false); + }); + it("matchPattern: one-or-more (:path+) requires at least one segment", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); expect(matchPattern("/api/users", "/api/:path+")).toBe(true); @@ -7718,14 +7943,14 @@ describe("middleware matcher patterns", () => { ).toBe(false); }); - it("matchesMiddleware: rejects a single object matcher config", async () => { + it("matchesMiddleware: fails closed for a single object matcher config", async () => { const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js"); const matcher: any = { source: "/dashboard", has: [{ type: "header", key: "x-user-tier", value: "pro" }], }; - expect(matchesMiddleware("/dashboard", matcher)).toBe(false); + expect(matchesMiddleware("/dashboard", matcher)).toBe(true); expect( matchesMiddleware( "/dashboard", @@ -7734,15 +7959,15 @@ describe("middleware matcher patterns", () => { headers: { "x-user-tier": "pro" }, }), ), - ).toBe(false); + ).toBe(true); }); - it("matchesMiddleware: rejects object matchers with unsupported fields", async () => { + it("matchesMiddleware: fails closed for object matchers with unsupported fields", async () => { const { matchesMiddleware } = await import("../packages/vinext/src/server/middleware.js"); const matcher: any = [{ source: "/does-not-match", regexp: "^/dashboard(?:/.*)?$" }]; - expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(false); - expect(matchesMiddleware("/about", matcher)).toBe(false); + expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(true); + expect(matchesMiddleware("/about", matcher)).toBe(true); }); it("matchesMiddleware: matches default-locale and locale-prefixed paths unless locale is false", async () => { @@ -7781,17 +8006,17 @@ describe("middleware matcher patterns", () => { it("matchPattern: fails closed for pathological ReDoS patterns", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); - // Pathological pattern: (a+)+ causes catastrophic backtracking + // Pathological constraint: (?:a+)+ causes catastrophic backtracking. // matchPattern must avoid evaluating it and run middleware instead of // silently bypassing a potentially security-sensitive request guard. // lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard - expect(matchPattern("/aaaaaaaaaaaaaaaaaaaac", "(a+)+b")).toBe(true); + expect(matchPattern("/aaaaaaaaaaaaaaaaaaaac", "/:path((?:a+)+)")).toBe(true); }); - it("matchPattern: does not run globally for malformed regex syntax", async () => { + it("matchPattern: fails closed for malformed matcher syntax", async () => { const { matchPattern } = await import("../packages/vinext/src/server/middleware.js"); - expect(matchPattern("/public", "/admin(")).toBe(false); + expect(matchPattern("/public", "/admin(")).toBe(true); expect(matchPattern("/admin(", "/admin(")).toBe(true); }); }); @@ -10807,6 +11032,14 @@ describe("isSafeRegex", () => { expect(isSafeRegex("(\\d{2,4})")).toBe(true); }); + it("accepts only fixed-width nested bounded repetition", async () => { + const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js"); + expect(isSafeRegex("\\d{4}(?:-\\d{2}){2}")).toBe(true); + expect(isSafeRegex("(?:a{2}){3}")).toBe(true); + expect(isSafeRegex("(?:a{1,2}){3}")).toBe(false); + expect(isSafeRegex("(?:a{1,2}){2,4}")).toBe(false); + }); + it("rejects nested quantifiers: (a+)+", async () => { const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js"); expect(isSafeRegex("(a+)+")).toBe(false); @@ -10837,11 +11070,20 @@ describe("isSafeRegex", () => { expect(isSafeRegex("(a+){2,}")).toBe(false); }); + it("rejects bounded outer repetition around an unbounded inner atom", async () => { + const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js"); + expect(isSafeRegex("(a+){2}")).toBe(false); + expect(isSafeRegex("(a+){2,4}")).toBe(false); + expect(isSafeRegex("(a+){10}")).toBe(false); + }); + it("accepts quantifier on group without inner quantifier", async () => { const { isSafeRegex } = await import("../packages/vinext/src/config/config-matchers.js"); // (ab)+ is fine — no inner quantifier expect(isSafeRegex("(ab)+")).toBe(true); expect(isSafeRegex("(foo|bar)*")).toBe(true); + expect(isSafeRegex("(?:(?!\\.)[^/])+?")).toBe(true); + expect(isSafeRegex("[^/]+.*")).toBe(true); }); it("treats escaped characters as safe", async () => { @@ -10905,6 +11147,12 @@ describe("safeRegExp", () => { expect(re).toBeNull(); }); + it("uses the final ignore-case semantics for ambiguous alternatives", async () => { + const { safeRegExp } = await import("../packages/vinext/src/config/config-matchers.js"); + expect(safeRegExp("(?:a|A)+", "i")).toBeNull(); + expect(safeRegExp("(?:ab|ac)+", "i")).toBeInstanceOf(RegExp); + }); + it("returns null for invalid regex syntax", async () => { const { safeRegExp } = await import("../packages/vinext/src/config/config-matchers.js"); const re = safeRegExp("(?P"); @@ -11427,6 +11675,64 @@ describe("checkHasConditions", () => { ).toBe(false); }); + // Ported from Next.js matchHas, which uses `value.slice(-1)[0]` when the + // parsed query contains duplicate keys. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/prepare-destination.ts + it("uses the last duplicate query value for has and missing", async () => { + const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js"); + const ctx = { + ...makeCtx(), + query: new URLSearchParams("role=guest&role=admin&blocked=1&blocked=0"), + }; + + expect( + checkHasConditions([{ type: "query", key: "role", value: "admin" }], undefined, ctx), + ).toBe(true); + expect( + checkHasConditions([{ type: "query", key: "role", value: "guest" }], undefined, ctx), + ).toBe(false); + expect( + checkHasConditions(undefined, [{ type: "query", key: "blocked", value: "1" }], ctx), + ).toBe(true); + expect( + checkHasConditions(undefined, [{ type: "query", key: "blocked", value: "0" }], ctx), + ).toBe(false); + }); + + it('treats value: "" as presence-only for every condition type', async () => { + const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js"); + const ctx = makeCtx({ + headers: { "x-present": "yes" }, + cookies: { session: "active" }, + query: { preview: "enabled" }, + host: "example.com", + }); + const present = [ + { type: "header" as const, key: "x-present", value: "" }, + { type: "cookie" as const, key: "session", value: "" }, + { type: "query" as const, key: "preview", value: "" }, + { type: "host" as const, key: "", value: "" }, + ]; + + expect(checkHasConditions(present, undefined, ctx)).toBe(true); + for (const condition of present) { + expect(checkHasConditions(undefined, [condition], ctx), condition.type).toBe(false); + } + + expect( + checkHasConditions([{ type: "query", key: "empty", value: "" }], undefined, { + ...ctx, + query: new URLSearchParams("empty="), + }), + ).toBe(false); + expect( + checkHasConditions([{ type: "query", key: "present", value: "" }], undefined, { + ...ctx, + query: new URLSearchParams("present=yes&present="), + }), + ).toBe(true); + }); + // -- host conditions -- it("has host: matches exact value", async () => { const { checkHasConditions } = await import("../packages/vinext/src/config/config-matchers.js");