vinext 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/cli.js +4 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/entry.js +1 -15
  4. package/dist/client/entry.js.map +1 -1
  5. package/dist/client/validate-module-path.d.ts +15 -0
  6. package/dist/client/validate-module-path.d.ts.map +1 -0
  7. package/dist/client/validate-module-path.js +31 -0
  8. package/dist/client/validate-module-path.js.map +1 -0
  9. package/dist/config/config-matchers.d.ts +20 -0
  10. package/dist/config/config-matchers.d.ts.map +1 -1
  11. package/dist/config/config-matchers.js +185 -36
  12. package/dist/config/config-matchers.js.map +1 -1
  13. package/dist/config/next-config.d.ts +4 -0
  14. package/dist/config/next-config.d.ts.map +1 -1
  15. package/dist/config/next-config.js.map +1 -1
  16. package/dist/deploy.d.ts.map +1 -1
  17. package/dist/deploy.js +20 -12
  18. package/dist/deploy.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +173 -155
  21. package/dist/index.js.map +1 -1
  22. package/dist/server/api-handler.d.ts.map +1 -1
  23. package/dist/server/api-handler.js +2 -1
  24. package/dist/server/api-handler.js.map +1 -1
  25. package/dist/server/app-dev-server.d.ts +2 -0
  26. package/dist/server/app-dev-server.d.ts.map +1 -1
  27. package/dist/server/app-dev-server.js +305 -159
  28. package/dist/server/app-dev-server.js.map +1 -1
  29. package/dist/server/app-router-entry.d.ts.map +1 -1
  30. package/dist/server/app-router-entry.js +16 -3
  31. package/dist/server/app-router-entry.js.map +1 -1
  32. package/dist/server/dev-origin-check.d.ts +61 -0
  33. package/dist/server/dev-origin-check.d.ts.map +1 -0
  34. package/dist/server/dev-origin-check.js +164 -0
  35. package/dist/server/dev-origin-check.js.map +1 -0
  36. package/dist/server/dev-server.d.ts +0 -2
  37. package/dist/server/dev-server.d.ts.map +1 -1
  38. package/dist/server/dev-server.js +390 -372
  39. package/dist/server/dev-server.js.map +1 -1
  40. package/dist/server/image-optimization.d.ts +32 -2
  41. package/dist/server/image-optimization.d.ts.map +1 -1
  42. package/dist/server/image-optimization.js +110 -9
  43. package/dist/server/image-optimization.js.map +1 -1
  44. package/dist/server/middleware-codegen.d.ts +41 -0
  45. package/dist/server/middleware-codegen.d.ts.map +1 -0
  46. package/dist/server/middleware-codegen.js +187 -0
  47. package/dist/server/middleware-codegen.js.map +1 -0
  48. package/dist/server/middleware.d.ts.map +1 -1
  49. package/dist/server/middleware.js +37 -19
  50. package/dist/server/middleware.js.map +1 -1
  51. package/dist/server/normalize-path.d.ts +22 -0
  52. package/dist/server/normalize-path.d.ts.map +1 -0
  53. package/dist/server/normalize-path.js +50 -0
  54. package/dist/server/normalize-path.js.map +1 -0
  55. package/dist/server/prod-server.d.ts.map +1 -1
  56. package/dist/server/prod-server.js +95 -26
  57. package/dist/server/prod-server.js.map +1 -1
  58. package/dist/shims/cache-runtime.d.ts +7 -0
  59. package/dist/shims/cache-runtime.d.ts.map +1 -1
  60. package/dist/shims/cache-runtime.js +19 -15
  61. package/dist/shims/cache-runtime.js.map +1 -1
  62. package/dist/shims/cache.d.ts +8 -0
  63. package/dist/shims/cache.d.ts.map +1 -1
  64. package/dist/shims/cache.js +20 -15
  65. package/dist/shims/cache.js.map +1 -1
  66. package/dist/shims/fetch-cache.d.ts +2 -3
  67. package/dist/shims/fetch-cache.d.ts.map +1 -1
  68. package/dist/shims/fetch-cache.js +80 -9
  69. package/dist/shims/fetch-cache.js.map +1 -1
  70. package/dist/shims/head-state.d.ts +6 -1
  71. package/dist/shims/head-state.d.ts.map +1 -1
  72. package/dist/shims/head-state.js +18 -15
  73. package/dist/shims/head-state.js.map +1 -1
  74. package/dist/shims/head.d.ts.map +1 -1
  75. package/dist/shims/head.js +4 -1
  76. package/dist/shims/head.js.map +1 -1
  77. package/dist/shims/headers.d.ts +9 -13
  78. package/dist/shims/headers.d.ts.map +1 -1
  79. package/dist/shims/headers.js +30 -49
  80. package/dist/shims/headers.js.map +1 -1
  81. package/dist/shims/image.d.ts.map +1 -1
  82. package/dist/shims/image.js +11 -2
  83. package/dist/shims/image.js.map +1 -1
  84. package/dist/shims/navigation-state.d.ts +6 -1
  85. package/dist/shims/navigation-state.d.ts.map +1 -1
  86. package/dist/shims/navigation-state.js +20 -29
  87. package/dist/shims/navigation-state.js.map +1 -1
  88. package/dist/shims/navigation.js +2 -2
  89. package/dist/shims/navigation.js.map +1 -1
  90. package/dist/shims/router-state.d.ts +6 -1
  91. package/dist/shims/router-state.d.ts.map +1 -1
  92. package/dist/shims/router-state.js +16 -21
  93. package/dist/shims/router-state.js.map +1 -1
  94. package/dist/shims/router.d.ts.map +1 -1
  95. package/dist/shims/router.js +19 -6
  96. package/dist/shims/router.js.map +1 -1
  97. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,8 +6,11 @@ import { handleApiRoute } from "./server/api-handler.js";
