Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion examples/pages-router-cloudflare/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
};
Expand Down
65 changes: 56 additions & 9 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 { encodeUrlDotSegment } from "../server/normalize-path.js";

/**
* Cache for compiled regex patterns in matchConfigPattern.
Expand Down Expand Up @@ -1104,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;
Expand Down Expand Up @@ -1137,7 +1145,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, string>): string {
function substituteDestinationParams(
destination: string,
params: Record<string, string>,
encodedPathParamKeys?: ReadonlySet<string>,
): string {
const keys = Object.keys(params);
if (keys.length === 0) return destination;

Expand All @@ -1157,8 +1169,37 @@ function substituteDestinationParams(destination: string, params: Record<string,
_compiledDestinationParamCache.set(cacheKey, paramRe);
}

const replaceParams = (value: string, encodeParam: (value: string) => string): string =>
value.replace(paramRe, (_token, key: string) => encodeParam(params[key]));
const replaceParams = (
value: string,
encodeParam: (value: string, key: string, modifier: string | undefined) => string,
): string =>
value.replace(paramRe, (_token, key: string, modifier: string | undefined) =>
encodeParam(params[key], key, modifier),
);

const replacePathParams = (value: string): string => {
if (!encodedPathParamKeys) return replaceParams(value, (param) => param);

const replaceAndEncodePathname = (pathname: string): string =>
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) {
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);
Expand All @@ -1168,13 +1209,12 @@ function substituteDestinationParams(destination: string, params: Record<string,
if (queryIndex !== -1) {
const beforeQuery = beforeHash.slice(0, queryIndex);
const query = beforeHash.slice(queryIndex + 1);
return `${replaceParams(beforeQuery, (value) => 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 {
Expand Down Expand Up @@ -1206,8 +1246,15 @@ function substituteAndSanitizeDestination(
function substituteAndSanitizeRewriteDestination(
destination: string,
params: Record<string, string>,
pathParamKeys: ReadonlySet<string>,
): string {
const rewritten = substituteAndSanitizeDestination(destination, params);
const rewritten = sanitizeDestination(
substituteDestinationParams(
destination,
params,
isExternalUrl(destination) ? pathParamKeys : undefined,
),
);
if (!shouldAppendRewriteParamsToQuery(destination, params)) return rewritten;

const existingQueryKeys = getDestinationQueryKeys(destination);
Expand Down
6 changes: 3 additions & 3 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/vinext/src/server/normalize-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
}
8 changes: 4 additions & 4 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
}
Expand Down
19 changes: 17 additions & 2 deletions packages/vinext/src/shims/internal/pages-data-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {});
}
3 changes: 3 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions tests/app-router-production-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,35 @@ type StartedTextSequenceTarget = {
url: string;
};

async function startPathEchoTarget(): Promise<StartedTextSequenceTarget> {
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<void>((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<void>((resolve, reject) => {
upstream.close((error) => (error ? reject(error) : resolve()));
});
},
};
}

async function startTextSequenceTarget(options?: {
prefix?: string;
recordRequest?: (req: http.IncomingMessage) => void;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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" });
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -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 });
});

Expand All @@ -325,6 +362,16 @@ describe("App Router Production server (startProdServer)", () => {
expect(html).toContain("<script");
});

it("preserves external rewrite path prefixes", async () => {
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 () => {
Expand Down
Loading
Loading