Skip to content
13 changes: 12 additions & 1 deletion packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 {
Expand Down
136 changes: 23 additions & 113 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} 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";

/**
* Cache for compiled regex patterns in matchConfigPattern.
Expand Down Expand Up @@ -265,115 +266,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;
}

/**
Expand All @@ -383,11 +285,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;
}
Expand Down Expand Up @@ -619,7 +521,9 @@ function _matchConditionValue(
actualValue: string,
expectedValue: string | undefined,
): Record<string, string> | 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) {
Expand Down Expand Up @@ -658,9 +562,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);
Expand Down
13 changes: 12 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -85,6 +89,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 {
isNextDataPathname,
normalizeNextDataPagePathname,
Expand Down Expand Up @@ -2009,6 +2014,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,
);
Expand Down
Loading
Loading