6
6
  import { generateRscEntry, generateSsrEntry, generateBrowserEntry, } from "./server/app-dev-server.js";
7
7
  import { loadNextConfig, resolveNextConfig, } from "./config/next-config.js";
8
8
  import { findMiddlewareFile, runMiddleware } from "./server/middleware.js";
9
+ import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "./server/middleware-codegen.js";
10
+ import { normalizePath } from "./server/normalize-path.js";
9
11
  import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js";
10
- import { safeRegExp, isExternalUrl, proxyExternalRequest } from "./config/config-matchers.js";
12
+ import { validateDevRequest } from "./server/dev-origin-check.js";
13
+ import { safeRegExp, escapeHeaderSource, isExternalUrl, proxyExternalRequest } from "./config/config-matchers.js";
11
14
  import { scanMetadataFiles } from "./server/metadata-routes.js";
12
15
  import tsconfigPaths from "vite-tsconfig-paths";
13
16
  import MagicString from "magic-string";
@@ -559,103 +562,10 @@ import { NextRequest } from "next/server";`
559
562
  // We inline the matching + execution logic so the prod server can call it.
560
563
  const middlewareExportCode = middlewarePath
561
564
  ? `
562
- // --- Middleware support ---
563
- function matchesMiddleware(pathname, matcher) {
564
- if (!matcher) {
565
- return !pathname.startsWith("/_next") && !pathname.startsWith("/api") && !pathname.includes(".") && pathname !== "/favicon.ico";
566
- }
567
- var patterns = [];
568
- if (typeof matcher === "string") { patterns.push(matcher); }
569
- else if (Array.isArray(matcher)) {
570
- for (var m of matcher) {
571
- if (typeof m === "string") patterns.push(m);
572
- else if (m && typeof m === "object" && "source" in m) patterns.push(m.source);
573
- }
574
- }
575
- return patterns.some(function(p) { return matchMiddlewarePattern(pathname, p); });
576
- }
577
-
578
- function __isSafeRegex(pattern) {
579
- var quantifierAtDepth = [];
580
- var depth = 0;
581
- var i = 0;
582
- while (i < pattern.length) {
583
- var ch = pattern[i];
584
- if (ch === "\\\\") { i += 2; continue; }
585
- if (ch === "[") {
586
- i++;
587
- while (i < pattern.length && pattern[i] !== "]") {
588
- if (pattern[i] === "\\\\") i++;
589
- i++;
590
- }
591
- i++;
592
- continue;
593
- }
594
- if (ch === "(") {
595
- depth++;
596
- if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false);
597
- else quantifierAtDepth[depth] = false;
598
- i++;
599
- continue;
600
- }
601
- if (ch === ")") {
602
- var hadQ = depth > 0 && quantifierAtDepth[depth];
603
- if (depth > 0) depth--;
604
- var next = pattern[i + 1];
605
- if (next === "+" || next === "*" || next === "{") {
606
- if (hadQ) return false;
607
- if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true;
608
- }
609
- i++;
610
- continue;
611
- }
612
- if (ch === "+" || ch === "*") {
613
- if (depth > 0) quantifierAtDepth[depth] = true;
614
- i++;
615
- continue;
616
- }
617
- if (ch === "?") {
618
- var prev = i > 0 ? pattern[i - 1] : "";
619
- if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") {
620
- if (depth > 0) quantifierAtDepth[depth] = true;
621
- }
622
- i++;
623
- continue;
624
- }
625
- if (ch === "{") {
626
- var j = i + 1;
627
- while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++;
628
- if (j < pattern.length && pattern[j] === "}" && j > i + 1) {
629
- if (depth > 0) quantifierAtDepth[depth] = true;
630
- i = j + 1;
631
- continue;
632
- }
633
- }
634
- i++;
635
- }
636
- return true;
637
- }
638
- function __safeRegExp(pattern, flags) {
639
- if (!__isSafeRegex(pattern)) {
640
- console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern);
641
- return null;
642
- }
643
- try { return new RegExp(pattern, flags); } catch { return null; }
644
- }
645
-
646
- function matchMiddlewarePattern(pathname, pattern) {
647
- if (pattern.includes("(") || pattern.includes("\\\\")) {
648
- var re = __safeRegExp("^" + pattern + "$");
649
- if (re) return re.test(pathname);
650
- }
651
- var regexStr = pattern
652
- .replace(/\\./g, "\\\\.")
653
- .replace(/\\/:([\\w]+)\\*/g, "(?:/.*)?")
654
- .replace(/\\/:([\\w]+)\\+/g, "(?:/.+)")
655
- .replace(/:([\\w]+)/g, "([^/]+)");
656
- var re2 = __safeRegExp("^" + regexStr + "$");
657
- return re2 ? re2.test(pathname) : pathname === pattern;
658
- }
565
+ // --- Middleware support (generated from middleware-codegen.ts) ---
566
+ ${generateNormalizePathCode("es5")}
567
+ ${generateSafeRegExpCode("es5")}
568
+ ${generateMiddlewareMatcherCode("es5")}
659
569
 
