diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts
index 56f6accf30..03134de624 100644
--- a/integration/error-sanitization-test.ts
+++ b/integration/error-sanitization-test.ts
@@ -179,11 +179,8 @@ test.describe("Error Sanitization", () => {
expect(html).toMatch("Index Error");
expect(html).not.toMatch("LOADER");
expect(html).toMatch("MESSAGE:Unexpected Server Error");
- // This is the turbo-stream encoding - the fact that stack goes right
- // into __type means it has no value
- expect(html).toMatch(
- '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"',
- );
+ expect(html).toMatch('\\"SanitizedError\\"');
+ expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"');
expect(html).not.toMatch(/ at /i);
expect(errorLogs.length).toBe(1);
expect(errorLogs[0][0].message).toMatch("Loader Error");
@@ -195,11 +192,8 @@ test.describe("Error Sanitization", () => {
let html = await response.text();
expect(html).toMatch("Index Error");
expect(html).toMatch("MESSAGE:Unexpected Server Error");
- // This is the turbo-stream encoding - the fact that stack goes right
- // into __type means it has no value
- expect(html).toMatch(
- '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"',
- );
+ expect(html).toMatch('\\"SanitizedError\\"');
+ expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"');
expect(html).not.toMatch(/ at /i);
expect(errorLogs.length).toBe(1);
expect(errorLogs[0][0].message).toMatch("Render Error");
@@ -571,11 +565,8 @@ test.describe("Error Sanitization", () => {
expect(html).toMatch("Index Error");
expect(html).not.toMatch("LOADER");
expect(html).toMatch("MESSAGE:Unexpected Server Error");
- // This is the turbo-stream encoding - the fact that stack goes right
- // into __type means it has no value
- expect(html).toMatch(
- '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"',
- );
+ expect(html).toMatch('\\"SanitizedError\\"');
+ expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"');
expect(html).not.toMatch(/ at /i);
expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader");
@@ -589,11 +580,8 @@ test.describe("Error Sanitization", () => {
let html = await response.text();
expect(html).toMatch("Index Error");
expect(html).toMatch("MESSAGE:Unexpected Server Error");
- // This is the turbo-stream encoding - the fact that stack goes right
- // into __type means it has no value
- expect(html).toMatch(
- '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"',
- );
+ expect(html).toMatch('\\"SanitizedError\\"');
+ expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"');
expect(html).not.toMatch(/ at /i);
expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render");
diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts
index 9e8274ce2c..8f6d8babed 100644
--- a/integration/rsc/rsc-nojs-test.ts
+++ b/integration/rsc/rsc-nojs-test.ts
@@ -115,11 +115,16 @@ implementations.forEach((implementation) => {
throw redirect("https://example.com/");
}
+ if (id === "unsupported-protocol") {
+ throw redirect("about:blank");
+ }
+
return (
<>
{id || "home"}
Redirect
External
+ Unsupported
>
)
}
@@ -147,11 +152,16 @@ implementations.forEach((implementation) => {
throw redirect("https://example.com/");
}
+ if (id === "unsupported-protocol") {
+ throw redirect("about:blank");
+ }
+
return (
<>
{id || "home"}
Redirect
External
+ Unsupported
>
);
}
@@ -234,6 +244,16 @@ implementations.forEach((implementation) => {
await expect(page.getByText("Example Domain")).toBeAttached();
});
+ test("Handles unsupported protocol redirect Responses from render", async ({
+ page,
+ }) => {
+ let response = await page.request.get(
+ `http://localhost:${port}/render-redirect/unsupported-protocol`,
+ { maxRedirects: 0 },
+ );
+ expect(response.headers()["location"]).not.toBe("about:blank");
+ });
+
test("Suppport throwing redirect Response from suspended render", async ({
page,
}) => {
@@ -256,5 +276,16 @@ implementations.forEach((implementation) => {
await page.waitForURL(`https://example.com/`);
await expect(page.getByText("Example Domain")).toBeAttached();
});
+
+ test("Handles unsupported protocol redirect Responses from suspended render", async ({
+ page,
+ }) => {
+ let response = await page.request.get(
+ `http://localhost:${port}/render-redirect/lazy/unsupported-protocol`,
+ );
+ expect(await response.text()).not.toContain(
+ '
{id || "home"}
Redirect
External
+ Unsupported
>
)
}
@@ -1522,11 +1527,16 @@ implementations.forEach((implementation) => {
throw redirect("https://example.com/")
}
+ if (id === "unsupported-protocol") {
+ throw redirect("about:blank")
+ }
+
return (
<>
{id || "home"}
Redirect
External
+ Unsupported
>
);
}
@@ -1867,6 +1877,18 @@ implementations.forEach((implementation) => {
await expect(page.getByText("Example Domain")).toBeAttached();
});
+ test("Handles unsupported protocol redirect Responses from render", async ({
+ page,
+ }) => {
+ await page.goto(`http://localhost:${port}/render-redirect`);
+ await expect(page.getByText("home")).toBeAttached();
+ await page.getByText("Unsupported").click();
+ await page.waitForTimeout(500);
+ await expect(page).toHaveURL(
+ `http://localhost:${port}/render-redirect/unsupported-protocol`,
+ );
+ });
+
test("Suppport throwing redirect Response from suspended render", async ({
page,
}) => {
@@ -1894,6 +1916,18 @@ implementations.forEach((implementation) => {
await expect(page.getByText("Example Domain")).toBeAttached();
});
+ test("Handles unsupported protocol redirect Responses from suspended render", async ({
+ page,
+ }) => {
+ await page.goto(`http://localhost:${port}/render-redirect/lazy`);
+ await expect(page.getByText("home")).toBeAttached();
+ await page.getByText("Unsupported").click();
+ await page.waitForTimeout(500);
+ await expect(page).toHaveURL(
+ `http://localhost:${port}/render-redirect/lazy/unsupported-protocol`,
+ );
+ });
+
test("Support throwing Responses", async ({ page }) => {
await page.goto(
`http://localhost:${port}/render-route-error-response`,
diff --git a/packages/react-router/.changes/patch.framework-errors-turbo-stream.md b/packages/react-router/.changes/patch.framework-errors-turbo-stream.md
new file mode 100644
index 0000000000..90a935a87a
--- /dev/null
+++ b/packages/react-router/.changes/patch.framework-errors-turbo-stream.md
@@ -0,0 +1 @@
+Use `turbo-stream` to serialize and deserialize Framework Mode hydration errors
diff --git a/packages/react-router/.changes/patch.restrict-data-error-deserialization.md b/packages/react-router/.changes/patch.restrict-data-error-deserialization.md
new file mode 100644
index 0000000000..6879c3fb1c
--- /dev/null
+++ b/packages/react-router/.changes/patch.restrict-data-error-deserialization.md
@@ -0,0 +1 @@
+Remove the un-documented custom error serialization logic from Data Mode SSR built-in hydration flows
diff --git a/packages/react-router/.changes/patch.rsc-redirect-protocols.md b/packages/react-router/.changes/patch.rsc-redirect-protocols.md
new file mode 100644
index 0000000000..624bb35e87
--- /dev/null
+++ b/packages/react-router/.changes/patch.rsc-redirect-protocols.md
@@ -0,0 +1 @@
+Validate protocols in RSC render redirects
diff --git a/packages/react-router/.changes/patch.url-consolidation.md b/packages/react-router/.changes/patch.url-consolidation.md
new file mode 100644
index 0000000000..2d4d3a81f6
--- /dev/null
+++ b/packages/react-router/.changes/patch.url-consolidation.md
@@ -0,0 +1 @@
+Consolidate url normalization logic and better handle mixed slashes
diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx
index fa7ebbd07e..f8c33542c2 100644
--- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx
+++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx
@@ -8103,6 +8103,56 @@ function testDomRouter(
`);
});
+ it("does not deserialize custom Error subclass instances from the window", () => {
+ try {
+ (window as any).CustomError = class CustomError extends Error {};
+ window.__staticRouterHydrationData = {
+ loaderData: {},
+ actionData: null,
+ errors: {
+ "0": {
+ message: "custom error message",
+ __type: "Error",
+ __subType: "CustomError",
+ },
+ },
+ };
+ let router = createTestRouter([
+ {
+ path: "/",
+ Component: () => Nope ,
+ ErrorBoundary: () => ,
+ },
+ ]);
+ let { container } = render( );
+
+ function Boundary() {
+ let error = useRouteError() as Error;
+ return error instanceof Error ? (
+ <>
+ {error.constructor.name}
+ {error.toString()}
+ >
+ ) : (
+ No :(
+ );
+ }
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ "
+
+ Error
+
+
+ Error: custom error message
+
+
"
+ `);
+ } finally {
+ delete (window as any).CustomError;
+ }
+ });
+
it("renders hydration errors on leaf elements", async () => {
let router = createTestRouter(
[
diff --git a/packages/react-router/__tests__/resolvePath-test.tsx b/packages/react-router/__tests__/resolvePath-test.tsx
index 6a28d83e41..268cd974a8 100644
--- a/packages/react-router/__tests__/resolvePath-test.tsx
+++ b/packages/react-router/__tests__/resolvePath-test.tsx
@@ -60,6 +60,22 @@ describe("resolvePath", () => {
pathname: "/foo",
});
+ expect(resolvePath("//foo")).toMatchObject({
+ pathname: "/foo",
+ });
+
+ expect(resolvePath("\\\\foo")).toMatchObject({
+ pathname: "/foo",
+ });
+
+ expect(resolvePath("/\\foo")).toMatchObject({
+ pathname: "/foo",
+ });
+
+ expect(resolvePath("\\/foo")).toMatchObject({
+ pathname: "/foo",
+ });
+
spy.mockRestore();
});
diff --git a/packages/react-router/__tests__/router/redirects-test.ts b/packages/react-router/__tests__/router/redirects-test.ts
index bf14871076..86d5f99d10 100644
--- a/packages/react-router/__tests__/router/redirects-test.ts
+++ b/packages/react-router/__tests__/router/redirects-test.ts
@@ -448,6 +448,40 @@ describe("redirects", () => {
});
});
+ it("normalizes mixed leading separators in redirects", async () => {
+ let locations = [
+ "\\\\localhost/parent",
+ "/\\localhost/parent",
+ "\\/localhost/parent",
+ ];
+
+ for (let location of locations) {
+ let t = setup({ routes: REDIRECT_ROUTES });
+
+ let A = await t.navigate("/parent/child", {
+ formMethod: "post",
+ formData: createFormData({}),
+ });
+
+ let B = await A.actions.child.redirectReturn(
+ location,
+ undefined,
+ undefined,
+ ["parent"],
+ );
+ await B.loaders.parent.resolve("PARENT");
+ expect(t.router.state.location).toMatchObject({
+ hash: "",
+ pathname: "/parent",
+ search: "",
+ state: {
+ _isRedirect: true,
+ },
+ });
+ expect(t.window.location.assign).not.toHaveBeenCalled();
+ }
+ });
+
it("properly handles same-origin absolute URLs when using a basename", async () => {
let t = setup({ routes: REDIRECT_ROUTES, basename: "/base" });
diff --git a/packages/react-router/__tests__/server-runtime/data-test.ts b/packages/react-router/__tests__/server-runtime/data-test.ts
index bf196b458f..2eba5adefe 100644
--- a/packages/react-router/__tests__/server-runtime/data-test.ts
+++ b/packages/react-router/__tests__/server-runtime/data-test.ts
@@ -1,5 +1,11 @@
import { decodeViaTurboStream } from "../../lib/dom/ssr/single-fetch";
+import {
+ ErrorResponseImpl,
+ isRouteErrorResponse,
+} from "../../lib/router/utils";
import { createRequestHandler } from "../../lib/server-runtime/server";
+import { encodeViaTurboStream } from "../../lib/server-runtime/single-fetch";
+import { ServerMode } from "../../lib/server-runtime/mode";
import { mockServerBuild } from "./utils";
describe("loaders", () => {
@@ -33,3 +39,27 @@ describe("loaders", () => {
expect((decoded.value as any)[routeId].data).toEqual("/random");
});
});
+
+describe("turbo-stream error decoding", () => {
+ it("decodes ErrorResponse instances", async () => {
+ let body = encodeViaTurboStream(
+ {
+ errors: {
+ root: new ErrorResponseImpl(404, "Not Found", "Missing", true),
+ },
+ },
+ new AbortController().signal,
+ undefined,
+ ServerMode.Development,
+ );
+
+ let decoded = await decodeViaTurboStream(body, global);
+ let error = (decoded.value as any).errors.root;
+
+ expect(isRouteErrorResponse(error)).toBe(true);
+ expect(error.status).toBe(404);
+ expect(error.statusText).toBe("Not Found");
+ expect(error.data).toBe("Missing");
+ expect(error.internal).toBe(false);
+ });
+});
diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx
index 743aed2709..1e0abc2e5e 100644
--- a/packages/react-router/__tests__/useNavigate-test.tsx
+++ b/packages/react-router/__tests__/useNavigate-test.tsx
@@ -761,6 +761,33 @@ describe("useNavigate", () => {
`);
});
+
+ it("normalizes mixed leading separators", async () => {
+ for (let to of ["//foo", "\\\\foo", "/\\foo", "\\/foo"]) {
+ let renderer: TestRenderer.ReactTestRenderer;
+ TestRenderer.act(() => {
+ renderer = TestRenderer.create(
+
+
+ } />
+ foo} />
+
+ ,
+ );
+ });
+
+ // @ts-expect-error
+ let button = renderer.root.findByType("button");
+ await TestRenderer.act(() => button.props.onClick());
+
+ // @ts-expect-error
+ expect(renderer.toJSON()).toMatchInlineSnapshot(`
+
+ foo
+
+ `);
+ }
+ });
});
describe("with a relative href (relative=route)", () => {
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 681ecf7ec4..b49c3e8d32 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -395,9 +395,6 @@ export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/compo
/** @internal */
export type { AssetsManifest as UNSAFE_AssetsManifest } from "./lib/dom/ssr/entry";
-/** @internal */
-export { deserializeErrors as UNSAFE_deserializeErrors } from "./lib/dom/ssr/errors";
-
/** @internal */
export { RemixErrorBoundary as UNSAFE_RemixErrorBoundary } from "./lib/dom/ssr/errorBoundaries";
diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx
index 9183feb344..da380b2fbb 100644
--- a/packages/react-router/lib/dom-export/hydrated-router.tsx
+++ b/packages/react-router/lib/dom-export/hydrated-router.tsx
@@ -17,7 +17,6 @@ import {
UNSAFE_createBrowserHistory as createBrowserHistory,
UNSAFE_createClientRoutes as createClientRoutes,
UNSAFE_createRouter as createRouter,
- UNSAFE_deserializeErrors as deserializeErrors,
UNSAFE_getTurboStreamSingleFetchDataStrategy as getTurboStreamSingleFetchDataStrategy,
UNSAFE_getPatchRoutesOnNavigationFunction as getPatchRoutesOnNavigationFunction,
UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery,
@@ -158,12 +157,6 @@ function createHydratedRouter({
basename: window.__reactRouterContext?.basename,
isSpaMode: ssrInfo.context.isSpaMode,
});
-
- if (hydrationData && hydrationData.errors) {
- // TODO: De-dup this or remove entirely in v7 where single fetch is the
- // only approach and we have already serialized or deserialized on the server
- hydrationData.errors = deserializeErrors(hydrationData.errors);
- }
}
// We cannot support history-state-driven masking with SSR, so if a hard
diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx
index d3d4d39601..58937241b7 100644
--- a/packages/react-router/lib/dom/lib.tsx
+++ b/packages/react-router/lib/dom/lib.tsx
@@ -36,12 +36,14 @@ import type {
} from "../router/utils";
import {
ErrorResponseImpl,
+ SUPPORTED_ERROR_TYPES,
joinPaths,
matchPath,
parseToInfo,
resolveTo,
stripBasename,
} from "../router/utils";
+import { ABSOLUTE_URL_REGEX } from "../router/url";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type * as _ from "./global";
@@ -739,7 +741,10 @@ function deserializeErrors(
);
} else if (val && val.__type === "Error") {
// Attempt to reconstruct the right type of Error (i.e., ReferenceError)
- if (val.__subType) {
+ if (
+ typeof val.__subType === "string" &&
+ SUPPORTED_ERROR_TYPES.includes(val.__subType)
+ ) {
let ErrorConstructor = window[val.__subType];
if (typeof ErrorConstructor === "function") {
try {
@@ -1283,8 +1288,6 @@ export interface LinkProps
mask?: To;
}
-const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
-
/**
* A progressively enhanced [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
* wrapper to enable navigation with client-side routing.
diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx
index 84bdbf5a93..d75cb236af 100644
--- a/packages/react-router/lib/dom/server.tsx
+++ b/packages/react-router/lib/dom/server.tsx
@@ -30,6 +30,7 @@ import {
convertRoutesToDataRoutes,
isRouteErrorResponse,
} from "../router/utils";
+import { ABSOLUTE_URL_REGEX } from "../router/url";
import { DataRoutes, Router, mapRouteProperties } from "../components";
import {
DataRouterContext,
@@ -523,5 +524,3 @@ function encodeLocation(to: To): Path {
hash: encoded.hash,
};
}
-
-const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
diff --git a/packages/react-router/lib/dom/ssr/errors.ts b/packages/react-router/lib/dom/ssr/errors.ts
deleted file mode 100644
index 3bdd82c414..0000000000
--- a/packages/react-router/lib/dom/ssr/errors.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { RouterState } from "../../router/router";
-import { ErrorResponseImpl } from "../../router/utils";
-
-export function deserializeErrors(
- errors: RouterState["errors"],
-): RouterState["errors"] {
- if (!errors) return null;
- let entries = Object.entries(errors);
- let serialized: RouterState["errors"] = {};
- for (let [key, val] of entries) {
- // Hey you! If you change this, please change the corresponding logic in
- // serializeErrors in react-router/lib/server-runtime/errors.ts :)
- if (val && val.__type === "RouteErrorResponse") {
- serialized[key] = new ErrorResponseImpl(
- val.status,
- val.statusText,
- val.data,
- val.internal === true,
- );
- } else if (val && val.__type === "Error") {
- // Attempt to reconstruct the right type of Error (i.e., ReferenceError)
- if (val.__subType) {
- let ErrorConstructor = window[val.__subType];
- if (typeof ErrorConstructor === "function") {
- try {
- // @ts-expect-error
- let error = new ErrorConstructor(val.message);
- error.stack = val.stack;
- serialized[key] = error;
- } catch (
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- e
- ) {
- // no-op - fall through and create a normal Error
- }
- }
- }
-
- if (serialized[key] == null) {
- let error = new Error(val.message);
- error.stack = val.stack;
- serialized[key] = error;
- }
- } else {
- serialized[key] = val;
- }
- }
- return serialized;
-}
diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx
index 2860c51267..42fd58b0e1 100644
--- a/packages/react-router/lib/dom/ssr/single-fetch.tsx
+++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx
@@ -1,9 +1,6 @@
import * as React from "react";
-import {
- SUPPORTED_ERROR_TYPES,
- decode,
-} from "../../../vendor/turbo-stream-v2/turbo-stream";
+import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream";
import type { Router as DataRouter } from "../../router/router";
import { isDataWithResponseInit, isResponse } from "../../router/router";
import type {
@@ -14,6 +11,7 @@ import type {
} from "../../router/utils";
import {
ErrorResponseImpl,
+ SUPPORTED_ERROR_TYPES,
isRouteErrorResponse,
redirect,
data,
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index fb01bff674..5a40a17b30 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -26,7 +26,7 @@ import type {
RevalidationState,
NavigationStates,
} from "./router/router";
-import { IDLE_BLOCKER } from "./router/router";
+import { hasInvalidProtocol, IDLE_BLOCKER } from "./router/router";
import type {
DataRouteMatch,
ParamParseKey,
@@ -1118,6 +1118,7 @@ export class RenderErrorBoundary extends React.Component<
}
const errorRedirectHandledMap = new WeakMap>();
+
function RSCErrorHandler({
children,
error,
@@ -1139,10 +1140,14 @@ function RSCErrorHandler({
if (existingRedirect) throw existingRedirect;
let parsed = parseToInfo(redirect.location, basename);
+ let target = parsed.absoluteURL || parsed.to;
+ if (hasInvalidProtocol(target)) {
+ throw new Error("Invalid redirect location");
+ }
if (isBrowser && !errorRedirectHandledMap.get(error)) {
if (parsed.isExternal || redirect.reloadDocument) {
- window.location.href = parsed.absoluteURL || parsed.to;
+ window.location.href = target;
} else {
const redirectPromise: Promise = Promise.resolve().then(() =>
window.__reactRouterDataRouter!.navigate(parsed.to, {
@@ -1154,12 +1159,7 @@ function RSCErrorHandler({
}
}
- return (
-
- );
+ return ;
}
}
return children;
diff --git a/packages/react-router/lib/router/history.ts b/packages/react-router/lib/router/history.ts
index 46254b02c8..8a302cf6a8 100644
--- a/packages/react-router/lib/router/history.ts
+++ b/packages/react-router/lib/router/history.ts
@@ -1,3 +1,5 @@
+import { PROTOCOL_RELATIVE_URL_REGEX } from "./url";
+
////////////////////////////////////////////////////////////////////////////////
//#region Types and Constants
////////////////////////////////////////////////////////////////////////////////
@@ -803,7 +805,7 @@ export function createBrowserURLImpl(
// then we need to avoid the URL constructor treating a leading double slash
// as a protocol-less URL. By prepending the base, it forces the double slash
// to be parsed correctly as part of the pathname.
- if (!isAbsolute && href.startsWith("//")) {
+ if (!isAbsolute && PROTOCOL_RELATIVE_URL_REGEX.test(href)) {
// new URL('//', 'https://localhost') -> error!
// new URL('https://localhost//', 'https://localhost') -> no error!
href = base + href;
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index 713e3dba62..f90f4fb1ed 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -71,6 +71,10 @@ import {
removeDoubleSlashes,
flattenAndRankRoutes,
} from "./utils";
+import {
+ normalizeProtocolRelativeUrl,
+ PROTOCOL_RELATIVE_URL_REGEX,
+} from "./url";
////////////////////////////////////////////////////////////////////////////////
//#region Types and Constants
@@ -6831,6 +6835,14 @@ export const invalidProtocols = [
"javascript:",
];
+export function hasInvalidProtocol(location: string): boolean {
+ try {
+ return invalidProtocols.includes(new URL(location).protocol);
+ } catch {
+ return false;
+ }
+}
+
function normalizeRedirectLocation(
location: string,
currentUrl: URL,
@@ -6840,10 +6852,12 @@ function normalizeRedirectLocation(
if (isAbsoluteUrl(location)) {
// Strip off the protocol+origin for same-origin + same-basename absolute redirects
let normalizedLocation = location;
- let url = normalizedLocation.startsWith("//")
- ? new URL(currentUrl.protocol + normalizedLocation)
+ let url = PROTOCOL_RELATIVE_URL_REGEX.test(normalizedLocation)
+ ? new URL(
+ normalizeProtocolRelativeUrl(normalizedLocation, currentUrl.protocol),
+ )
: new URL(normalizedLocation);
- if (invalidProtocols.includes(url.protocol)) {
+ if (hasInvalidProtocol(url.toString())) {
throw new Error("Invalid redirect location");
}
let isSameBasename = stripBasename(url.pathname, basename) != null;
@@ -6854,7 +6868,7 @@ function normalizeRedirectLocation(
try {
let url = historyInstance.createURL(location);
- if (invalidProtocols.includes(url.protocol)) {
+ if (hasInvalidProtocol(url.toString())) {
throw new Error("Invalid redirect location");
}
} catch (
diff --git a/packages/react-router/lib/router/url.ts b/packages/react-router/lib/router/url.ts
new file mode 100644
index 0000000000..078b92a292
--- /dev/null
+++ b/packages/react-router/lib/router/url.ts
@@ -0,0 +1,6 @@
+export const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
+export const PROTOCOL_RELATIVE_URL_REGEX = /^[\\/]{2}/;
+
+export function normalizeProtocolRelativeUrl(url: string, protocol: string) {
+ return protocol + url.replace(/\\/g, "/");
+}
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index c1ec6c55db..f1eeb0c292 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -3,6 +3,11 @@ import type { MiddlewareEnabled } from "../types/future";
import type { Equal, Expect } from "../types/utils";
import type { Location, Path, To } from "./history";
import { invariant, parsePath, warning } from "./history";
+import {
+ ABSOLUTE_URL_REGEX,
+ normalizeProtocolRelativeUrl,
+ PROTOCOL_RELATIVE_URL_REGEX,
+} from "./url";
export type MaybePromise = T | Promise;
@@ -1706,7 +1711,6 @@ export function prependBasename({
return pathname === "/" ? basename : joinPaths([basename, pathname]);
}
-const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url);
/**
@@ -1901,7 +1905,7 @@ export function resolveTo(
}
export const removeDoubleSlashes = (path: string): string =>
- path.replace(/\/\/+/g, "/");
+ path.replace(/[\\/]{2,}/g, "/");
export const joinPaths = (paths: string[]): string =>
removeDoubleSlashes(paths.join("/"));
@@ -2105,6 +2109,15 @@ export type ErrorResponse = {
data: any;
};
+export const SUPPORTED_ERROR_TYPES = [
+ "EvalError",
+ "RangeError",
+ "ReferenceError",
+ "SyntaxError",
+ "TypeError",
+ "URIError",
+];
+
/*
* Utility class we use to hold auto-unwrapped 4xx/5xx Response bodies
*
@@ -2223,8 +2236,8 @@ export function parseToInfo(
if (isBrowser) {
try {
let currentUrl = new URL(window.location.href);
- let targetUrl = to.startsWith("//")
- ? new URL(currentUrl.protocol + to)
+ let targetUrl = PROTOCOL_RELATIVE_URL_REGEX.test(to)
+ ? new URL(normalizeProtocolRelativeUrl(to, currentUrl.protocol))
: new URL(to);
let path = stripBasename(targetUrl.pathname, basename);
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index b3f841088d..9db9982f0a 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -9,7 +9,7 @@ import { createBrowserHistory, invariant } from "../router/history";
import type { Router as DataRouter, RouterInit } from "../router/router";
import {
createRouter,
- invalidProtocols,
+ hasInvalidProtocol,
isMutationMethod,
} from "../router/router";
import type {
@@ -24,7 +24,8 @@ import type {
DataStrategyFunctionArgs,
RouterContextProvider,
} from "../router/utils";
-import { ErrorResponseImpl, createContext } from "../router/utils";
+import { ErrorResponseImpl, createContext, resolvePath } from "../router/utils";
+import { PROTOCOL_RELATIVE_URL_REGEX } from "../router/url";
import type {
DecodedSingleFetchResults,
FetchAndDecodeFunction,
@@ -146,16 +147,17 @@ export function createCallServer({
Promise.resolve(payloadPromise)
.then(async (payload) => {
if (payload.type === "redirect") {
- if (payload.reload || isExternalLocation(payload.location)) {
- if (hasInvalidProtocol(payload.location)) {
+ let location = normalizeRedirectLocation(payload.location);
+ if (payload.reload || isExternalLocation(location)) {
+ if (hasInvalidProtocol(location)) {
throw new Error("Invalid redirect location");
}
- window.location.href = payload.location;
+ window.location.href = location;
return;
}
React.startTransition(() => {
- globalVar.__reactRouterDataRouter.navigate(payload.location, {
+ globalVar.__reactRouterDataRouter.navigate(location, {
replace: payload.replace,
});
});
@@ -173,15 +175,16 @@ export function createCallServer({
globalVar.__routerActionID <= actionId
) {
if (rerender.type === "redirect") {
- if (rerender.reload || isExternalLocation(rerender.location)) {
- if (hasInvalidProtocol(rerender.location)) {
+ let location = normalizeRedirectLocation(rerender.location);
+ if (rerender.reload || isExternalLocation(location)) {
+ if (hasInvalidProtocol(location)) {
throw new Error("Invalid redirect location");
}
- window.location.href = rerender.location;
+ window.location.href = location;
return;
}
React.startTransition(() => {
- globalVar.__reactRouterDataRouter.navigate(rerender.location, {
+ globalVar.__reactRouterDataRouter.navigate(location, {
replace: rerender.replace,
});
});
@@ -1106,12 +1109,13 @@ function isExternalLocation(location: string) {
return newLocation.origin !== window.location.origin;
}
-function hasInvalidProtocol(location: string): boolean {
- try {
- return invalidProtocols.includes(new URL(location).protocol);
- } catch {
- return false;
+function normalizeRedirectLocation(location: string): string {
+ if (PROTOCOL_RELATIVE_URL_REGEX.test(location)) {
+ let path = resolvePath(location);
+ return path.pathname + path.search + path.hash;
}
+
+ return location;
}
function cloneRoutes(routes: DataRouteObject[] | undefined): DataRouteObject[] {
diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx
index a082abf9f3..317c714365 100644
--- a/packages/react-router/lib/rsc/server.ssr.tsx
+++ b/packages/react-router/lib/rsc/server.ssr.tsx
@@ -10,6 +10,7 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
import type { RSCPayload } from "./server.rsc";
import { createRSCRouteModules } from "./route-modules";
import { isRouteErrorResponse, type DataRouteObject } from "../router/utils";
+import { hasInvalidProtocol } from "../router/router";
import {
decodeRedirectErrorDigest,
decodeRouteErrorResponseDigest,
@@ -202,6 +203,10 @@ export async function routeRSCServerRequest({
serverResponse.status === SINGLE_FETCH_REDIRECT_STATUS &&
payload.type === "redirect"
) {
+ if (hasInvalidProtocol(payload.location)) {
+ throw new Error("Invalid redirect location");
+ }
+
const headers = new Headers(serverResponse.headers);
headers.delete("Content-Encoding");
headers.delete("Content-Length");
@@ -255,6 +260,10 @@ export async function routeRSCServerRequest({
headers.set("Content-Type", "text/html; charset=utf-8");
if (renderRedirect) {
+ if (hasInvalidProtocol(renderRedirect.location)) {
+ throw new Error("Invalid redirect location");
+ }
+
headers.set("Location", renderRedirect.location);
return new Response(html, {
status: renderRedirect.status,
@@ -265,6 +274,10 @@ export async function routeRSCServerRequest({
const redirectTransform = new TransformStream({
flush(controller) {
if (renderRedirect) {
+ if (hasInvalidProtocol(renderRedirect.location)) {
+ return;
+ }
+
controller.enqueue(
new TextEncoder().encode(
` `,
@@ -300,6 +313,10 @@ export async function routeRSCServerRequest({
}
if (renderRedirect) {
+ if (hasInvalidProtocol(renderRedirect.location)) {
+ throw new Error("Invalid redirect location");
+ }
+
return new Response(`Redirect: ${renderRedirect.location}`, {
status: renderRedirect.status,
headers: {
@@ -388,6 +405,10 @@ export async function routeRSCServerRequest({
headers.set("Content-Type", "text/html; charset=utf-8");
if (retryRedirect) {
+ if (hasInvalidProtocol(retryRedirect.location)) {
+ throw new Error("Invalid redirect location");
+ }
+
headers.set("Location", retryRedirect.location);
return new Response(html, {
status: retryRedirect.status,
@@ -398,6 +419,10 @@ export async function routeRSCServerRequest({
const retryRedirectTransform = new TransformStream({
flush(controller) {
if (retryRedirect) {
+ if (hasInvalidProtocol(retryRedirect.location)) {
+ return;
+ }
+
controller.enqueue(
new TextEncoder().encode(
` `,
@@ -495,6 +520,10 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) {
const payload = useSafe(decoded);
if (payload.type === "redirect") {
+ if (hasInvalidProtocol(payload.location)) {
+ throw new Error("Invalid redirect location");
+ }
+
throw new Response(null, {
status: payload.status,
headers: {
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index dcc15f5869..8cb9d16c5a 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -21,7 +21,7 @@ import type { AppLoadContext } from "./data";
import type { HandleErrorFunction, ServerBuild } from "./build";
import type { CriticalCss, EntryContext } from "../dom/ssr/entry";
import { createEntryRouteModules } from "./entry";
-import { sanitizeErrors, serializeError, serializeErrors } from "./errors";
+import { sanitizeErrors, serializeError } from "./errors";
import { ServerMode, isServerMode } from "./mode";
import type { RouteMatch } from "./routeMatching";
import { matchServerRoutes } from "./routeMatching";
@@ -550,7 +550,7 @@ async function handleDocumentRequest(
let state = {
loaderData: context.loaderData,
actionData: context.actionData,
- errors: serializeErrors(context.errors, serverMode),
+ errors: context.errors,
};
let baseServerHandoff: ServerHandoff = {
basename: build.basename,
@@ -636,7 +636,7 @@ async function handleDocumentRequest(
let state = {
loaderData: context.loaderData,
actionData: context.actionData,
- errors: serializeErrors(context.errors, serverMode),
+ errors: context.errors,
};
entryContext = {
...entryContext,
diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts
index 3128162d86..791a75836d 100644
--- a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts
+++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts
@@ -1,8 +1,8 @@
+import { SUPPORTED_ERROR_TYPES } from "../../lib/router/utils";
import { flatten } from "./flatten";
import { unflatten } from "./unflatten";
import {
Deferred,
- SUPPORTED_ERROR_TYPES,
TYPE_ERROR,
TYPE_PREVIOUS_RESOLVED,
TYPE_PROMISE,
diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts
index 86e45358ad..0c146a930f 100644
--- a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts
+++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts
@@ -1,3 +1,4 @@
+import { SUPPORTED_ERROR_TYPES } from "../../lib/router/utils";
import {
Deferred,
HOLE,
@@ -19,7 +20,6 @@ import {
TYPE_SYMBOL,
TYPE_URL,
type ThisDecode,
- SUPPORTED_ERROR_TYPES,
} from "./utils";
const globalObj = (
diff --git a/packages/react-router/vendor/turbo-stream-v2/utils.ts b/packages/react-router/vendor/turbo-stream-v2/utils.ts
index 51ccc7765f..e091c55318 100644
--- a/packages/react-router/vendor/turbo-stream-v2/utils.ts
+++ b/packages/react-router/vendor/turbo-stream-v2/utils.ts
@@ -44,15 +44,6 @@ export interface ThisEncode {
signal?: AbortSignal;
}
-export const SUPPORTED_ERROR_TYPES = [
- "EvalError",
- "RangeError",
- "ReferenceError",
- "SyntaxError",
- "TypeError",
- "URIError",
-];
-
export class Deferred {
promise: Promise;
resolve!: (value: T) => void;