From 5b57f5f371595ad97ac91cca389c5adc08ddcc3a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 15 Jun 2026 15:02:28 -0400 Subject: [PATCH 1/3] Request Host derivation + CSRF check simplifications (#15185) --- docs/api/other-api/adapter.md | 20 +- .../minor.request-context-domain-name.md | 1 + .../__tests__/server-test.ts | 125 ++++++++- packages/react-router-architect/server.ts | 15 +- .../.changes/patch.request-host-hardening.md | 4 + .../__tests__/server-test.ts | 73 +++++ packages/react-router-express/server.ts | 7 +- .../.changes/patch.request-url-csrf-host.md | 1 + .../__tests__/server-runtime/actions-test.ts | 264 +++++++++--------- packages/react-router/lib/actions.ts | 45 +-- packages/react-router/lib/rsc/server.rsc.ts | 2 +- .../react-router/lib/server-runtime/server.ts | 2 +- .../lib/server-runtime/single-fetch.ts | 2 +- 13 files changed, 373 insertions(+), 188 deletions(-) create mode 100644 packages/react-router-architect/.changes/minor.request-context-domain-name.md create mode 100644 packages/react-router-express/.changes/patch.request-host-hardening.md create mode 100644 packages/react-router/.changes/patch.request-url-csrf-host.md diff --git a/docs/api/other-api/adapter.md b/docs/api/other-api/adapter.md index ce95b9db83..6e44013432 100644 --- a/docs/api/other-api/adapter.md +++ b/docs/api/other-api/adapter.md @@ -18,7 +18,7 @@ If you initialized your app with `npx create-react-router@latest` with something If you're using the built-in React Router App Server, you don't interact with this API -Each adapter has the same API. In the future, we may have helpers specific to the platform you're deploying to. +Each adapter has the same API. Some adapters also have options specific to the platform you're deploying to. ## `@react-router/express` @@ -120,6 +120,24 @@ Update the `dev` and `start` scripts to use your new Express server: } ``` +## `@react-router/architect` + +[Reference Documentation ↗](https://api.reactrouter.com/v7/modules/_react-router_architect.html) + +Here's an example with Architect: + +```ts +import { createRequestHandler } from "@react-router/architect"; +import * as build from "./build/server"; + +export const handler = createRequestHandler({ + build, + useRequestContextDomainName: true, +}); +``` + +The `useRequestContextDomainName` option tells the adapter to use `event.requestContext.domainName` when creating the `request`, instead of the prior behavior of `X-Forwarded-Host` - falling back on the `Host` header in both cases. This argument will be removed in v8 and the domain name will be used by default. + ## `@react-router/cloudflare` [Reference Documentation ↗](https://api.reactrouter.com/v7/modules/_react-router_cloudflare.html) diff --git a/packages/react-router-architect/.changes/minor.request-context-domain-name.md b/packages/react-router-architect/.changes/minor.request-context-domain-name.md new file mode 100644 index 0000000000..5afe68bd83 --- /dev/null +++ b/packages/react-router-architect/.changes/minor.request-context-domain-name.md @@ -0,0 +1 @@ +Add a `useRequestContextDomainName` option to `createRequestHandler` to derive request URL hosts from the API Gateway request context. diff --git a/packages/react-router-architect/__tests__/server-test.ts b/packages/react-router-architect/__tests__/server-test.ts index cc9277b9b5..9fe6e9f5aa 100644 --- a/packages/react-router-architect/__tests__/server-test.ts +++ b/packages/react-router-architect/__tests__/server-test.ts @@ -28,9 +28,19 @@ let mockedCreateRequestHandler = typeof createReactRequestHandler >; -function createMockEvent(event: Partial = {}) { +type MockEvent = Omit, "requestContext"> & { + requestContext?: Partial; +}; + +function createMockEvent(event: MockEvent = {}) { let now = new Date(); return { + isBase64Encoded: false, + rawPath: "/", + rawQueryString: "", + routeKey: "foo", + version: "2.0", + ...event, headers: { host: "localhost:3333", accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -41,9 +51,6 @@ function createMockEvent(event: Partial = {}) { "accept-encoding": "gzip, deflate", ...event.headers, }, - isBase64Encoded: false, - rawPath: "/", - rawQueryString: "", requestContext: { http: { method: "GET", @@ -65,9 +72,6 @@ function createMockEvent(event: Partial = {}) { timeEpoch: now.getTime(), ...event.requestContext, }, - routeKey: "foo", - version: "2.0", - ...event, }; } @@ -113,6 +117,37 @@ describe("architect createRequestHandler", () => { }); }); + it("can use the request context domain name", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response(`Host: ${new URL(req.url).host}`); + }); + + await lambdaTester( + createRequestHandler({ + // We don't have a real app to test, but it doesn't matter. We won't ever + // call through to the real createRequestHandler + // @ts-expect-error + build: undefined, + useRequestContextDomainName: true, + }), + ) + .event( + createMockEvent({ + headers: { + host: "localhost:3333", + "x-forwarded-host": "ignore.com", + }, + requestContext: { + domainName: "example.com", + }, + }), + ) + .expectResolve((res: APIGatewayProxyStructuredResultV2) => { + expect(res.statusCode).toBe(200); + expect(res.body).toBe("Host: example.com"); + }); + }); + it("handles nested // requests", async () => { mockedCreateRequestHandler.mockImplementation(() => async (req) => { return new Response(`URL: ${new URL(req.url).pathname}`); @@ -248,6 +283,82 @@ describe("architect createReactRouterRequest", () => { expect(request.method).toBe("GET"); expect(request.headers.get("cookie")).toBe("__session=value"); }); + + it("uses x-forwarded-host by default", () => { + let request = createReactRouterRequest( + createMockEvent({ + headers: { + host: "localhost:3333", + "x-forwarded-host": "example.com", + }, + }), + ); + + expect(request.url).toBe("https://example.com/"); + }); + + it("uses request context domain name when enabled", () => { + let request = createReactRouterRequest( + createMockEvent({ + headers: { + host: "localhost:3333", + "x-forwarded-host": "ignore.com", + }, + requestContext: { + domainName: "example.com", + }, + }), + true, + ); + + expect(request.url).toBe("https://example.com/"); + }); + + it("ignores invalid characters in x-forwarded-host", () => { + let request = createReactRouterRequest( + createMockEvent({ + headers: { + host: "localhost:3333", + "x-forwarded-host": "example.com:4444/invalid@chars", + }, + rawPath: "/foo", + }), + ); + + expect(request.url).toBe("https://example.com:4444/foo"); + }); + + it("ignores invalid characters in request context domain name", () => { + let request = createReactRouterRequest( + createMockEvent({ + headers: { + host: "localhost:3333", + "x-forwarded-host": "example.com", + }, + requestContext: { + domainName: "context.example.com:4444/invalid@chars", + }, + rawPath: "/foo", + }), + true, + ); + + expect(request.url).toBe("https://context.example.com:4444/foo"); + }); + + it("falls back for invalid host values", () => { + let request = createReactRouterRequest( + createMockEvent({ + headers: { + host: "#invalid", + "x-forwarded-host": "@invalid", + }, + rawPath: "/foo", + }), + ); + + expect(request.url).toBe("https://localhost/foo"); + }); }); describe("sendReactRouterResponse", () => { diff --git a/packages/react-router-architect/server.ts b/packages/react-router-architect/server.ts index b31ae20d12..b7140b372f 100644 --- a/packages/react-router-architect/server.ts +++ b/packages/react-router-architect/server.ts @@ -40,15 +40,18 @@ export function createRequestHandler({ build, getLoadContext, mode = process.env.NODE_ENV, + // TODO(v8): Remove this flag and make this the default behavior + useRequestContextDomainName = false, }: { build: ServerBuild; getLoadContext?: GetLoadContextFunction; mode?: string; + useRequestContextDomainName?: boolean; }): RequestHandler { let handleRequest = createReactRouterRequestHandler(build, mode); return async (event) => { - let request = createReactRouterRequest(event); + let request = createReactRouterRequest(event, useRequestContextDomainName); let loadContext = await getLoadContext?.(event); let response = await handleRequest(request, loadContext); @@ -59,8 +62,16 @@ export function createRequestHandler({ export function createReactRouterRequest( event: APIGatewayProxyEventV2, + useRequestContextDomainName: boolean = false, ): Request { - let host = event.headers["x-forwarded-host"] || event.headers.host; + let rawHost = useRequestContextDomainName + ? event.requestContext.domainName || event.headers.host || "" + : event.headers["x-forwarded-host"] || event.headers.host || ""; + let [hostname, portStr] = rawHost.split(":"); + hostname = hostname.split(/[\\/?#@]/)[0] || "localhost"; + let hostPort = Number.parseInt(portStr ?? "", 10); + let port = Number.isSafeInteger(hostPort) ? hostPort : undefined; + let host = `${hostname}${port ? `:${port}` : ""}`; let search = event.rawQueryString.length ? `?${event.rawQueryString}` : ""; let scheme = process.env.ARC_SANDBOX ? "http" : "https"; let url = new URL(`${scheme}://${host}${event.rawPath}${search}`); diff --git a/packages/react-router-express/.changes/patch.request-host-hardening.md b/packages/react-router-express/.changes/patch.request-host-hardening.md new file mode 100644 index 0000000000..8f19773bb0 --- /dev/null +++ b/packages/react-router-express/.changes/patch.request-host-hardening.md @@ -0,0 +1,4 @@ +Adjust express adapter host computation + +- read port from `x-forwarded-host` based on `trust proxy` setting +- handle invalid hostname characters diff --git a/packages/react-router-express/__tests__/server-test.ts b/packages/react-router-express/__tests__/server-test.ts index feccbf5af8..8b4f08cfcd 100644 --- a/packages/react-router-express/__tests__/server-test.ts +++ b/packages/react-router-express/__tests__/server-test.ts @@ -307,4 +307,77 @@ describe("express createRemixRequest", () => { expect(remixRequest.headers.get("host")).toBe("localhost:3000"); expect(remixRequest.url).toBe("http://localhost:3000/foo/bar"); }); + + it("does not use x-forwarded-host port unless trust proxy is enabled", async () => { + let expressRequest = createRequest({ + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "localhost", + headers: { + Host: "localhost:3000", + "x-forwarded-host": "example.com:8443", + }, + }); + let expressResponse = createResponse(); + + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.url).toBe("http://localhost:3000/foo/bar"); + }); + + it("uses x-forwarded-host port when trust proxy is enabled", async () => { + let app = express(); + app.set("trust proxy", true); + let expressRequest = createRequest({ + app, + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "example.com", + headers: { + Host: "localhost:3000", + "x-forwarded-host": "example.com:8443", + }, + }); + let expressResponse = createResponse(); + + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.url).toBe("http://example.com:8443/foo/bar"); + }); + + it("ignores invalid characters in host values", async () => { + let expressRequest = createRequest({ + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "localhost/invalid", + headers: { + Host: "localhost:3000", + }, + }); + let expressResponse = createResponse(); + + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.url).toBe("http://localhost:3000/foo/bar"); + }); + + it("falls back for invalid host values", async () => { + let expressRequest = createRequest({ + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "/invalid", + headers: { + Host: "localhost:3000", + }, + }); + let expressResponse = createResponse(); + + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.url).toBe("http://localhost:3000/foo/bar"); + }); }); diff --git a/packages/react-router-express/server.ts b/packages/react-router-express/server.ts index 16e98ba443..8af35a5fcd 100644 --- a/packages/react-router-express/server.ts +++ b/packages/react-router-express/server.ts @@ -98,7 +98,9 @@ export function createRemixRequest( ): Request { // req.hostname doesn't include port information so grab that from // `X-Forwarded-Host` or `Host` - let [, hostnamePortStr] = req.get("X-Forwarded-Host")?.split(":") ?? []; + let [, hostnamePortStr] = req.app?.enabled("trust proxy") + ? (req.get("X-Forwarded-Host")?.split(":") ?? []) + : []; let [, hostPortStr] = req.get("host")?.split(":") ?? []; let hostnamePort = Number.parseInt(hostnamePortStr, 10); let hostPort = Number.parseInt(hostPortStr, 10); @@ -108,7 +110,8 @@ export function createRemixRequest( ? hostPort : ""; // Use req.hostname here as it respects the "trust proxy" setting - let resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; + let hostname = req.hostname.split(/[\\/?#@]/)[0] || "localhost"; + let resolvedHost = `${hostname}${port ? `:${port}` : ""}`; // Use `req.originalUrl` so Remix is aware of the full path let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); diff --git a/packages/react-router/.changes/patch.request-url-csrf-host.md b/packages/react-router/.changes/patch.request-url-csrf-host.md new file mode 100644 index 0000000000..90f44a6696 --- /dev/null +++ b/packages/react-router/.changes/patch.request-url-csrf-host.md @@ -0,0 +1 @@ +Use the constructed request URL host when validating action request origins. diff --git a/packages/react-router/__tests__/server-runtime/actions-test.ts b/packages/react-router/__tests__/server-runtime/actions-test.ts index dfd00be72f..0a7762480e 100644 --- a/packages/react-router/__tests__/server-runtime/actions-test.ts +++ b/packages/react-router/__tests__/server-runtime/actions-test.ts @@ -6,129 +6,106 @@ import { throwIfPotentialCSRFAttack } from "../../lib/actions"; describe("throwIfPotentialCSRFAttack", () => { describe("when origin matches host", () => { - it("should not throw when origin matches host header", () => { - const headers = new Headers({ - origin: "https://example.com", - host: "example.com", + it("should not throw when origin matches request URL host", () => { + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://example.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, undefined), + throwIfPotentialCSRFAttack(request, undefined), ).not.toThrow(); }); - it("should not throw when origin matches x-forwarded-host header", () => { - const headers = new Headers({ - origin: "https://example.com", - "x-forwarded-host": "example.com", + it("should ignore x-forwarded-host headers", () => { + let request = new Request("https://different.com/action", { + method: "POST", + headers: { + origin: "https://different.com", + "x-forwarded-host": "example.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, undefined), - ).not.toThrow(); - }); - - it("should prefer x-forwarded-host over host header", () => { - const headers = new Headers({ - origin: "https://example.com", - "x-forwarded-host": "example.com", - host: "different.com", - }); - expect(() => - throwIfPotentialCSRFAttack(headers, undefined), - ).not.toThrow(); - }); - - it("should use first value from comma-separated x-forwarded-host", () => { - const headers = new Headers({ - origin: "https://example.com", - "x-forwarded-host": "example.com, other.com, another.com", - }); - expect(() => - throwIfPotentialCSRFAttack(headers, undefined), + throwIfPotentialCSRFAttack(request, undefined), ).not.toThrow(); }); }); describe("when origin does not match host", () => { - it("should throw when origin does not match host header", () => { - const headers = new Headers({ - origin: "https://untrusted.com", - host: "example.com", - }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "host header does not match `origin` header from a forwarded action request", - ); - }); - - it("should throw when origin does not match x-forwarded-host header", () => { - const headers = new Headers({ - origin: "https://untrusted.com", - "x-forwarded-host": "example.com", - }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "x-forwarded-host header does not match `origin` header from a forwarded action request", - ); - }); - - it("should throw when origin is present but host headers are missing", () => { - const headers = new Headers({ - origin: "https://untrusted.com", + it("should throw when origin does not match request URL host", () => { + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://untrusted.com", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "`x-forwarded-host` or `host` headers are not provided", + expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow( + "`request.url` host does not match `origin` header from a forwarded action request", ); }); }); describe("with allowed origins", () => { it("should not throw when origin matches an allowed origin exactly", () => { - const headers = new Headers({ - origin: "https://trusted.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://trusted.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["trusted.com"]), + throwIfPotentialCSRFAttack(request, ["trusted.com"]), ).not.toThrow(); }); it("should not throw when origin matches a wildcard pattern", () => { - const headers = new Headers({ - origin: "https://sub.trusted.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://sub.trusted.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["*.trusted.com"]), + throwIfPotentialCSRFAttack(request, ["*.trusted.com"]), ).not.toThrow(); }); it("should not throw when origin matches a multi-level wildcard pattern", () => { - const headers = new Headers({ - origin: "https://sub.domain.trusted.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://sub.domain.trusted.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["**.trusted.com"]), + throwIfPotentialCSRFAttack(request, ["**.trusted.com"]), ).not.toThrow(); }); it("should throw when origin does not match any allowed origin", () => { - const headers = new Headers({ - origin: "https://untrusted.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://untrusted.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["trusted.com", "*.safe.com"]), + throwIfPotentialCSRFAttack(request, ["trusted.com", "*.safe.com"]), ).toThrow( - "host header does not match `origin` header from a forwarded action request", + "`request.url` host does not match `origin` header from a forwarded action request", ); }); it("should handle multiple allowed origins", () => { - const headers = new Headers({ - origin: "https://partner2.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://partner2.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, [ + throwIfPotentialCSRFAttack(request, [ "partner1.com", "partner2.com", "*.trusted.com", @@ -139,20 +116,25 @@ describe("throwIfPotentialCSRFAttack", () => { describe("edge cases", () => { it("should not throw when origin is not present", () => { - const headers = new Headers({ - host: "example.com", - }); expect(() => - throwIfPotentialCSRFAttack(headers, undefined), + throwIfPotentialCSRFAttack( + new Request("https://example.com/action", { + method: "POST", + }), + undefined, + ), ).not.toThrow(); }); - it("should throw when origin is null string without host", () => { - const headers = new Headers({ - origin: "null", + it("should throw when origin is null string", () => { + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "null", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "`x-forwarded-host` or `host` headers are not provided", + expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow( + "`request.url` host does not match `origin` header from a forwarded action request", ); }); @@ -167,104 +149,112 @@ describe("throwIfPotentialCSRFAttack", () => { ]; for (const invalidHeader of invalidHeaders) { - const headers = new Headers({ - origin: invalidHeader, - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: invalidHeader, + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( + expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow( "`origin` header is not a valid URL. Aborting the action.", ); } }); it("should handle origin with port number", () => { - const headers = new Headers({ - origin: "https://example.com:8080", - host: "example.com:8080", + let request = new Request("https://example.com:8080/action", { + method: "POST", + headers: { + origin: "https://example.com:8080", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, undefined), + throwIfPotentialCSRFAttack(request, undefined), ).not.toThrow(); }); it("should throw when origin port differs from host port", () => { - const headers = new Headers({ - origin: "https://example.com:8080", - host: "example.com:3000", + let request = new Request("https://example.com:3000/action", { + method: "POST", + headers: { + origin: "https://example.com:8080", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "host header does not match `origin` header from a forwarded action request", + expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow( + "`request.url` host does not match `origin` header from a forwarded action request", ); }); - it("should handle x-forwarded-host with whitespace", () => { - const headers = new Headers({ - origin: "https://example.com", - "x-forwarded-host": " example.com ", - }); - expect(() => - throwIfPotentialCSRFAttack(headers, undefined), - ).not.toThrow(); - }); - it("should ignore empty string in allowed origins", () => { - const headers = new Headers({ - origin: "https://different.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://different.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["", "other.com"]), + throwIfPotentialCSRFAttack(request, ["", "other.com"]), ).toThrow( - "host header does not match `origin` header from a forwarded action request", + "`request.url` host does not match `origin` header from a forwarded action request", ); }); - it("should throw when origin is null string but has matching host", () => { - const headers = new Headers({ - origin: "null", - host: "null", + it("should not throw when origin is null string but matches request URL host", () => { + let request = new Request("https://null/action", { + method: "POST", + headers: { + origin: "null", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, undefined), + throwIfPotentialCSRFAttack(request, undefined), ).not.toThrow(); }); it("should handle subdomain in origin vs base domain in host", () => { - const headers = new Headers({ - origin: "https://api.example.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://api.example.com", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, undefined)).toThrow( - "host header does not match `origin` header from a forwarded action request", + expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow( + "`request.url` host does not match `origin` header from a forwarded action request", ); }); it("should not throw when wildcard allows subdomain", () => { - const headers = new Headers({ - origin: "https://api.example.com", - host: "main.com", + let request = new Request("https://main.com/action", { + method: "POST", + headers: { + origin: "https://api.example.com", + }, }); expect(() => - throwIfPotentialCSRFAttack(headers, ["*.example.com"]), + throwIfPotentialCSRFAttack(request, ["*.example.com"]), ).not.toThrow(); }); it("should throw on * wildcard patterns because they only match one segment", () => { - const headers = new Headers({ - origin: "https://different.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://different.com", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, ["*"])).toThrow( - "host header does not match `origin` header from a forwarded action request", + expect(() => throwIfPotentialCSRFAttack(request, ["*"])).toThrow( + "`request.url` host does not match `origin` header from a forwarded action request", ); }); it("** should match anything", () => { - const headers = new Headers({ - origin: "https://different.com", - host: "example.com", + let request = new Request("https://example.com/action", { + method: "POST", + headers: { + origin: "https://different.com", + }, }); - expect(() => throwIfPotentialCSRFAttack(headers, ["**"])).not.toThrow(); + expect(() => throwIfPotentialCSRFAttack(request, ["**"])).not.toThrow(); }); }); }); diff --git a/packages/react-router/lib/actions.ts b/packages/react-router/lib/actions.ts index 9e0052171c..778c88a454 100644 --- a/packages/react-router/lib/actions.ts +++ b/packages/react-router/lib/actions.ts @@ -1,8 +1,8 @@ export function throwIfPotentialCSRFAttack( - headers: Headers, + request: Request, allowedActionOrigins: string[] | undefined, ) { - let originHeader = headers.get("origin"); + let originHeader = request.headers.get("origin"); let originDomain: string | null = null; try { @@ -15,24 +15,15 @@ export function throwIfPotentialCSRFAttack( `\`origin\` header is not a valid URL. Aborting the action.`, ); } - let host = parseHostHeader(headers); + let host = new URL(request.url).host; - if (originDomain && (!host || originDomain !== host.value)) { + if (originDomain && originDomain !== host) { if (!isAllowedOrigin(originDomain, allowedActionOrigins)) { - if (host) { - // This seems to be an CSRF attack. We should not proceed with the action. - throw new Error( - `${host.type} header does not match \`origin\` header from a forwarded ` + - `action request. Aborting the action.`, - ); - } else { - // This is an attack. We should not proceed with the action. - throw new Error( - "`x-forwarded-host` or `host` headers are not provided. One of these " + - "is needed to compare the `origin` header from a forwarded action " + - "request. Aborting the action.", - ); - } + // This seems to be an CSRF attack. We should not proceed with the action. + throw new Error( + "The `request.url` host does not match `origin` header from a forwarded " + + "action request. Aborting the action.", + ); } } } @@ -101,21 +92,3 @@ function isAllowedOrigin( matchWildcardDomain(originDomain, allowedOrigin)), ); } - -function parseHostHeader(headers: Headers) { - let forwardedHostHeader = headers.get("x-forwarded-host"); - let forwardedHostValue = forwardedHostHeader?.split(",")[0]?.trim(); - let hostHeader = headers.get("host"); - - return forwardedHostValue - ? { - type: "x-forwarded-host", - value: forwardedHostValue, - } - : hostHeader - ? { - type: "host", - value: hostHeader, - } - : undefined; -} diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index d85ab73f22..c538d02c43 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -852,7 +852,7 @@ async function generateRenderResponse( let potentialCSRFAttackError: unknown | undefined; if (isMutationMethod(request.method)) { try { - throwIfPotentialCSRFAttack(request.headers, allowedActionOrigins); + throwIfPotentialCSRFAttack(request, allowedActionOrigins); ctx.runningAction = true; let result = await processServerAction( diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 8cb9d16c5a..73d03b7d4c 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -487,7 +487,7 @@ async function handleDocumentRequest( if (isMutationMethod(request.method)) { try { throwIfPotentialCSRFAttack( - request.headers, + request, Array.isArray(build.allowedActionOrigins) ? build.allowedActionOrigins : [], diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 4ab9886353..bb029788bf 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -46,7 +46,7 @@ export async function singleFetchAction( try { try { throwIfPotentialCSRFAttack( - request.headers, + request, Array.isArray(build.allowedActionOrigins) ? build.allowedActionOrigins : [], From 8ef9b16f646a08840a79554a726512a44180bb50 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 15 Jun 2026 16:34:11 -0400 Subject: [PATCH 2/3] Version release PR branch names --- DEVELOPMENT.md | 10 +++++----- scripts/changes/pr.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f61da82a27..10d71f68a4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -12,19 +12,19 @@ When you are ready to begin the release process: - Merge all release-bound changes to `main` with their change files - The `release.yml` workflow will see the `main` branch with change files in it: - This triggers the "PR" step and runs `scripts/changes/pr.ts` - - This will create or update a `release-pr` branch from `main` + - This will create or update a versioned release branch from `main` such as `release-v7-pr` - Runs `scripts/changes/version.ts` - Updates versions - Generate changelogs - Deletes change files - - Opens a PR from `release-pr` to `main` + - Opens a PR from `release-v-pr` to `main` - Once that PR is merged, the `release.yml` workflow will run again against `main` and see no change files: - This triggers the `needs-publish` step, which checks npm to see if the current `react-router` version is already published - If publishing is needed, this triggers the "publish" step and runs `scripts/changes/publish.ts` - Publishes all packages to npm - Tags the commits and pushes tags to the origin - Creates a github release -- The `release-pr` branch can be deleted after the PR is merged +- The `release-v-pr` branch can be deleted after the PR is merged ### Iterating a release PR @@ -48,11 +48,11 @@ Hotfix releases operate like the above but off of a hotfix branch that is create - Merge into `hotfix` - The `release.yml` workflow will see the `hotfix` branch with change files in it: - This triggers the "PR" step (`scripts/changes/pr.ts`) - - This will create a new branch from `hotfix` + - This will create or update a versioned hotfix branch from `hotfix` such as `hotfix-v7-pr` - Update the versions in the new branch - Generate the proper `CHANGELOG.md` entries - Delete the change files - - Open a PR to the `hotfix` branch + - Open a PR from `hotfix-v-pr` to the `hotfix` branch - Once that PR is merged, the `release.yml` workflow will run again against `hotfix` and see no change files: - This triggers the "publish" step (`scripts/changes/publish.ts`) - Publishes all packages to npm diff --git a/scripts/changes/pr.ts b/scripts/changes/pr.ts index 17cef96c93..28add39e1d 100644 --- a/scripts/changes/pr.ts +++ b/scripts/changes/pr.ts @@ -7,6 +7,9 @@ * Environment: * GITHUB_TOKEN - Required (unless --preview) */ +import * as semver from "semver"; + +import { readJson } from "../utils/fs.ts"; import { addPrLabels, closePr, @@ -14,6 +17,7 @@ import { findOpenPr, updatePr, } from "../utils/github.ts"; +import { getPackageFile } from "../utils/packages.ts"; import { logAndExec } from "../utils/process.ts"; import type { PackageRelease } from "./changes.ts"; import { @@ -30,7 +34,6 @@ if (!preview && !["main", "hotfix"].includes(baseBranch)) { throw new Error("Error: script must be run from the main or hotfix branch"); } -let prBranch = baseBranch === "hotfix" ? "hotfix-pr" : "release-pr"; let prLabels = ["pkg:react-router"]; // GitHub has a 65,536 character limit for PR body. We use 60,000 to be safe. @@ -53,6 +56,11 @@ async function main() { let { releases } = result; + let pkgJson = readJson(getPackageFile("react-router", "package.json")); + let majorVersion = semver.major(releases[0]?.nextVersion ?? pkgJson.version); + let prBranchPrefix = baseBranch === "hotfix" ? "hotfix" : "release"; + let prBranch = `${prBranchPrefix}-v${majorVersion}-pr`; + if (releases.length === 0) { console.log("No pending changes to release."); From 09e6020d1950e54f361f7ad00938ecd4dde60929 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 15 Jun 2026 16:48:44 -0400 Subject: [PATCH 3/3] Optimize route matching internals (#15186) --- integration/fog-of-war-test.ts | 14 ++++-- .../patch.precompute-route-branch-matchers.md | 1 + .../__tests__/dom/ssr/fog-of-war-test.ts | 25 +++++++++++ .../react-router/__tests__/rsc/server-test.ts | 43 ++++++++++++++++++ .../__tests__/server-runtime/server-test.ts | 21 ++++++++- .../react-router/lib/dom/ssr/fog-of-war.ts | 27 +++++++++++ packages/react-router/lib/router/utils.ts | 44 +++++++++++++++--- packages/react-router/lib/rsc/browser.tsx | 4 +- packages/react-router/lib/rsc/server.rsc.ts | 45 ++++++++----------- .../react-router/lib/server-runtime/server.ts | 36 ++++----------- 10 files changed, 196 insertions(+), 64 deletions(-) create mode 100644 packages/react-router/.changes/patch.precompute-route-branch-matchers.md create mode 100644 packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts create mode 100644 packages/react-router/__tests__/rsc/server-test.ts diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts index 5c415455e4..03b29b05fb 100644 --- a/integration/fog-of-war-test.ts +++ b/integration/fog-of-war-test.ts @@ -715,7 +715,9 @@ test.describe("Fog of War", () => { expect(await app.getHtml("#parent")).toMatch(`Parent`); expect(await app.getHtml("#child2")).toMatch(`Child 2`); expect(manifestRequests).toEqual([ - expect.stringMatching(/\/__manifest\?paths=%2Fparent%2Fchild2&version=/), + expect.stringMatching( + /\/__manifest\?paths=%2Fparent%2C%2Fparent%2Fchild2&version=/, + ), ]); }); @@ -1065,7 +1067,7 @@ test.describe("Fog of War", () => { await page.waitForSelector("#splat"); expect(await app.getHtml("#splat")).toMatch("Splat: b/c"); expect(manifestRequests).toEqual([ - expect.stringMatching(/\/__manifest\?paths=%2Fb%2Fc&version=/), + expect.stringMatching(/\/__manifest\?paths=%2Fb%2C%2Fb%2Fc&version=/), ]); }); @@ -1137,7 +1139,9 @@ test.describe("Fog of War", () => { await app.clickLink("/not/a/path"); await page.waitForSelector("#error"); expect(manifestRequests).toEqual([ - expect.stringMatching(/\/__manifest\?paths=%2Fnot%2Fa%2Fpath&version=/), + expect.stringMatching( + /\/__manifest\?paths=%2Fnot%2C%2Fnot%2Fa%2C%2Fnot%2Fa%2Fpath&version=/, + ), ]); manifestRequests = []; @@ -1449,7 +1453,9 @@ test.describe("Fog of War", () => { // Wait for eager discovery to kick off await new Promise((r) => setTimeout(r, 500)); expect(manifestRequests).toEqual([ - expect.stringMatching(/\/custom-manifest\?paths=%2Fa%2Fb&version=/), + expect.stringMatching( + /\/custom-manifest\?paths=%2Fa%2C%2Fa%2Fb&version=/, + ), ]); expect(wrongManifestRequests).toEqual([]); diff --git a/packages/react-router/.changes/patch.precompute-route-branch-matchers.md b/packages/react-router/.changes/patch.precompute-route-branch-matchers.md new file mode 100644 index 0000000000..240a492936 --- /dev/null +++ b/packages/react-router/.changes/patch.precompute-route-branch-matchers.md @@ -0,0 +1 @@ +Precompute route branch matchers to avoid recompiling route path regexes during matching diff --git a/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts new file mode 100644 index 0000000000..d86c511e29 --- /dev/null +++ b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts @@ -0,0 +1,25 @@ +import { getPathsWithAncestors } from "../../../lib/dom/ssr/fog-of-war"; + +describe("fog of war", () => { + describe("getPathsWithAncestors", () => { + test("adds parent paths", () => { + expect(getPathsWithAncestors(["/a/b/c"])).toEqual([ + "/a", + "/a/b", + "/a/b/c", + ]); + }); + + test("dedupes shared parent paths", () => { + expect(getPathsWithAncestors(["/a/b", "/a/c"])).toEqual([ + "/a", + "/a/b", + "/a/c", + ]); + }); + + test("normalizes paths without leading slashes", () => { + expect(getPathsWithAncestors(["a/b"])).toEqual(["/a", "/a/b"]); + }); + }); +}); diff --git a/packages/react-router/__tests__/rsc/server-test.ts b/packages/react-router/__tests__/rsc/server-test.ts new file mode 100644 index 0000000000..fdf4d2ab32 --- /dev/null +++ b/packages/react-router/__tests__/rsc/server-test.ts @@ -0,0 +1,43 @@ +import { + matchRSCServerRequest, + type RSCMatch, + type RSCRouteConfigEntry, +} from "../../lib/rsc/server.rsc"; +import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war"; + +describe("RSC server", () => { + describe("manifest requests", () => { + test("rejects manifest requests over the URL limit", async () => { + let path = `/${"a".repeat(URL_LIMIT)}.manifest`; + + let { response, match } = await matchManifestRequest( + new Request(`https://remix.run${path}`), + [], + ); + + expect(response.status).toBe(400); + expect(match).toBeUndefined(); + }); + }); +}); + +async function matchManifestRequest( + request: Request, + routes: RSCRouteConfigEntry[], +) { + let match: RSCMatch | undefined; + let response = await matchRSCServerRequest({ + createTemporaryReferenceSet: () => ({}), + request, + routes, + generateResponse(nextMatch) { + match = nextMatch; + return new Response(null, { + status: nextMatch.statusCode, + headers: nextMatch.headers, + }); + }, + }); + + return { response, match }; +} diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts index 10a28d06e2..73d25c9c22 100644 --- a/packages/react-router/__tests__/server-runtime/server-test.ts +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -6,6 +6,7 @@ import { createContext, type StaticHandlerContext } from "react-router"; import { createRequestHandler } from "../../lib/server-runtime/server"; import { ServerMode } from "../../lib/server-runtime/mode"; +import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war"; import { mockServerBuild } from "./utils"; function spyConsole() { @@ -2133,7 +2134,7 @@ describe("shared server runtime", () => { let handler = createRequestHandler(build, ServerMode.Test); let request = new Request( - `${baseUrl}/__manifest?paths=%2Fa%2Fb&version=${build.assets.version}`, + `${baseUrl}/__manifest?paths=%2Fa,%2Fa%2Fb&version=${build.assets.version}`, ); let result = await handler(request); @@ -2165,6 +2166,24 @@ describe("shared server runtime", () => { }); }); + test("rejects manifest requests over the URL limit", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request( + `${baseUrl}/__manifest?paths=${encodeURIComponent( + `/${"a".repeat(URL_LIMIT)}`, + )}&version=${build.assets.version}`, + ); + + let result = await handler(request); + expect(result.status).toBe(400); + }); + test("disabled when route discovery is disabled", async () => { let build = mockServerBuild( { diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 35d117887b..265a4606a2 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -25,6 +25,31 @@ const discoveredPaths = new Set(); // https://stackoverflow.com/a/417184 export const URL_LIMIT = 7680; +export function getPathsWithAncestors(paths: string[]): string[] { + let result = new Set(); + + paths.forEach((path) => { + if (!path.startsWith("/")) { + path = `/${path}`; + } + // In addition to the requested path, we need to include patches for each + // ancestor path so that we pick up any pathless/index routes below ancestor + // segments. So if we get a request for `/parent/child`, we need to look for + // a match on `/parent` so that if a `parent._index` route exists we return + // it and it's available for client side matching if the user routes back up + // to `/parent`. This is the same thing we do on initial load in + // via `getPartialManifest()`. + for (let i = 1; i < path.length; i++) { + if (path[i] === "/") { + result.add(path.slice(0, i)); + } + } + result.add(path); + }); + + return Array.from(result); +} + export function isFogOfWarEnabled( routeDiscovery: ServerBuild["routeDiscovery"], ssr: boolean, @@ -228,6 +253,8 @@ export async function fetchAndApplyManifestPatches( patchRoutes: DataRouter["patchRoutes"], signal?: AbortSignal, ): Promise { + paths = getPathsWithAncestors(paths); + // NOTE: Intentionally using a standalone `URLSearchParams` instance // instead of mutating `url.searchParams`, which is *significantly* slower: // https://issues.chromium.org/issues/331406951 diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index f1eeb0c292..d9565131ca 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1105,6 +1105,8 @@ interface RouteMeta { caseSensitive: boolean; childrenIndex: number; route: RouteObjectType; + matcher?: RegExp; + compiledParams?: CompiledPathParam[]; } /** @@ -1205,9 +1207,21 @@ function flattenRoutes( branches.push({ path, score: computeScore(path, route.index), - routesMeta, + routesMeta: routesMeta.map((meta, i) => { + let [matcher, params] = compilePath( + meta.relativePath, + meta.caseSensitive, + i === routesMeta.length - 1, + ); + return { + ...meta, + matcher, + compiledParams: params, + } satisfies RouteMeta; + }), }); }; + routes.forEach((route, index) => { // coarse-grain check for optional params if (route.path === "" || !route.path?.includes("?")) { @@ -1360,10 +1374,21 @@ function matchRouteBranch< matchedPathname === "/" ? pathname : pathname.slice(matchedPathname.length) || "/"; - let match = matchPath( - { path: meta.relativePath, caseSensitive: meta.caseSensitive, end }, - remainingPathname, - ); + let pattern = { + path: meta.relativePath, + caseSensitive: meta.caseSensitive, + end, + }; + let match = + // Use precomputed matcher if it exists + meta.matcher && meta.compiledParams + ? matchPathImpl( + pattern, + remainingPathname, + meta.matcher, + meta.compiledParams, + ) + : matchPath(pattern, remainingPathname); let route = meta.route; @@ -1546,6 +1571,15 @@ export function matchPath( pattern.end, ); + return matchPathImpl(pattern, pathname, matcher, compiledParams); +} + +function matchPathImpl( + pattern: PathPattern, + pathname: string, + matcher: RegExp, + compiledParams: CompiledPathParam[], +): PathMatch> | null { let match = pathname.match(matcher); if (!match) return null; diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 9db9982f0a..184de53777 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -44,7 +44,7 @@ import { import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries"; import type { RouteModules } from "../dom/ssr/routeModules"; import { populateRSCRouteModules } from "./route-modules"; -import { URL_LIMIT } from "../dom/ssr/fog-of-war"; +import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war"; const defaultManifestPath = "/__manifest"; @@ -1044,6 +1044,8 @@ async function fetchAndApplyManifestPatches( fetchImplementation: (request: Request) => Promise, signal?: AbortSignal, ) { + paths = getPathsWithAncestors(paths); + let url = getManifestUrl(paths); if (url == null) { return; diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index c538d02c43..a038487664 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -39,6 +39,7 @@ import { } from "../router/utils"; import { getDocumentHeadersImpl } from "../server-runtime/headers"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; +import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war"; import { throwIfPotentialCSRFAttack } from "../actions"; import invariant from "../server-runtime/invariant"; @@ -532,6 +533,14 @@ async function generateManifestResponse( temporaryReferences: unknown, routeDiscovery: RouteDiscovery | undefined, ) { + let url = new URL(request.url); + if (url.toString().length > URL_LIMIT) { + return new Response(null, { + statusText: "Bad Request", + status: 400, + }); + } + if (routeDiscovery?.mode === "initial") { let payload: RSCManifestPayload = { type: "manifest", @@ -550,7 +559,6 @@ async function generateManifestResponse( ); } - let url = new URL(request.url); let pathParam = url.searchParams.get("paths"); let pathnames = pathParam ? pathParam.split(",").filter(Boolean) @@ -1193,7 +1201,7 @@ async function getRenderPayload( ), ) : getAdditionalRoutePatches( - [staticContext.location.pathname], + getPathsWithAncestors([staticContext.location.pathname]), routes, basename, staticContext.matches.map((m) => m.route.id), @@ -1428,33 +1436,18 @@ async function getAdditionalRoutePatches( let matchedPaths = new Set(); for (const pathname of pathnames) { - let segments = pathname.split("/").filter(Boolean); - let paths: string[] = ["/"]; - - // We've already matched to the last segment - segments.pop(); - - // Traverse each path for our parents and match in case they have pathless/index - // children we need to include in the initial manifest - while (segments.length > 0) { - paths.push(`/${segments.join("/")}`); - segments.pop(); + if (matchedPaths.has(pathname)) { + continue; } - - paths.forEach((path) => { - if (matchedPaths.has(path)) { + matchedPaths.add(pathname); + let matches = matchRoutes(routes, pathname, basename) || []; + matches.forEach((m, i) => { + if (patchRouteMatches.get(m.route.id)) { return; } - matchedPaths.add(path); - let matches = matchRoutes(routes, path, basename) || []; - matches.forEach((m, i) => { - if (patchRouteMatches.get(m.route.id)) { - return; - } - patchRouteMatches.set(m.route.id, { - ...m.route, - parentId: matches[i - 1]?.route.id, - }); + patchRouteMatches.set(m.route.id, { + ...m.route, + parentId: matches[i - 1]?.route.id, }); }); } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 73d03b7d4c..00bd4ff98d 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -367,6 +367,13 @@ async function handleManifestRequest( branches: RouteBranch[], url: URL, ) { + if (url.toString().length > URL_LIMIT) { + return new Response(null, { + statusText: "Bad Request", + status: 400, + }); + } + if (build.assets.version !== url.searchParams.get("version")) { return new Response(null, { status: 204, @@ -376,40 +383,15 @@ async function handleManifestRequest( }); } - if (url.toString().length > URL_LIMIT) { - return new Response(null, { - statusText: "Bad Request", - status: 400, - }); - } - let patches: Record = {}; if (url.searchParams.has("paths")) { - let paths = new Set(); - - // In addition to responding with the patches for the requested paths, we - // need to include patches for each partial path so that we pick up any - // pathless/index routes below ancestor segments. So if we - // get a request for `/parent/child`, we need to look for a match on `/parent` - // so that if a `parent._index` route exists we return it so it's available - // for client side matching if the user routes back up to `/parent`. - // This is the same thing we do on initial load in via - // `getPartialManifest()` let pathParam = url.searchParams.get("paths") || ""; - let requestedPaths = pathParam.split(",").filter(Boolean); - requestedPaths.forEach((path) => { + let paths = new Set(pathParam.split(",").filter(Boolean)); + for (let path of paths) { if (!path.startsWith("/")) { path = `/${path}`; } - let segments = path.split("/").slice(1); - segments.forEach((_, i) => { - let partialPath = segments.slice(0, i + 1).join("/"); - paths.add(`/${partialPath}`); - }); - }); - - for (let path of paths) { let matches = matchServerRoutes( build.routes, dataRoutes,