Skip to content
Merged
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ integration/helpers/**/build/
playwright-report/
test-results/
build.utils.d.ts
.github/workflows/*.lock.yml
.wrangler/
.tmp/
.react-router/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve route matching performance for long paths
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve handling of special characters in navigation paths
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve validation of action request origins
20 changes: 20 additions & 0 deletions packages/react-router/__tests__/dom/link-href-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ describe("<Link> href", () => {
expect(renderer.root.findByType("a").props.href).toEqual("//remix.run");
});

test("normalizes special characters in relative <Link> values", () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox/messages"]}>
<Routes>
<Route path="inbox">
<Route
path="messages"
element={<Link to={"/\t/nested/path"} />}
/>
</Route>
</Routes>
</MemoryRouter>,
);
});

expect(renderer.root.findByType("a").props.href).toEqual("/nested/path");
});

test('<Link to="mailto:remix@example.com"> is treated as external link', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/__tests__/router/browser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ describe("a browser history", () => {
expect(href).toEqual("/the/path?the=query#the-hash");
});

it("normalizes special characters in relative hrefs", () => {
for (let char of ["\t", "\n", "\r"]) {
expect(history.createHref(`/${char}/nested/path`)).toBe("/nested/path");
}
});

it("does not encode the generated path", () => {
const encodedHref = history.createHref({
pathname: "/%23abc",
Expand Down
21 changes: 20 additions & 1 deletion packages/react-router/__tests__/router/redirects-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createMemoryHistory } from "../../lib/router/history";
import { IDLE_NAVIGATION, createRouter } from "../../lib/router/router";
import { replace } from "../../lib/router/utils";
import { redirect, replace } from "../../lib/router/utils";
import type { TestRouteObject } from "./utils/data-router-setup";
import { cleanup, setup } from "./utils/data-router-setup";
import { createFormData, tick } from "./utils/utils";
Expand Down Expand Up @@ -482,6 +482,25 @@ describe("redirects", () => {
}
});

it("normalizes special characters in redirects", async () => {
let router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/" },
{ path: "/start", loader: () => redirect("/\t/parent") },
{ path: "/parent" },
],
});
router.initialize();
await tick();

await router.navigate("/start");
expect(router.state.location).toMatchObject({
pathname: "/parent",
});
router.dispose();
});

it("properly handles same-origin absolute URLs when using a basename", async () => {
let t = setup({ routes: REDIRECT_ROUTES, basename: "/base" });

Expand Down
40 changes: 33 additions & 7 deletions packages/react-router/__tests__/server-runtime/actions-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,23 @@ describe("throwIfPotentialCSRFAttack", () => {
},
});
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

it("should compare complete origins", () => {
for (let [origin, requestUrl] of [
["http://example.com", "https://example.com/action"],
["https://example.com", "http://example.com/action"],
]) {
let request = new Request(requestUrl, {
method: "POST",
headers: { origin },
});

expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow();
}
});
});

describe("with allowed origins", () => {
Expand All @@ -59,6 +73,18 @@ describe("throwIfPotentialCSRFAttack", () => {
).not.toThrow();
});

it("should support explicitly allowed hosts", () => {
let request = new Request("https://example.com/action", {
method: "POST",
headers: {
origin: "http://example.com",
},
});
expect(() =>
throwIfPotentialCSRFAttack(request, ["example.com"]),
).not.toThrow();
});

it("should not throw when origin matches a wildcard pattern", () => {
let request = new Request("https://example.com/action", {
method: "POST",
Expand Down Expand Up @@ -93,7 +119,7 @@ describe("throwIfPotentialCSRFAttack", () => {
expect(() =>
throwIfPotentialCSRFAttack(request, ["trusted.com", "*.safe.com"]),
).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand Down Expand Up @@ -134,7 +160,7 @@ describe("throwIfPotentialCSRFAttack", () => {
},
});
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand Down Expand Up @@ -181,7 +207,7 @@ describe("throwIfPotentialCSRFAttack", () => {
},
});
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand All @@ -195,7 +221,7 @@ describe("throwIfPotentialCSRFAttack", () => {
expect(() =>
throwIfPotentialCSRFAttack(request, ["", "other.com"]),
).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand All @@ -219,7 +245,7 @@ describe("throwIfPotentialCSRFAttack", () => {
},
});
expect(() => throwIfPotentialCSRFAttack(request, undefined)).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand All @@ -243,7 +269,7 @@ describe("throwIfPotentialCSRFAttack", () => {
},
});
expect(() => throwIfPotentialCSRFAttack(request, ["*"])).toThrow(
"`request.url` host does not match `origin` header from a forwarded action request",
"`request.url` origin does not match `origin` header from a forwarded action request",
);
});

Expand Down
20 changes: 13 additions & 7 deletions packages/react-router/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,30 @@ export function throwIfPotentialCSRFAttack(
) {
let originHeader = request.headers.get("origin");
let originDomain: string | null = null;
let originUrl: URL | null = null;

try {
originDomain =
typeof originHeader === "string" && originHeader !== "null"
? new URL(originHeader).host
: originHeader;
if (typeof originHeader === "string" && originHeader !== "null") {
originUrl = new URL(originHeader);
originDomain = originUrl.host;
} else {
originDomain = originHeader;
}
} catch {
throw new Error(
`\`origin\` header is not a valid URL. Aborting the action.`,
);
}
let host = new URL(request.url).host;
let requestUrl = new URL(request.url);
let originMatchesRequest = originUrl
? originUrl.origin === requestUrl.origin
: originDomain === requestUrl.host;

if (originDomain && originDomain !== host) {
if (originDomain && !originMatchesRequest) {
if (!isAllowedOrigin(originDomain, allowedActionOrigins)) {
// 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 " +
"The `request.url` origin does not match `origin` header from a forwarded " +
"action request. Aborting the action.",
);
}
Expand Down
7 changes: 3 additions & 4 deletions packages/react-router/lib/dom/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ import {
defaultMapRouteProperties,
ErrorResponseImpl,
SUPPORTED_ERROR_TYPES,
isAbsoluteUrl,
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";
Expand Down Expand Up @@ -1334,7 +1334,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
) {
let { basename, navigator, useTransitions } =
React.useContext(NavigationContext);
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
let isAbsolute = typeof to === "string" && isAbsoluteUrl(to);

let parsed = parseToInfo(to, basename);
to = parsed.to;
Expand Down Expand Up @@ -1944,8 +1944,7 @@ export const Form = React.forwardRef<HTMLFormElement, FormProps>(
let formAction = useFormAction(action, { relative });
let formMethod: HTMLFormMethod =
method.toLowerCase() === "get" ? "get" : "post";
let isAbsolute =
typeof action === "string" && ABSOLUTE_URL_REGEX.test(action);
let isAbsolute = typeof action === "string" && isAbsoluteUrl(action);

let submitHandler: React.SubmitEventHandler<HTMLFormElement> = (event) => {
onSubmit && onSubmit(event);
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/lib/dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
convertRoutesToDataRoutes,
isRouteErrorResponse,
} from "../router/utils";
import { ABSOLUTE_URL_REGEX } from "../router/url";
import { ABSOLUTE_URL_REGEX, normalizeRelativeUrl } from "../router/url";
import { DataRoutes, Router } from "../components";
import {
DataRouterContext,
Expand Down Expand Up @@ -457,6 +457,7 @@ function createHref(to: To) {

function encodeLocation(to: To): Path {
let href = typeof to === "string" ? to : createPath(to);
href = normalizeRelativeUrl(href);
// Treating this as a full URL will strip any trailing spaces so we need to
// pre-encode them since they might be part of a matching splat param from
// an ancestor route
Expand Down
5 changes: 3 additions & 2 deletions packages/react-router/lib/router/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PROTOCOL_RELATIVE_URL_REGEX } from "./url";
import { normalizeRelativeUrl, PROTOCOL_RELATIVE_URL_REGEX } from "./url";

////////////////////////////////////////////////////////////////////////////////
//#region Types and Constants
Expand Down Expand Up @@ -407,7 +407,7 @@ export function createBrowserHistory(
}

function createBrowserHref(window: Window, to: To) {
return typeof to === "string" ? to : createPath(to);
return normalizeRelativeUrl(typeof to === "string" ? to : createPath(to));
}

return getUrlBasedHistory(
Expand Down Expand Up @@ -798,6 +798,7 @@ export function createBrowserURLImpl(
invariant(base, "No window.location.(origin|href) available to create URL");

let href = typeof to === "string" ? to : createPath(to);
href = normalizeRelativeUrl(href);

// Treating this as a full URL will strip any trailing spaces so we need to
// pre-encode them since they might be part of a matching splat param from
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
} from "./utils";
import {
normalizeProtocolRelativeUrl,
normalizeRelativeUrl,
PROTOCOL_RELATIVE_URL_REGEX,
} from "./url";

Expand Down Expand Up @@ -6908,6 +6909,8 @@ function normalizeRedirectLocation(
basename: string,
historyInstance: History,
): string {
location = normalizeRelativeUrl(location);

if (isAbsoluteUrl(location)) {
// Strip off the protocol+origin for same-origin + same-basename absolute redirects
let normalizedLocation = location;
Expand Down
19 changes: 19 additions & 0 deletions packages/react-router/lib/router/url.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
export const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
export const PROTOCOL_RELATIVE_URL_REGEX = /^[\\/]{2}/;

// Normalize characters ignored by the URL parser before determining whether a
// URL is relative or absolute.
export function normalizeRelativeUrl(url: string): string {
if (ABSOLUTE_URL_REGEX.test(url)) {
return url;
}

let normalized = url.replace(/[\t\n\r]/g, "");
if (!ABSOLUTE_URL_REGEX.test(normalized)) {
return normalized;
}

if (PROTOCOL_RELATIVE_URL_REGEX.test(normalized)) {
return normalized.replace(/^[\\/]+/, "/");
}

return normalized.replace(/^([a-z][a-z0-9+.-]*):/i, "$1%3A");
}

export function normalizeProtocolRelativeUrl(url: string, protocol: string) {
return protocol + url.replace(/\\/g, "/");
}
Loading
Loading