From b43709a7f2194b4bd33413d6389a5c9b1e4926cc Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 10:54:12 +0100 Subject: [PATCH 1/4] fix(rewrites): preserve external destination path prefixes --- .../pages-router-cloudflare/next.config.mjs | 7 ++- packages/vinext/src/config/config-matchers.ts | 57 ++++++++++++++++--- packages/vinext/src/index.ts | 6 +- packages/vinext/src/server/normalize-path.ts | 11 ++++ packages/vinext/src/server/prod-server.ts | 8 +-- playwright.config.ts | 3 + tests/app-router-production-server.test.ts | 47 +++++++++++++++ tests/e2e/app-router/route-config.spec.ts | 43 ++++++++++++++ .../external-rewrite.spec.ts | 40 +++++++++++++ .../e2e/pages-router-prod/production.spec.ts | 41 +++++++++++++ tests/e2e/pages-router/config.spec.ts | 41 +++++++++++++ tests/fixtures/app-basic/next.config.ts | 7 +++ tests/fixtures/pages-basic/next.config.mjs | 7 +++ tests/shims.test.ts | 57 +++++++++++++++++++ 14 files changed, 359 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/cloudflare-pages-router/external-rewrite.spec.ts diff --git a/examples/pages-router-cloudflare/next.config.mjs b/examples/pages-router-cloudflare/next.config.mjs index 728c3505b7..28a09b88dd 100644 --- a/examples/pages-router-cloudflare/next.config.mjs +++ b/examples/pages-router-cloudflare/next.config.mjs @@ -25,7 +25,12 @@ export default { async rewrites() { return { - beforeFiles: [], + beforeFiles: [ + { + source: "/external-prefix/:path*", + destination: "http://127.0.0.1:4231/v1/:path*", + }, + ], afterFiles: [{ source: "/nav-test", destination: "/about" }], fallback: [], }; diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index a2a8b818c3..acb6dc8da0 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -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 { encodeUrlDotSegment } from "../server/normalize-path.js"; /** * Cache for compiled regex patterns in matchConfigPattern. @@ -1137,7 +1138,11 @@ export function matchesRewriteSource( * Handles repeated params (e.g. `/api/:id/:id`) and catch-all suffix forms * (`:path*`, `:path+`) in a single pass. Unknown params are left intact. */ -function substituteDestinationParams(destination: string, params: Record): string { +function substituteDestinationParams( + destination: string, + params: Record, + encodePathParams = false, +): string { const keys = Object.keys(params); if (keys.length === 0) return destination; @@ -1157,8 +1162,43 @@ function substituteDestinationParams(destination: string, params: Record string): string => - value.replace(paramRe, (_token, key: string) => encodeParam(params[key])); + const replaceParams = ( + value: string, + encodeParam: (value: string, modifier: string | undefined) => string, + ): string => + value.replace(paramRe, (_token, key: string, modifier: string | undefined) => + encodeParam(params[key], modifier), + ); + + const replacePathParams = (value: string): string => { + if (!encodePathParams) return replaceParams(value, (param) => param); + + const replaceAndEncodePathname = (pathname: string): string => + pathname + .split("/") + .flatMap((segment) => { + paramRe.lastIndex = 0; + const hasSubstitution = paramRe.test(segment); + paramRe.lastIndex = 0; + const substituted = replaceParams(segment, (param) => param); + return hasSubstitution ? substituted.split("/").map(encodeUrlDotSegment) : [substituted]; + }) + .join("/"); + + const authorityStart = value.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//)?.[0].length; + if (authorityStart === undefined) { + return replaceAndEncodePathname(value); + } + + const pathnameStart = value.indexOf("/", authorityStart); + if (pathnameStart === -1) return replaceParams(value, (param) => param); + + const authority = value.slice(0, pathnameStart); + return ( + replaceParams(authority, (param) => param) + + replaceAndEncodePathname(value.slice(pathnameStart)) + ); + }; const hashIndex = destination.indexOf("#"); const beforeHash = hashIndex === -1 ? destination : destination.slice(0, hashIndex); @@ -1168,13 +1208,12 @@ function substituteDestinationParams(destination: string, params: Record value)}?${replaceParams( - query, - encodeDestinationQueryParamValue, + return `${replacePathParams(beforeQuery)}?${replaceParams(query, (value) => + encodeDestinationQueryParamValue(value), )}${replaceParams(hash, (value) => value)}`; } - return replaceParams(destination, (value) => value); + return `${replacePathParams(beforeHash)}${replaceParams(hash, (value) => value)}`; } function encodeDestinationQueryParamValue(value: string): string { @@ -1207,7 +1246,9 @@ function substituteAndSanitizeRewriteDestination( destination: string, params: Record, ): string { - const rewritten = substituteAndSanitizeDestination(destination, params); + const rewritten = sanitizeDestination( + substituteDestinationParams(destination, params, isExternalUrl(destination)), + ); if (!shouldAppendRewriteParamsToQuery(destination, params)) return rewritten; const existingQueryKeys = getDestinationQueryKeys(destination); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a5008acbad..06198ac2b5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -99,7 +99,7 @@ import { VINEXT_TIMING_HEADER, } from "./server/headers.js"; import { logRequest, now } from "./server/request-log.js"; -import { normalizePath } from "./server/normalize-path.js"; +import { escapeUrlDotSegments, normalizePath } from "./server/normalize-path.js"; import { filterInternalHeaders, INTERNAL_HEADERS, @@ -4739,7 +4739,7 @@ export const loadServerActionClient = ${ // receives the decoded path for config rule matching. { const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - url = pathname + qs; + url = escapeUrlDotSegments(pathname) + qs; } const capturedMiddlewarePath = middlewarePath; @@ -4757,7 +4757,7 @@ export const loadServerActionClient = ${ if (bp && pathname.startsWith(bp)) { const stripped = pathname.slice(bp.length) || "/"; const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - url = stripped + qs; + url = escapeUrlDotSegments(stripped) + qs; pathname = stripped; } diff --git a/packages/vinext/src/server/normalize-path.ts b/packages/vinext/src/server/normalize-path.ts index 98e31d3ba5..3ab87fa14e 100644 --- a/packages/vinext/src/server/normalize-path.ts +++ b/packages/vinext/src/server/normalize-path.ts @@ -106,3 +106,14 @@ export function normalizePath(pathname: string): string { return "/" + resolved.join("/"); } + +export function encodeUrlDotSegment(value: string): string { + if (/^(?:\.|%2e){1,2}$/i.test(value)) { + return value.replace(/\.|%2e/gi, (dot) => (dot === "." ? "%252e" : dot.replaceAll("%", "%25"))); + } + return value; +} + +export function escapeUrlDotSegments(pathname: string): string { + return pathname.split("/").map(encodeUrlDotSegment).join("/"); +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 9f0cef9e17..b91440737a 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -34,7 +34,7 @@ import { DEFAULT_IMAGE_SIZES, type ImageConfig, } from "./image-optimization.js"; -import { normalizePath } from "./normalize-path.js"; +import { escapeUrlDotSegments, normalizePath } from "./normalize-path.js"; import { filterInternalHeaders, isOpenRedirectShaped } from "./request-pipeline.js"; import { notFoundResponse } from "./http-error-responses.js"; import { @@ -1439,7 +1439,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // RSC handler receives an already-canonical path and doesn't need to // re-normalize. This deduplicates the normalizePath work done above. const qs = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : ""; - const normalizedUrl = pathname + qs; + const normalizedUrl = escapeUrlDotSegments(pathname) + qs; // Convert Node.js request to Web Request and call the RSC handler const request = nodeToWebRequest(req, normalizedUrl, prerenderSecret); @@ -1660,7 +1660,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { res.end("Bad Request"); return; } - let url = pathname + rawQs; + let url = escapeUrlDotSegments(pathname) + rawQs; // Internal prerender endpoint — only reachable with the correct build-time secret. // Used by the prerender phase to fetch getStaticPaths results via HTTP. @@ -1782,7 +1782,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { const stripped = stripBasePath(pathname, basePath); if (stripped !== pathname) { const qs = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - url = stripped + qs; + url = escapeUrlDotSegments(stripped) + qs; pathname = stripped; } } diff --git a/playwright.config.ts b/playwright.config.ts index fbd79ce9bc..78475f0819 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,7 @@ const appRouterBrowserSpecificTests = "**/app-router/**/*.browser.spec.ts"; const appRouterServer = { command: "npx vp dev --port 4174", cwd: "./tests/fixtures/app-basic", + env: { ...process.env, TEST_EXTERNAL_REWRITE_ORIGIN: "http://127.0.0.1:4228" }, port: 4174, reuseExistingServer: !process.env.CI, timeout: 30_000, @@ -37,6 +38,7 @@ const projectServers = { server: { command: "npx vp run vinext#build && npx vp dev --port 4173", cwd: "./tests/fixtures/pages-basic", + env: { ...process.env, TEST_EXTERNAL_REWRITE_ORIGIN: "http://127.0.0.1:4229" }, port: 4173, reuseExistingServer: !process.env.CI, timeout: 30_000, @@ -121,6 +123,7 @@ const projectServers = { command: "npx vp run vinext#build && node ../../../packages/vinext/dist/cli.js build && node ../../../packages/vinext/dist/cli.js start --port 4175", cwd: "./tests/fixtures/pages-basic", + env: { ...process.env, TEST_EXTERNAL_REWRITE_ORIGIN: "http://127.0.0.1:4230" }, port: 4175, reuseExistingServer: !process.env.CI, timeout: 60_000, diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index 600a7f19b3..663a9d428f 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -109,6 +109,35 @@ type StartedTextSequenceTarget = { url: string; }; +async function startPathEchoTarget(): Promise { + const upstream = http.createServer((req, res) => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ pathname: new URL(req.url ?? "/", "http://localhost").pathname })); + }); + + await new Promise((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, "127.0.0.1", () => { + upstream.off("error", reject); + resolve(); + }); + }); + + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("Path echo target did not bind to a TCP port"); + } + + return { + url: `http://127.0.0.1:${address.port}`, + close() { + return new Promise((resolve, reject) => { + upstream.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + async function startTextSequenceTarget(options?: { prefix?: string; recordRequest?: (req: http.IncomingMessage) => void; @@ -212,6 +241,7 @@ describe("App Router Production server (startProdServer)", () => { let appStaticDynamicTarget: StartedTextSequenceTarget | undefined; let appStaticRevalidateTarget: StartedTextSequenceTarget | undefined; let revalidatePathFetchTarget: StartedTextSequenceTarget | undefined; + let externalRewriteTarget: StartedTextSequenceTarget | undefined; function extractRequestId(html: string): string | undefined { return ( @@ -257,6 +287,7 @@ describe("App Router Production server (startProdServer)", () => { } beforeAll(async () => { + externalRewriteTarget = await startPathEchoTarget(); appStaticDelayTarget = await startDelayedJsonSequenceTarget(750); appStaticDynamicTarget = await startTextSequenceTarget({ prefix: "dynamic" }); appStaticRevalidateTarget = await startTextSequenceTarget({ prefix: "revalidate" }); @@ -265,6 +296,7 @@ describe("App Router Production server (startProdServer)", () => { process.env.TEST_APP_STATIC_DYNAMIC_TARGET = appStaticDynamicTarget.url; process.env.TEST_APP_STATIC_REVALIDATE_TARGET = appStaticRevalidateTarget.url; process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET = revalidatePathFetchTarget.url; + process.env.TEST_EXTERNAL_REWRITE_ORIGIN = externalRewriteTarget.url; try { // Build the app-basic fixture to the default dist/ directory @@ -289,15 +321,18 @@ describe("App Router Production server (startProdServer)", () => { await appStaticDynamicTarget?.close(); await appStaticRevalidateTarget?.close(); await revalidatePathFetchTarget?.close(); + await externalRewriteTarget?.close(); } finally { appStaticDelayTarget = undefined; appStaticDynamicTarget = undefined; appStaticRevalidateTarget = undefined; revalidatePathFetchTarget = undefined; + externalRewriteTarget = undefined; delete process.env.TEST_APP_STATIC_DELAY_TARGET; delete process.env.TEST_APP_STATIC_DYNAMIC_TARGET; delete process.env.TEST_APP_STATIC_REVALIDATE_TARGET; delete process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET; + delete process.env.TEST_EXTERNAL_REWRITE_ORIGIN; } throw error; } @@ -309,10 +344,12 @@ describe("App Router Production server (startProdServer)", () => { await appStaticDynamicTarget?.close(); await appStaticRevalidateTarget?.close(); await revalidatePathFetchTarget?.close(); + await externalRewriteTarget?.close(); delete process.env.TEST_APP_STATIC_DELAY_TARGET; delete process.env.TEST_APP_STATIC_DYNAMIC_TARGET; delete process.env.TEST_APP_STATIC_REVALIDATE_TARGET; delete process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET; + delete process.env.TEST_EXTERNAL_REWRITE_ORIGIN; fs.rmSync(outDir, { recursive: true, force: true }); }); @@ -325,6 +362,16 @@ describe("App Router Production server (startProdServer)", () => { expect(html).toContain(" { + for (const pathname of ["%252e%252e", ".%252e", "%252e."]) { + const response = await fetch(`${baseUrl}/external-prefix/${pathname}/outside`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + pathname: expect.stringMatching(/^\/v1(?:\/|$)/), + }); + } + }); + // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-static/app-static.test.ts it("contains dynamic = 'error' failures without terminating later App Router requests", async () => { diff --git a/tests/e2e/app-router/route-config.spec.ts b/tests/e2e/app-router/route-config.spec.ts index 18a14d69af..932123bc89 100644 --- a/tests/e2e/app-router/route-config.spec.ts +++ b/tests/e2e/app-router/route-config.spec.ts @@ -1,7 +1,32 @@ +import { createServer, type Server } from "node:http"; import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4174"; +let externalRewriteServer: Server; + +test.beforeAll(async () => { + externalRewriteServer = createServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + pathname: new URL(request.url ?? "/", "http://127.0.0.1:4228").pathname, + }), + ); + }); + + await new Promise((resolve, reject) => { + externalRewriteServer.once("error", reject); + externalRewriteServer.listen(4228, "127.0.0.1", resolve); + }); +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + externalRewriteServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + test.describe("Route segment configs", () => { test("force-static page renders", async ({ page }) => { const response = await page.goto(`${BASE}/static-test`); @@ -23,4 +48,22 @@ test.describe("Route segment configs", () => { await expect(page.locator("h1")).toHaveText("ISR Revalidate Page"); await expect(page.locator('[data-testid="timestamp"]')).not.toBeEmpty(); }); + + test("external catch-all rewrites preserve their destination path prefix", async ({ + request, + }) => { + const normal = await request.get(`${BASE}/external-prefix/resource`); + expect(normal.status()).toBe(200); + await expect(normal.json()).resolves.toEqual({ pathname: "/v1/resource" }); + + for (const pathname of ["%252e%252e", ".%252e", "%252e."]) { + const traversal = await request.get(`${BASE}/external-prefix/${pathname}/outside`); + expect(traversal.status()).toBe(200); + const traversalResult = (await traversal.json()) as { pathname: string }; + expect(traversalResult.pathname).toMatch(/^\/v1(?:\/|$)/); + } + + const confusedPrefix = await request.get(`${BASE}/external-prefix../outside`); + expect(confusedPrefix.status()).toBe(404); + }); }); diff --git a/tests/e2e/cloudflare-pages-router/external-rewrite.spec.ts b/tests/e2e/cloudflare-pages-router/external-rewrite.spec.ts new file mode 100644 index 0000000000..09b387df13 --- /dev/null +++ b/tests/e2e/cloudflare-pages-router/external-rewrite.spec.ts @@ -0,0 +1,40 @@ +import { createServer, type Server } from "node:http"; +import { expect, test } from "@playwright/test"; + +let externalRewriteServer: Server; + +test.beforeAll(async () => { + externalRewriteServer = createServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + pathname: new URL(request.url ?? "/", "http://127.0.0.1:4231").pathname, + }), + ); + }); + await new Promise((resolve, reject) => { + externalRewriteServer.once("error", reject); + externalRewriteServer.listen(4231, "127.0.0.1", resolve); + }); +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + externalRewriteServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + +test("external catch-all rewrites preserve their destination path prefix", async ({ request }) => { + const normal = await request.get("/external-prefix/resource"); + expect(normal.status()).toBe(200); + await expect(normal.json()).resolves.toEqual({ pathname: "/v1/resource" }); + + for (const pathname of ["%252e%252e", ".%252e", "%252e."]) { + const traversal = await request.get(`/external-prefix/${pathname}/outside`); + expect(traversal.status()).toBe(200); + const traversalResult = (await traversal.json()) as { pathname: string }; + expect(traversalResult.pathname).toMatch(/^\/v1(?:\/|$)/); + } + + expect((await request.get("/external-prefix../outside")).status()).toBe(404); +}); diff --git a/tests/e2e/pages-router-prod/production.spec.ts b/tests/e2e/pages-router-prod/production.spec.ts index 4623fffa4c..725962d5eb 100644 --- a/tests/e2e/pages-router-prod/production.spec.ts +++ b/tests/e2e/pages-router-prod/production.spec.ts @@ -1,3 +1,4 @@ +import { createServer, type Server } from "node:http"; import { test, expect } from "@playwright/test"; /** @@ -9,7 +10,47 @@ import { test, expect } from "@playwright/test"; */ const BASE = "http://localhost:4175"; +let externalRewriteServer: Server; +const receivedPaths: string[] = []; + +test.beforeAll(async () => { + externalRewriteServer = createServer((request, response) => { + const pathname = new URL(request.url ?? "/", "http://127.0.0.1:4230").pathname; + receivedPaths.push(pathname); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ pathname })); + }); + + await new Promise((resolve, reject) => { + externalRewriteServer.once("error", reject); + externalRewriteServer.listen(4230, "127.0.0.1", resolve); + }); +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + externalRewriteServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + test.describe("Pages Router Production Build", () => { + test("external catch-all rewrites preserve their destination path prefix", async ({ + request, + }) => { + const normal = await request.get(`${BASE}/external-prefix/resource`); + expect(normal.status()).toBe(200); + await expect(normal.json()).resolves.toEqual({ pathname: "/v1/resource" }); + + for (const pathname of ["%252e%252e", ".%252e", "%252e."]) { + const traversal = await request.get(`${BASE}/external-prefix/${pathname}/outside`); + expect(traversal.status()).toBe(200); + const traversalResult = (await traversal.json()) as { pathname: string }; + expect(traversalResult.pathname).toMatch(/^\/v1(?:\/|$)/); + } + + expect((await request.get(`${BASE}/external-prefix../outside`)).status()).toBe(404); + }); + test("index page renders with correct content", async ({ page }) => { const response = await page.goto(`${BASE}/`); expect(response?.status()).toBe(200); diff --git a/tests/e2e/pages-router/config.spec.ts b/tests/e2e/pages-router/config.spec.ts index 3c576ca0bb..496ec0bc5b 100644 --- a/tests/e2e/pages-router/config.spec.ts +++ b/tests/e2e/pages-router/config.spec.ts @@ -1,7 +1,31 @@ +import { createServer, type Server } from "node:http"; import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4173"; +let externalRewriteServer: Server; +const receivedPaths: string[] = []; + +test.beforeAll(async () => { + externalRewriteServer = createServer((request, response) => { + const pathname = new URL(request.url ?? "/", "http://127.0.0.1:4229").pathname; + receivedPaths.push(pathname); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ pathname })); + }); + + await new Promise((resolve, reject) => { + externalRewriteServer.once("error", reject); + externalRewriteServer.listen(4229, "127.0.0.1", resolve); + }); +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + externalRewriteServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + test.describe("next/config (Pages Router)", () => { test("getConfig returns publicRuntimeConfig", async ({ page }) => { await page.goto(`${BASE}/config-test`); @@ -10,4 +34,21 @@ test.describe("next/config (Pages Router)", () => { // Falls back to "default-app" if not set await expect(page.locator("#app-name")).toContainText("App:"); }); + + test("external catch-all rewrites preserve their destination path prefix", async ({ + request, + }) => { + const normal = await request.get(`${BASE}/external-prefix/resource`); + expect(normal.status()).toBe(200); + await expect(normal.json()).resolves.toEqual({ pathname: "/v1/resource" }); + + for (const pathname of ["%252e%252e", ".%252e", "%252e."]) { + const traversal = await request.get(`${BASE}/external-prefix/${pathname}/outside`); + expect(traversal.status()).toBe(200); + const traversalResult = (await traversal.json()) as { pathname: string }; + expect(traversalResult.pathname).toMatch(/^\/v1(?:\/|$)/); + } + + expect((await request.get(`${BASE}/external-prefix../outside`)).status()).toBe(404); + }); }); diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index eb613c16ff..7b9ec2c66f 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -97,6 +97,13 @@ const nextConfig: NextConfig = { source: "/repeat-rewrite/:slug", destination: "/docs/:slug/:slug", }, + // Ported from Next.js external rewrite coverage: + // test/e2e/custom-routes/custom-routes.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/custom-routes/custom-routes.test.ts + { + source: "/external-prefix/:path*", + destination: `${process.env.TEST_EXTERNAL_REWRITE_ORIGIN ?? "http://127.0.0.1:4228"}/v1/:path*`, + }, // Used by Vitest: app-router.test.ts (external proxy credential stripping) // Only active when TEST_EXTERNAL_PROXY_TARGET env var is set. ...(process.env.TEST_EXTERNAL_PROXY_TARGET diff --git a/tests/fixtures/pages-basic/next.config.mjs b/tests/fixtures/pages-basic/next.config.mjs index 1a6ab37315..86e4ede841 100644 --- a/tests/fixtures/pages-basic/next.config.mjs +++ b/tests/fixtures/pages-basic/next.config.mjs @@ -40,6 +40,13 @@ const nextConfig = { source: "/repeat-rewrite/:id", destination: "/docs/:id/:id", }, + // Ported from Next.js external rewrite coverage: + // test/e2e/custom-routes/custom-routes.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/custom-routes/custom-routes.test.ts + { + source: "/external-prefix/:path*", + destination: `${process.env.TEST_EXTERNAL_REWRITE_ORIGIN ?? "http://127.0.0.1:4229"}/v1/:path*`, + }, { source: "/rewrite-navigation/:id", destination: "/rewrite-navigation/:id/destination", diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d068a0b8cd..fb5f25db73 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11978,6 +11978,63 @@ describe("matchRewrite with external URLs", () => { expect(isExternalUrl(result!)).toBe(true); }); + it("preserves an external destination prefix when a catch-all contains encoded traversal", async () => { + const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js"); + const rewrites = [ + { + source: "/proxy/:path*", + destination: "https://api.example.com/v1/:path*", + }, + ]; + + // Request normalization decodes the E2E input `%252e%252e` once before matching. + expect(matchRewrite("/proxy/%2e%2e/outside", rewrites, emptyCtx)).toBe( + "https://api.example.com/v1/%252e%252e/outside", + ); + expect(matchRewrite("/proxy../outside", rewrites, emptyCtx)).toBeNull(); + expect(matchRewrite("/proxy/a%2Fb", rewrites, emptyCtx)).toBe( + "https://api.example.com/v1/a%2Fb", + ); + expect(matchRewrite("/proxy/a%23b", rewrites, emptyCtx)).toBe( + "https://api.example.com/v1/a%23b", + ); + expect(matchRewrite("/proxy/a%3Fb", rewrites, emptyCtx)).toBe( + "https://api.example.com/v1/a%3Fb", + ); + expect(matchRewrite("/proxy/hello world/café", rewrites, emptyCtx)).toBe( + "https://api.example.com/v1/hello world/café", + ); + + expect( + matchRewrite( + "/proxy/%2e", + [{ source: "/proxy/:sub", destination: "https://:sub.example.com/v1" }], + emptyCtx, + ), + ).toBe("https://%2e.example.com/v1"); + expect( + matchRewrite( + "/proxy/%2e%2e", + [{ source: "/proxy/:id", destination: "https://api.example.com/v1/prefix-:id" }], + emptyCtx, + ), + ).toBe("https://api.example.com/v1/prefix-%2e%2e"); + expect( + matchRewrite( + "/proxy/%2e%2e", + [{ source: "/proxy/:id", destination: "https://api.example.com/v1#section-:id" }], + emptyCtx, + ), + ).toBe("https://api.example.com/v1#section-%2e%2e"); + expect( + matchRewrite( + "/proxy/value", + [{ source: "/proxy/:id", destination: "https://api.example.com/v1/../api/:id" }], + emptyCtx, + ), + ).toBe("https://api.example.com/v1/../api/value"); + }); + it("returns internal path for non-external rewrites", async () => { const { matchRewrite, isExternalUrl } = await import("../packages/vinext/src/config/config-matchers.js"); From 17a12030f9877e51aeb0553a7c87113e2eb6af60 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 14:29:14 +0100 Subject: [PATCH 2/4] fix(rewrites): preserve condition capture semantics --- packages/vinext/src/config/config-matchers.ts | 39 +++++++++++++------ tests/shims.test.ts | 20 ++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index acb6dc8da0..ba15085041 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1105,8 +1105,15 @@ export function matchRewrite( ...params, ...conditionParams, }; + const pathParamKeys = new Set( + Object.keys(params).filter((key) => !Object.hasOwn(conditionParams, key)), + ); // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params). - return substituteAndSanitizeRewriteDestination(rewrite.destination, rewriteParams); + return substituteAndSanitizeRewriteDestination( + rewrite.destination, + rewriteParams, + pathParamKeys, + ); } } return null; @@ -1141,7 +1148,7 @@ export function matchesRewriteSource( function substituteDestinationParams( destination: string, params: Record, - encodePathParams = false, + encodedPathParamKeys?: ReadonlySet, ): string { const keys = Object.keys(params); if (keys.length === 0) return destination; @@ -1164,24 +1171,29 @@ function substituteDestinationParams( const replaceParams = ( value: string, - encodeParam: (value: string, modifier: string | undefined) => string, + encodeParam: (value: string, key: string, modifier: string | undefined) => string, ): string => value.replace(paramRe, (_token, key: string, modifier: string | undefined) => - encodeParam(params[key], modifier), + encodeParam(params[key], key, modifier), ); const replacePathParams = (value: string): string => { - if (!encodePathParams) return replaceParams(value, (param) => param); + if (!encodedPathParamKeys) return replaceParams(value, (param) => param); const replaceAndEncodePathname = (pathname: string): string => pathname .split("/") .flatMap((segment) => { - paramRe.lastIndex = 0; - const hasSubstitution = paramRe.test(segment); - paramRe.lastIndex = 0; - const substituted = replaceParams(segment, (param) => param); - return hasSubstitution ? substituted.split("/").map(encodeUrlDotSegment) : [substituted]; + let allParamsArePathDerived = true; + let hasSubstitution = false; + const substituted = replaceParams(segment, (param, key) => { + hasSubstitution = true; + if (!encodedPathParamKeys.has(key)) allParamsArePathDerived = false; + return param; + }); + return hasSubstitution && allParamsArePathDerived + ? substituted.split("/").map(encodeUrlDotSegment) + : [substituted]; }) .join("/"); @@ -1245,9 +1257,14 @@ function substituteAndSanitizeDestination( function substituteAndSanitizeRewriteDestination( destination: string, params: Record, + pathParamKeys: ReadonlySet, ): string { const rewritten = sanitizeDestination( - substituteDestinationParams(destination, params, isExternalUrl(destination)), + substituteDestinationParams( + destination, + params, + isExternalUrl(destination) ? pathParamKeys : undefined, + ), ); if (!shouldAppendRewriteParamsToQuery(destination, params)) return rewritten; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index fb5f25db73..c4014f7c24 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -12033,6 +12033,26 @@ describe("matchRewrite with external URLs", () => { emptyCtx, ), ).toBe("https://api.example.com/v1/../api/value"); + + expect( + matchRewrite( + "/proxy/value", + [ + { + source: "/proxy/:id", + destination: "https://api.example.com/v1/:captured/outside", + has: [ + { + type: "query", + key: "value", + value: "(?.*)", + }, + ], + }, + ], + { ...emptyCtx, query: new URLSearchParams({ value: ".." }) }, + ), + ).toBe("https://api.example.com/v1/../outside"); }); it("returns internal path for non-external rewrites", async () => { From e96a576d0c9023d7a460df287912bfa3ef5e579f Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 15:02:34 +0100 Subject: [PATCH 3/4] fix(rewrites): encode external path params precisely --- packages/vinext/src/config/config-matchers.ts | 19 +++--------- tests/shims.test.ts | 29 ++++++++++++++++++- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index ba15085041..6cb5872a83 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1181,21 +1181,10 @@ function substituteDestinationParams( if (!encodedPathParamKeys) return replaceParams(value, (param) => param); const replaceAndEncodePathname = (pathname: string): string => - pathname - .split("/") - .flatMap((segment) => { - let allParamsArePathDerived = true; - let hasSubstitution = false; - const substituted = replaceParams(segment, (param, key) => { - hasSubstitution = true; - if (!encodedPathParamKeys.has(key)) allParamsArePathDerived = false; - return param; - }); - return hasSubstitution && allParamsArePathDerived - ? substituted.split("/").map(encodeUrlDotSegment) - : [substituted]; - }) - .join("/"); + replaceParams(pathname, (param, key) => { + if (!encodedPathParamKeys.has(key)) return param; + return param.split("/").map(encodeUrlDotSegment).join("/"); + }); const authorityStart = value.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//)?.[0].length; if (authorityStart === undefined) { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c4014f7c24..44d5c93186 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -12018,7 +12018,14 @@ describe("matchRewrite with external URLs", () => { [{ source: "/proxy/:id", destination: "https://api.example.com/v1/prefix-:id" }], emptyCtx, ), - ).toBe("https://api.example.com/v1/prefix-%2e%2e"); + ).toBe("https://api.example.com/v1/prefix-%252e%252e"); + expect( + matchRewrite( + "/proxy/%2e", + [{ source: "/proxy/:id", destination: "https://api.example.com/v1/.:id/outside" }], + emptyCtx, + ), + ).toBe("https://api.example.com/v1/.%252e/outside"); expect( matchRewrite( "/proxy/%2e%2e", @@ -12053,6 +12060,26 @@ describe("matchRewrite with external URLs", () => { { ...emptyCtx, query: new URLSearchParams({ value: ".." }) }, ), ).toBe("https://api.example.com/v1/../outside"); + + expect( + matchRewrite( + "/proxy/%2e", + [ + { + source: "/proxy/:pathValue", + destination: "https://api.example.com/v1/:pathValue-:captured/outside", + has: [ + { + type: "query", + key: "value", + value: "(?.*)", + }, + ], + }, + ], + { ...emptyCtx, query: new URLSearchParams({ value: "." }) }, + ), + ).toBe("https://api.example.com/v1/%252e-./outside"); }); it("returns internal path for non-external rewrites", async () => { From 66d8395b38df9c965f28fc3361a76a2c102d7cae Mon Sep 17 00:00:00 2001 From: James Date: Wed, 8 Jul 2026 16:14:05 +0100 Subject: [PATCH 4/4] fix(pages): prefetch middleware rewrite targets --- .../src/shims/internal/pages-data-target.ts | 19 +++++- tests/shims.test.ts | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/internal/pages-data-target.ts b/packages/vinext/src/shims/internal/pages-data-target.ts index 7e9d808b32..ddfff86146 100644 --- a/packages/vinext/src/shims/internal/pages-data-target.ts +++ b/packages/vinext/src/shims/internal/pages-data-target.ts @@ -15,6 +15,8 @@ import { fetchStaticPagesData, fetchUncachedPagesData } from "./pages-data-fetch import { getDeploymentId, NEXT_DEPLOYMENT_ID_HEADER } from "../../utils/deployment-id.js"; import { isUnknownRecord } from "../../utils/record.js"; +const routerBasePath = process.env.__NEXT_ROUTER_BASEPATH ?? ""; + export type PagesDataTarget = { /** Final fetch URL for the data endpoint, including basePath and search. */ dataHref: string; @@ -281,12 +283,25 @@ export function prefetchPagesData(target: PagesDataTarget): void { ? target.middlewareDataHref : (target.prefetchDataHref ?? target.middlewareDataHref ?? target.dataHref); void fetchStaticPagesData(dataHref, { headers }) - .then((response) => response.arrayBuffer()) + .then(async (response) => { + prefetchMiddlewareRewriteLoader(response); + await response.arrayBuffer(); + }) .catch(() => {}); return; } if (target.middlewareDataHref) { - void fetchUncachedPagesData(target.middlewareDataHref, { headers }).catch(() => {}); + void fetchUncachedPagesData(target.middlewareDataHref, { headers }) + .then(prefetchMiddlewareRewriteLoader) + .catch(() => {}); } } + +function prefetchMiddlewareRewriteLoader(response: Response): void { + const rewriteTarget = response.headers.get("x-nextjs-rewrite"); + if (!rewriteTarget) return; + + const target = resolvePagesDataNavigationTarget(rewriteTarget, routerBasePath); + if (target) void target.loader().catch(() => {}); +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 44d5c93186..068db1818f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -18297,6 +18297,64 @@ describe("Pages Router _next/data client navigation", () => { } }); + // Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-rewrites/test/index.test.ts + it("warms a middleware rewrite destination loader during prefetch", async () => { + const previousWindow = (globalThis as any).window; + const originalDocument = (globalThis as any).document; + const originalFetch = globalThis.fetch; + + const sourceLoader = vi.fn(async () => makePageModule("source")); + const destinationLoader = vi.fn(async () => makePageModule("about")); + const { win, buildId } = createDataNavWindow({ + loaders: { + "/rewrite-me-to-about": sourceLoader, + "/about": destinationLoader, + }, + ssgPatterns: [], + sspPatterns: [], + }); + (win.__NEXT_DATA__ as any).__vinext = { hasMiddleware: true }; + (win as any).__VINEXT_MIDDLEWARE_MATCHER__ = ["/:path*"]; + (globalThis as any).window = win; + (globalThis as any).document = { + createElement: () => ({ rel: "", as: "", href: "" }), + head: { appendChild: vi.fn() }, + }; + vi.resetModules(); + + globalThis.fetch = vi.fn( + async () => + new Response("{}", { + headers: { + "Content-Type": "application/json", + "x-nextjs-rewrite": "/about?override=internal", + }, + }), + ) as any; + + try { + const { default: Router } = await import("../packages/vinext/src/shims/router.js"); + + await Router.prefetch("/rewrite-me-to-about?override=internal"); + await vi.waitFor(() => expect(destinationLoader).toHaveBeenCalledOnce()); + + expect(sourceLoader).toHaveBeenCalledOnce(); + expect(globalThis.fetch).toHaveBeenCalledWith( + `/_next/data/${buildId}/rewrite-me-to-about.json?override=internal`, + expect.objectContaining({ + headers: expect.objectContaining({ "x-middleware-prefetch": "1" }), + }), + ); + } finally { + if (previousWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = previousWindow; + (globalThis as any).document = originalDocument; + globalThis.fetch = originalFetch; + vi.resetModules(); + } + }); + it("reuses a default-locale SSG middleware prefetch for the following navigation", async () => { const previousWindow = (globalThis as any).window; const originalDocument = (globalThis as any).document;