660
570
  export async function runMiddleware(request) {
661
571
  var middlewareFn = middlewareModule.default || middlewareModule.middleware;
@@ -665,7 +575,11 @@ export async function runMiddleware(request) {
665
575
  var matcher = config && config.matcher;
666
576
  var url = new URL(request.url);
667
577
 
668
- if (!matchesMiddleware(url.pathname, matcher)) return { continue: true };
578
+ // Normalize pathname before matching to prevent path-confusion bypasses
579
+ // (percent-encoding like /%61dmin, double slashes like /dashboard//settings).
580
+ var normalizedPathname = __normalizePath(decodeURIComponent(url.pathname));
581
+
582
+ if (!matchesMiddleware(normalizedPathname, matcher)) return { continue: true };
669
583
 
670
584
  var nextRequest = request instanceof NextRequest ? request : new NextRequest(request);
671
585
  var response;
@@ -715,7 +629,11 @@ import { resetSSRHead, getSSRHeadHTML } from "next/head";
715
629
  import { flushPreloads } from "next/dynamic";
716
630
  import { setSSRContext } from "next/router";
717
631
  import { getCacheHandler } from "next/cache";
718
- import { withFetchCache } from "vinext/fetch-cache";
632
+ import { runWithFetchCache } from "vinext/fetch-cache";
633
+ import { _runWithCacheState } from "next/cache";
634
+ import { runWithPrivateCache } from "vinext/cache-runtime";
635
+ import { runWithRouterState } from "vinext/router-state";
636
+ import { runWithHeadState } from "vinext/head-state";
719
637
  import { safeJsonStringify } from "vinext/html";
720
638
  import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
721
639
  import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
@@ -1126,7 +1044,11 @@ export async function renderPage(request, url, manifest) {
1126
1044
  }
1127
1045
 
1128
1046
  const { route, params } = match;
1129
- const cleanupFetchCache = withFetchCache();
1047
+ return runWithRouterState(() =>
1048
+ runWithHeadState(() =>
1049
+ _runWithCacheState(() =>
1050
+ runWithPrivateCache(() =>
1051
+ runWithFetchCache(async () => {
1130
1052
  try {
1131
1053
  if (typeof setSSRContext === "function") {
1132
1054
  setSSRContext({
@@ -1193,7 +1115,7 @@ export async function renderPage(request, url, manifest) {
1193
1115
  if (result && result.props) pageProps = result.props;
1194
1116
  if (result && result.redirect) {
1195
1117
  var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
1196
- return new Response(null, { status: gsspStatus, headers: { Location: result.redirect.destination } });
1118
+ return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
1197
1119
  }
1198
1120
  if (result && result.notFound) {
1199
1121
  return new Response("404", { status: 404 });
@@ -1252,7 +1174,7 @@ export async function renderPage(request, url, manifest) {
1252
1174
  if (result && result.props) pageProps = result.props;
1253
1175
  if (result && result.redirect) {
1254
1176
  var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
1255
- return new Response(null, { status: gspStatus, headers: { Location: result.redirect.destination } });
1177
+ return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
1256
1178
  }
1257
1179
  if (result && result.notFound) {
1258
1180
  return new Response("404", { status: 404 });
@@ -1385,9 +1307,12 @@ export async function renderPage(request, url, manifest) {
1385
1307
  } catch (e) {
1386
1308
  console.error("[vinext] SSR error:", e);
1387
1309
  return new Response("Internal Server Error", { status: 500 });
1388
- } finally {
1389
- cleanupFetchCache();
1390
1310
  }
1311
+ }) // end runWithFetchCache
1312
+ ) // end runWithPrivateCache
1313
+ ) // end _runWithCacheState
1314
+ ) // end runWithHeadState
1315
+ ); // end runWithRouterState
1391
1316
  }
1392
1317
 
1393
1318
  export async function handleApiRoute(request, url) {
@@ -1470,6 +1395,9 @@ ${middlewareExportCode}
1470
1395
  const loaderEntries = pageRoutes.map((r) => {
1471
1396
  const absPath = r.filePath.replace(/\\/g, "/");
1472
1397
  const nextFormatPattern = pagesPatternToNextFormat(r.pattern);
1398
+ // JSON.stringify safely escapes quotes, backslashes, and special chars in
1399
+ // both the route pattern and the absolute file path.
1400
+ // lgtm[js/bad-code-sanitization]
1473
1401
  return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`;
1474
1402
  });
1475
1403
  const appFileBase = path.join(pagesDir, "_app").replace(/\\/g, "/");
@@ -1638,6 +1566,15 @@ hydrate();
1638
1566
  // Expose image remote patterns for validation in next/image shim
1639
1567
  defines["process.env.__VINEXT_IMAGE_REMOTE_PATTERNS"] = JSON.stringify(JSON.stringify(nextConfig.images?.remotePatterns ?? []));
1640
1568
  defines["process.env.__VINEXT_IMAGE_DOMAINS"] = JSON.stringify(JSON.stringify(nextConfig.images?.domains ?? []));
1569
+ // Expose allowed image widths (union of deviceSizes + imageSizes) for
1570
+ // server-side validation. Matches Next.js behavior: only configured
1571
+ // sizes are accepted by the image optimization endpoint.
1572
+ {
1573
+ const deviceSizes = nextConfig.images?.deviceSizes ?? [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
1574
+ const imageSizes = nextConfig.images?.imageSizes ?? [16, 32, 48, 64, 96, 128, 256, 384];
1575
+ defines["process.env.__VINEXT_IMAGE_DEVICE_SIZES"] = JSON.stringify(JSON.stringify(deviceSizes));
1576
+ defines["process.env.__VINEXT_IMAGE_SIZES"] = JSON.stringify(JSON.stringify(imageSizes));
1577
+ }
1641
1578
  // Draft mode secret — generated once at build time so the
1642
1579
  // __prerender_bypass cookie is consistent across all server
1643
1580
  // instances (e.g. multiple Cloudflare Workers isolates).
@@ -1812,7 +1749,15 @@ hydrate();
1812
1749
  // route handlers so they can set the Allow header and run user-defined
1813
1750
  // OPTIONS handlers. Without this, Vite's CORS middleware responds to
1814
1751
  // OPTIONS with a 204 before the request reaches vinext's handler.
1815
- server: { cors: { preflightContinue: true } },
1752
+ // Keep Vite's default restrictive origin policy by explicitly
1753
+ // setting it. Without the `origin` field, `preflightContinue: true`
1754
+ // would override Vite's default and allow any origin.
1755
+ server: {
1756
+ cors: {
1757
+ preflightContinue: true,
1758
+ origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/,
1759
+ },
1760
+ },
1816
1761
  // Externalize React packages from SSR transform — they are CJS and
1817
1762
  // must be loaded natively by Node, not through Vite's ESM evaluator.
1818
1763
  // Skip when targeting Cloudflare Workers (they bundle everything).
@@ -2049,6 +1994,7 @@ hydrate();
2049
1994
  rewrites: nextConfig?.rewrites,
2050
1995
  headers: nextConfig?.headers,
2051
1996
  allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
1997
+ allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins,
2052
1998
  });
2053
1999
  }
2054
2000
  if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
@@ -2170,16 +2116,42 @@ hydrate();
2170
2116
  if (url.split("?")[0].endsWith(".rsc")) {
2171
2117
  return next();
2172
2118
  }
2119
+ // ── Cross-origin request protection ─────────────────────────
2120
+ // Block requests from non-localhost origins to prevent
2121
+ // cross-origin data exfiltration from the dev server.
2122
+ const blockReason = validateDevRequest({
2123
+ origin: req.headers.origin,
2124
+ host: req.headers.host,
2125
+ "x-forwarded-host": req.headers["x-forwarded-host"],
2126
+ "sec-fetch-site": req.headers["sec-fetch-site"],
2127
+ "sec-fetch-mode": req.headers["sec-fetch-mode"],
2128
+ }, nextConfig?.serverActionsAllowedOrigins);
2129
+ if (blockReason) {
2130
+ console.warn(`[vinext] Blocked dev request: ${blockReason} (${url})`);
2131
+ res.writeHead(403, { "Content-Type": "text/plain" });
2132
+ res.end("Forbidden");
2133
+ return;
2134
+ }
2173
2135
  // ── Image optimization passthrough (dev mode) ─────────────
2174
2136
  // In dev, redirect to the original asset URL so Vite serves it.
2175
2137
  if (url.split("?")[0] === "/_vinext/image") {
2176
2138
  const imgParams = new URLSearchParams(url.split("?")[1] ?? "");
2177
- const imgUrl = imgParams.get("url");
2139
+ const rawImgUrl = imgParams.get("url");
2140
+ // Normalize backslashes: browsers and the URL constructor treat
2141
+ // /\evil.com as //evil.com, bypassing the // check.
2142
+ const imgUrl = rawImgUrl?.replaceAll("\\", "/") ?? null;
2178
2143
  // Allowlist: must start with "/" but not "//" — blocks absolute
2179
- // URLs, protocol-relative, and exotic schemes (data:, javascript:, etc.).
2144
+ // URLs, protocol-relative, backslash variants, and exotic schemes.
2180
2145
  if (!imgUrl || !imgUrl.startsWith("/") || imgUrl.startsWith("//")) {
2181
2146
  res.writeHead(400);
2182
- res.end(!imgUrl ? "Missing url parameter" : "Only relative URLs allowed");
2147
+ res.end(!rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed");
2148
+ return;
2149
+ }
2150
+ // Validate the constructed URL's origin hasn't changed (defense in depth).
2151
+ const resolvedImg = new URL(imgUrl, `http://${req.headers.host || "localhost"}`);
2152
+ if (resolvedImg.origin !== `http://${req.headers.host || "localhost"}`) {
2153
+ res.writeHead(400);
2154
+ res.end("Only relative URLs allowed");
2183
2155
  return;
2184
2156
  }
2185
2157
  res.writeHead(302, { Location: imgUrl });
@@ -2201,16 +2173,20 @@ hydrate();
2201
2173
  if (pathname.includes(".") && !pathname.endsWith(".html")) {
2202
2174
  return next();
2203
2175
  }
2204
- // Guard against protocol-relative URL open redirect attacks.
2205
- // Paths like //example.com/ would be redirected to //example.com
2206
- // by the trailing-slash normalizer, which browsers interpret as
2207
- // http://example.com an open redirect. Next.js returns 404 for
2208
- // double-slash paths.
2176
+ // Guard against protocol-relative URL open redirects.
2177
+ // Normalize backslashes first: browsers treat /\ as // in URL
2178
+ // context. Check the RAW pathname before normalizePath so the
2179
+ // guard fires before normalizePath collapses //.
2180
+ pathname = pathname.replaceAll("\\", "/");
2209
2181
  if (pathname.startsWith("//")) {
2210
2182
  res.writeHead(404);
2211
2183
  res.end("404 Not Found");
2212
2184
  return;
2213
2185
  }
2186
+ // Normalize the pathname to prevent path-confusion attacks.
2187
+ // decodeURIComponent prevents /%61dmin bypassing /admin matchers.
2188
+ // normalizePath collapses // and resolves . / .. segments.
2189
+ pathname = normalizePath(decodeURIComponent(pathname));
2214
2190
  // Strip basePath prefix from URL for route matching.
2215
2191
  // All internal routing uses basePath-free paths.
2216
2192
  //
@@ -3017,6 +2993,29 @@ function getNextPublicEnvDefines() {
3017
2993
  }
3018
2994
  return defines;
3019
2995
  }
2996
+ /**
2997
+ * If the current position in `str` starts with a parenthesized group, consume
2998
+ * it and advance `re.lastIndex` past the closing `)`. Returns the group
2999
+ * contents or null if no group is present.
3000
+ */
3001
+ function extractConstraint(str, re) {
3002
+ if (str[re.lastIndex] !== "(")
3003
+ return null;
3004
+ const start = re.lastIndex + 1;
3005
+ let depth = 1;
3006
+ let i = start;
3007
+ while (i < str.length && depth > 0) {
3008
+ if (str[i] === "(")
3009
+ depth++;
3010
+ else if (str[i] === ")")
3011
+ depth--;
3012
+ i++;
3013
+ }
3014
+ if (depth !== 0)
3015
+ return null;
3016
+ re.lastIndex = i;
3017
+ return str.slice(start, i - 1);
3018
+ }
3020
3019
  /**
3021
3020
  * Match a Next.js route pattern (e.g. "/blog/:slug", "/docs/:path*") against a pathname.
3022
3021
  * Returns matched params or null.
@@ -3043,28 +3042,44 @@ export function matchConfigPattern(pathname, pattern) {
3043
3042
  // :param* -> (.*)
3044
3043
  // :param+ -> (.+)
3045
3044
  const paramNames = [];
3046
- const regexStr = pattern
3047
- .replace(/\./g, "\\.")
3048
- // :param* with optional constraint
3049
- .replace(/:(\w+)\*(?:\(([^)]+)\))?/g, (_m, name, constraint) => {
3050
- paramNames.push(name);
3051
- return constraint ? `(${constraint})` : "(.*)";
3052
- })
3053
- // :param+ with optional constraint
3054
- .replace(/:(\w+)\+(?:\(([^)]+)\))?/g, (_m, name, constraint) => {
3055
- paramNames.push(name);
3056
- return constraint ? `(${constraint})` : "(.+)";
3057
- })
3058
- // :param(constraint) named param with inline regex constraint
3059
- .replace(/:(\w+)\(([^)]+)\)/g, (_m, name, constraint) => {
3060
- paramNames.push(name);
3061
- return `(${constraint})`;
3062
- })
3063
- // :param plain named param
3064
- .replace(/:(\w+)/g, (_m, name) => {
3065
- paramNames.push(name);
3066
- return "([^/]+)";
3067
- });
3045
+ // Single-pass conversion with procedural suffix handling. The tokenizer
3046
+ // matches only simple, non-overlapping tokens; quantifier/constraint
3047
+ // suffixes after :param are consumed procedurally to avoid polynomial
3048
+ // backtracking in the regex engine.
3049
+ let regexStr = "";
3050
+ const tokenRe = /:(\w+)|[.]|[^:.]+/g; // lgtm[js/redos] — alternatives are non-overlapping (`:` and `.` excluded from `[^:.]+`)
3051
+ let tok;
3052
+ while ((tok = tokenRe.exec(pattern)) !== null) {
3053
+ if (tok[1] !== undefined) {
3054
+ const name = tok[1];
3055
+ const rest = pattern.slice(tokenRe.lastIndex);
3056
+ // Check for quantifier (* or +) with optional constraint
3057
+ if (rest.startsWith("*") || rest.startsWith("+")) {
3058
+ const quantifier = rest[0];
3059
+ tokenRe.lastIndex += 1;
3060
+ const constraint = extractConstraint(pattern, tokenRe);
3061
+ paramNames.push(name);
3062
+ if (constraint !== null) {
3063
+ regexStr += `(${constraint})`;
3064
+ }
3065
+ else {
3066
+ regexStr += quantifier === "*" ? "(.*)" : "(.+)";
3067
+ }
3068
+ }
3069
+ else {
3070
+ // Check for inline constraint without quantifier
3071
+ const constraint = extractConstraint(pattern, tokenRe);
3072
+ paramNames.push(name);
3073
+ regexStr += constraint !== null ? `(${constraint})` : "([^/]+)";
3074
+ }
3075
+ }
3076
+ else if (tok[0] === ".") {
3077
+ regexStr += "\\.";
3078
+ }
3079
+ else {
3080
+ regexStr += tok[0];
3081
+ }
3082
+ }
3068
3083
  const re = safeRegExp("^" + regexStr + "$");
3069
3084
  if (!re)
3070
3085
  return null;
@@ -3094,7 +3109,12 @@ export function matchConfigPattern(pathname, pattern) {
3094
3109
  if (isPlus && (!rest || rest === "/"))
3095
3110
  return null;
3096
3111
  // For :path* zero segments is fine
3097
- return { [paramName]: rest.startsWith("/") ? rest.slice(1) : rest };
3112
+ let restValue = rest.startsWith("/") ? rest.slice(1) : rest;
3113
+ try {
3114
+ restValue = decodeURIComponent(restValue);
3115
+ }
3116
+ catch { /* malformed percent-encoding */ }
3117
+ return { [paramName]: restValue };
3098
3118
  }
3099
3119
  // Simple segment-based matching for exact patterns and :param
3100
3120
  const parts = pattern.split("/");
@@ -3112,6 +3132,17 @@ export function matchConfigPattern(pathname, pattern) {
3112
3132
  }
3113
3133
  return params;
3114
3134
  }
3135
+ /**
3136
+ * Sanitize a redirect/rewrite destination by collapsing leading slashes and
3137
+ * backslashes to a single "/" for non-external URLs. Browsers interpret "\"
3138
+ * as "/" in URL contexts, so "\/evil.com" becomes "//evil.com" (protocol-relative).
3139
+ */
3140
+ function sanitizeDestinationLocal(dest) {
3141
+ if (dest.startsWith("http://") || dest.startsWith("https://"))
3142
+ return dest;
3143
+ dest = dest.replace(/^[\\/]+/, "/");
3144
+ return dest;
3145
+ }
3115
3146
  /**
3116
3147
  * Apply redirect rules from next.config.js.
3117
3148
  * Returns true if a redirect was applied.
@@ -3126,6 +3157,8 @@ function applyRedirects(pathname, res, redirects) {
3126
3157
  dest = dest.replace(`:${key}+`, value);
3127
3158
  dest = dest.replace(`:${key}`, value);
3128
3159
  }
3160
+ // Sanitize to prevent open redirect via protocol-relative URLs
3161
+ dest = sanitizeDestinationLocal(dest);
3129
3162
  res.writeHead(redirect.permanent ? 308 : 307, { Location: dest });
3130
3163
  res.end();
3131
3164
  return true;
@@ -3206,6 +3239,8 @@ function applyRewrites(pathname, rewrites) {
3206
3239
  dest = dest.replace(`:${key}+`, value);
3207
3240
  dest = dest.replace(`:${key}`, value);
3208
3241
  }
3242
+ // Sanitize to prevent open redirect via protocol-relative URLs
3243
+ dest = sanitizeDestinationLocal(dest);
3209
3244
  return dest;
3210
3245
  }
3211
3246
  }
@@ -3216,24 +3251,7 @@ function applyRewrites(pathname, rewrites) {
3216
3251
  */
3217
3252
  function applyHeaders(pathname, res, headers) {
3218
3253
  for (const rule of headers) {
3219
- // Escape regex metacharacters in the source, then convert Next.js patterns.
3220
- // Strategy: extract regex groups first, process the rest, then restore groups.
3221
- const groups = [];
3222
- const withPlaceholders = rule.source.replace(/\(([^)]+)\)/g, (_m, inner) => {
3223
- groups.push(inner);
3224
- return `___GROUP_${groups.length - 1}___`;
3225
- });
3226
- const escaped = withPlaceholders
3227
- // Escape dots and other metacharacters
3228
- .replace(/\./g, "\\.")
3229
- .replace(/\+/g, "\\+")
3230
- .replace(/\?/g, "\\?")
3231
- // Convert glob * to .*
3232
- .replace(/\*/g, ".*")
3233
- // Convert :param to [^/]+
3234
- .replace(/:\w+/g, "[^/]+")
3235
- // Restore regex groups (contents are untouched)
3236
- .replace(/___GROUP_(\d+)___/g, (_m, idx) => `(${groups[Number(idx)]})`);
3254
+ const escaped = escapeHeaderSource(rule.source);
3237
3255
  const sourceRegex = safeRegExp("^" + escaped + "$");
3238
3256
  if (sourceRegex && sourceRegex.test(pathname)) {
3239
3257
  for (const header of rule.headers) {