Skip to content

Commit 36bdbeb

Browse files
authored
fix: normalize control characters in relative URLs (#15416)
Assisted-By: devx/597728f0-1b3f-4153-b929-18dbff6cd683
1 parent 5461c8f commit 36bdbeb

11 files changed

Lines changed: 90 additions & 11 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve handling of special characters in navigation paths

packages/react-router/__tests__/dom/link-href-test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ describe("<Link> href", () => {
129129
expect(renderer.root.findByType("a").props.href).toEqual("//remix.run");
130130
});
131131

132+
test("normalizes special characters in relative <Link> values", () => {
133+
let renderer: TestRenderer.ReactTestRenderer;
134+
TestRenderer.act(() => {
135+
renderer = TestRenderer.create(
136+
<MemoryRouter initialEntries={["/inbox/messages"]}>
137+
<Routes>
138+
<Route path="inbox">
139+
<Route
140+
path="messages"
141+
element={<Link to={"/\t/nested/path"} />}
142+
/>
143+
</Route>
144+
</Routes>
145+
</MemoryRouter>,
146+
);
147+
});
148+
149+
expect(renderer.root.findByType("a").props.href).toEqual("/nested/path");
150+
});
151+
132152
test('<Link to="mailto:remix@example.com"> is treated as external link', () => {
133153
let renderer: TestRenderer.ReactTestRenderer;
134154
TestRenderer.act(() => {

packages/react-router/__tests__/router/browser-test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ describe("a browser history", () => {
4747
expect(href).toEqual("/the/path?the=query#the-hash");
4848
});
4949

50+
it("normalizes special characters in relative hrefs", () => {
51+
for (let char of ["\t", "\n", "\r"]) {
52+
expect(history.createHref(`/${char}/nested/path`)).toBe("/nested/path");
53+
}
54+
});
55+
5056
it("does not encode the generated path", () => {
5157
const encodedHref = history.createHref({
5258
pathname: "/%23abc",

packages/react-router/__tests__/router/redirects-test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createMemoryHistory } from "../../lib/router/history";
22
import { IDLE_NAVIGATION, createRouter } from "../../lib/router/router";
3-
import { replace } from "../../lib/router/utils";
3+
import { redirect, replace } from "../../lib/router/utils";
44
import type { TestRouteObject } from "./utils/data-router-setup";
55
import { cleanup, setup } from "./utils/data-router-setup";
66
import { createFormData, tick } from "./utils/utils";
@@ -482,6 +482,25 @@ describe("redirects", () => {
482482
}
483483
});
484484

485+
it("normalizes special characters in redirects", async () => {
486+
let router = createRouter({
487+
history: createMemoryHistory(),
488+
routes: [
489+
{ path: "/" },
490+
{ path: "/start", loader: () => redirect("/\t/parent") },
491+
{ path: "/parent" },
492+
],
493+
});
494+
router.initialize();
495+
await tick();
496+
497+
await router.navigate("/start");
498+
expect(router.state.location).toMatchObject({
499+
pathname: "/parent",
500+
});
501+
router.dispose();
502+
});
503+
485504
it("properly handles same-origin absolute URLs when using a basename", async () => {
486505
let t = setup({ routes: REDIRECT_ROUTES, basename: "/base" });
487506

packages/react-router/lib/dom/lib.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ import {
3838
defaultMapRouteProperties,
3939
ErrorResponseImpl,
4040
SUPPORTED_ERROR_TYPES,
41+
isAbsoluteUrl,
4142
joinPaths,
4243
matchPath,
4344
parseToInfo,
4445
resolveTo,
4546
stripBasename,
4647
} from "../router/utils";
47-
import { ABSOLUTE_URL_REGEX } from "../router/url";
4848

4949
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5050
import type * as _ from "./global";
@@ -1334,7 +1334,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
13341334
) {
13351335
let { basename, navigator, useTransitions } =
13361336
React.useContext(NavigationContext);
1337-
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
1337+
let isAbsolute = typeof to === "string" && isAbsoluteUrl(to);
13381338

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

19501949
let submitHandler: React.SubmitEventHandler<HTMLFormElement> = (event) => {
19511950
onSubmit && onSubmit(event);

packages/react-router/lib/dom/server.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
convertRoutesToDataRoutes,
2525
isRouteErrorResponse,
2626
} from "../router/utils";
27-
import { ABSOLUTE_URL_REGEX } from "../router/url";
27+
import { ABSOLUTE_URL_REGEX, normalizeRelativeUrl } from "../router/url";
2828
import { DataRoutes, Router } from "../components";
2929
import {
3030
DataRouterContext,
@@ -457,6 +457,7 @@ function createHref(to: To) {
457457

458458
function encodeLocation(to: To): Path {
459459
let href = typeof to === "string" ? to : createPath(to);
460+
href = normalizeRelativeUrl(href);
460461
// Treating this as a full URL will strip any trailing spaces so we need to
461462
// pre-encode them since they might be part of a matching splat param from
462463
// an ancestor route

packages/react-router/lib/router/history.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PROTOCOL_RELATIVE_URL_REGEX } from "./url";
1+
import { normalizeRelativeUrl, PROTOCOL_RELATIVE_URL_REGEX } from "./url";
22

33
////////////////////////////////////////////////////////////////////////////////
44
//#region Types and Constants
@@ -407,7 +407,7 @@ export function createBrowserHistory(
407407
}
408408

409409
function createBrowserHref(window: Window, to: To) {
410-
return typeof to === "string" ? to : createPath(to);
410+
return normalizeRelativeUrl(typeof to === "string" ? to : createPath(to));
411411
}
412412

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

800800
let href = typeof to === "string" ? to : createPath(to);
801+
href = normalizeRelativeUrl(href);
801802

802803
// Treating this as a full URL will strip any trailing spaces so we need to
803804
// pre-encode them since they might be part of a matching splat param from

packages/react-router/lib/router/router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
} from "./utils";
7878
import {
7979
normalizeProtocolRelativeUrl,
80+
normalizeRelativeUrl,
8081
PROTOCOL_RELATIVE_URL_REGEX,
8182
} from "./url";
8283

@@ -6908,6 +6909,8 @@ function normalizeRedirectLocation(
69086909
basename: string,
69096910
historyInstance: History,
69106911
): string {
6912+
location = normalizeRelativeUrl(location);
6913+
69116914
if (isAbsoluteUrl(location)) {
69126915
// Strip off the protocol+origin for same-origin + same-basename absolute redirects
69136916
let normalizedLocation = location;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
export const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
22
export const PROTOCOL_RELATIVE_URL_REGEX = /^[\\/]{2}/;
33

4+
// Normalize characters ignored by the URL parser before determining whether a
5+
// URL is relative or absolute.
6+
export function normalizeRelativeUrl(url: string): string {
7+
if (ABSOLUTE_URL_REGEX.test(url)) {
8+
return url;
9+
}
10+
11+
let normalized = url.replace(/[\t\n\r]/g, "");
12+
if (!ABSOLUTE_URL_REGEX.test(normalized)) {
13+
return normalized;
14+
}
15+
16+
if (PROTOCOL_RELATIVE_URL_REGEX.test(normalized)) {
17+
return normalized.replace(/^[\\/]+/, "/");
18+
}
19+
20+
return normalized.replace(/^([a-z][a-z0-9+.-]*):/i, "$1%3A");
21+
}
22+
423
export function normalizeProtocolRelativeUrl(url: string, protocol: string) {
524
return protocol + url.replace(/\\/g, "/");
625
}

packages/react-router/lib/router/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { invariant, parsePath, warning } from "./history";
55
import {
66
ABSOLUTE_URL_REGEX,
77
normalizeProtocolRelativeUrl,
8+
normalizeRelativeUrl,
89
PROTOCOL_RELATIVE_URL_REGEX,
910
} from "./url";
1011

@@ -1852,7 +1853,8 @@ export function prependBasename({
18521853
return pathname === "/" ? basename : joinPaths([basename, pathname]);
18531854
}
18541855

1855-
export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url);
1856+
export const isAbsoluteUrl = (url: string) =>
1857+
ABSOLUTE_URL_REGEX.test(normalizeRelativeUrl(url));
18561858

18571859
/**
18581860
* Returns a resolved {@link Path} object relative to the given pathname.
@@ -1873,6 +1875,7 @@ export function resolvePath(to: To, fromPathname = "/"): Path {
18731875

18741876
let pathname: string;
18751877
if (toPathname) {
1878+
toPathname = normalizeRelativeUrl(toPathname);
18761879
toPathname = removeDoubleSlashes(toPathname);
18771880
if (toPathname.startsWith("/")) {
18781881
pathname = resolvePathname(toPathname.substring(1), "/");
@@ -2400,7 +2403,9 @@ export function parseToInfo<T extends To | string>(
24002403
_to: T,
24012404
basename: string,
24022405
): ParsedLocationInfo<T | string> {
2403-
let to = _to as string;
2406+
let to = (
2407+
typeof _to === "string" ? normalizeRelativeUrl(_to) : _to
2408+
) as string;
24042409
if (typeof to !== "string" || !ABSOLUTE_URL_REGEX.test(to)) {
24052410
return {
24062411
absoluteURL: undefined,

0 commit comments

Comments
 (0)