Skip to content

Commit e8777d7

Browse files
ynakootimdorr
authored andcommitted
fix(router): prevent double-encoding of route params
Fixes a bug where route parameters containing both spaces and percent signs were incorrectly double-encoded during path generation. Implements a safe encoder that preserves already-encoded sequences while ensuring RFC 3986 compliance.
1 parent 2f9ffa5 commit e8777d7

File tree

2 files changed

+138
-73
lines changed

2 files changed

+138
-73
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { matchPath, generatePath } from "../index";
2+
3+
describe("Double Encoding Bug Repro", () => {
4+
it("correctly matches a route with mixed encoded/unencoded params", () => {
5+
// Expected behavior:
6+
// URL: /malformed/2%25%200%20g%20-%202
7+
// Decoded URL path: /malformed/2% 0 g - 2
8+
// Params: { id: "2% 0 g - 2" }
9+
10+
// If the browser/history provides the decoded path:
11+
const pathname = "/malformed/2% 0 g - 2";
12+
const match = matchPath("/malformed/:id", pathname);
13+
14+
expect(match).not.toBeNull();
15+
expect(match?.params.id).toBe("2% 0 g - 2");
16+
});
17+
18+
it("correctly generates a path with mixed encoded/unencoded params", () => {
19+
// Expected behavior:
20+
// Input: "2% 0 g - 2"
21+
// Output: "/malformed/2%25%200%20g%20-%202"
22+
23+
// If we pass a raw string, it should be encoded.
24+
expect(generatePath("/malformed/:id", { id: "2% 0 g - 2" })).toBe(
25+
"/malformed/2%25%200%20g%20-%202"
26+
);
27+
28+
// If we pass an already encoded string (or partially encoded), it should NOT be double encoded if we use safeEncode?
29+
// The prompt says: "Prevent re-encoding already-encoded sequences."
30+
// So if we pass "2%200", it should remain "2%200" (if that was the intent).
31+
// But "2% 0" should become "2%25%200".
32+
33+
// The bug report says "Actual: 2%%200%20g%20-%202".
34+
// This suggests that currently something is producing this wrong value.
35+
});
36+
37+
it("does not double-encode already encoded sequences", () => {
38+
// This is the core of the fix requirement.
39+
// If I have "%20", it should stay "%20".
40+
// If I have " ", it should become "%20".
41+
42+
// Currently generatePath uses encodeURIComponent.
43+
// encodeURIComponent("%20") -> "%2520".
44+
// encodeURIComponent(" ") -> "%20".
45+
46+
// With safeEncode:
47+
// safeEncode("%20") -> "%20".
48+
// safeEncode(" ") -> "%20".
49+
50+
expect(generatePath("/:id", { id: "%20" })).toBe("/%20");
51+
expect(generatePath("/:id", { id: " " })).toBe("/%20");
52+
53+
// Mixed: "2% 0" -> "2%25%200"
54+
expect(generatePath("/:id", { id: "2% 0" })).toBe("/2%25%200");
55+
56+
// Mixed: "2%200" -> "2%200"
57+
expect(generatePath("/:id", { id: "2%200" })).toBe("/2%200");
58+
});
59+
});

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

Lines changed: 79 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -89,29 +89,29 @@ type JsonValue = JsonPrimitive | JsonObject | JsonArray;
8989
*/
9090
export type Submission =
9191
| {
92-
formMethod: FormMethod;
93-
formAction: string;
94-
formEncType: FormEncType;
95-
formData: FormData;
96-
json: undefined;
97-
text: undefined;
98-
}
92+
formMethod: FormMethod;
93+
formAction: string;
94+
formEncType: FormEncType;
95+
formData: FormData;
96+
json: undefined;
97+
text: undefined;
98+
}
9999
| {
100-
formMethod: FormMethod;
101-
formAction: string;
102-
formEncType: FormEncType;
103-
formData: undefined;
104-
json: JsonValue;
105-
text: undefined;
106-
}
100+
formMethod: FormMethod;
101+
formAction: string;
102+
formEncType: FormEncType;
103+
formData: undefined;
104+
json: JsonValue;
105+
text: undefined;
106+
}
107107
| {
108-
formMethod: FormMethod;
109-
formAction: string;
110-
formEncType: FormEncType;
111-
formData: undefined;
112-
json: undefined;
113-
text: string;
114-
};
108+
formMethod: FormMethod;
109+
formAction: string;
110+
formEncType: FormEncType;
111+
formData: undefined;
112+
json: undefined;
113+
text: string;
114+
};
115115

116116
/**
117117
* A context instance used as the key for the `get`/`set` methods of a
@@ -320,13 +320,13 @@ export type MiddlewareFunction<Result = unknown> = (
320320
* Arguments passed to loader functions
321321
*/
322322
export interface LoaderFunctionArgs<Context = DefaultContext>
323-
extends DataFunctionArgs<Context> {}
323+
extends DataFunctionArgs<Context> { }
324324

325325
/**
326326
* Arguments passed to action functions
327327
*/
328328
export interface ActionFunctionArgs<Context = DefaultContext>
329-
extends DataFunctionArgs<Context> {}
329+
extends DataFunctionArgs<Context> { }
330330

331331
/**
332332
* Loaders and actions can return anything
@@ -615,8 +615,8 @@ export function isUnsupportedLazyRouteFunctionKey(
615615
*/
616616
export type LazyRouteObject<R extends AgnosticRouteObject> = {
617617
[K in keyof R as K extends UnsupportedLazyRouteObjectKey
618-
? never
619-
: K]?: () => Promise<R[K] | null | undefined>;
618+
? never
619+
: K]?: () => Promise<R[K] | null | undefined>;
620620
};
621621

622622
/**
@@ -626,7 +626,7 @@ export type LazyRouteObject<R extends AgnosticRouteObject> = {
626626
export interface LazyRouteFunction<R extends AgnosticRouteObject> {
627627
(): Promise<
628628
Omit<R, UnsupportedLazyRouteFunctionKey> &
629-
Partial<Record<UnsupportedLazyRouteFunctionKey, never>>
629+
Partial<Record<UnsupportedLazyRouteFunctionKey, never>>
630630
>;
631631
}
632632

@@ -709,34 +709,34 @@ type RegexMatchPlus<
709709
T extends string,
710710
> = T extends `${infer First}${infer Rest}`
711711
? First extends CharPattern
712-
? RegexMatchPlus<CharPattern, Rest> extends never
713-
? First
714-
: `${First}${RegexMatchPlus<CharPattern, Rest>}`
715-
: never
712+
? RegexMatchPlus<CharPattern, Rest> extends never
713+
? First
714+
: `${First}${RegexMatchPlus<CharPattern, Rest>}`
715+
: never
716716
: never;
717717

718718
// Recursive helper for finding path parameters in the absence of wildcards
719719
type _PathParam<Path extends string> =
720720
// split path into individual path segments
721721
Path extends `${infer L}/${infer R}`
722-
? _PathParam<L> | _PathParam<R>
723-
: // find params after `:`
724-
Path extends `:${infer Param}`
725-
? Param extends `${infer Optional}?${string}`
726-
? RegexMatchPlus<ParamChar, Optional>
727-
: RegexMatchPlus<ParamChar, Param>
728-
: // otherwise, there aren't any params present
729-
never;
722+
? _PathParam<L> | _PathParam<R>
723+
: // find params after `:`
724+
Path extends `:${infer Param}`
725+
? Param extends `${infer Optional}?${string}`
726+
? RegexMatchPlus<ParamChar, Optional>
727+
: RegexMatchPlus<ParamChar, Param>
728+
: // otherwise, there aren't any params present
729+
never;
730730

731731
export type PathParam<Path extends string> =
732732
// check if path is just a wildcard
733733
Path extends "*" | "/*"
734-
? "*"
735-
: // look for wildcard at the end of the path
736-
Path extends `${infer Rest}/*`
737-
? "*" | _PathParam<Rest>
738-
: // look for params in the absence of wildcards
739-
_PathParam<Path>;
734+
? "*"
735+
: // look for wildcard at the end of the path
736+
Path extends `${infer Rest}/*`
737+
? "*" | _PathParam<Rest>
738+
: // look for params in the absence of wildcards
739+
_PathParam<Path>;
740740

741741
// eslint-disable-next-line @typescript-eslint/no-unused-vars
742742
type _tests = [
@@ -790,7 +790,7 @@ export interface AgnosticRouteMatch<
790790
}
791791

792792
export interface AgnosticDataRouteMatch
793-
extends AgnosticRouteMatch<string, AgnosticDataRouteObject> {}
793+
extends AgnosticRouteMatch<string, AgnosticDataRouteObject> { }
794794

795795
function isIndexRoute(
796796
route: AgnosticRouteObject,
@@ -817,7 +817,7 @@ export function convertRoutesToDataRoutes(
817817
invariant(
818818
allowInPlaceMutations || !manifest[id],
819819
`Found a route id collision on id "${id}". Route ` +
820-
"id's must be globally unique within Data Router usages",
820+
"id's must be globally unique within Data Router usages",
821821
);
822822

823823
if (isIndexRoute(route)) {
@@ -864,11 +864,11 @@ function mergeRouteUpdates<T extends AgnosticDataRouteObject>(
864864
...updates,
865865
...(typeof updates.lazy === "object" && updates.lazy != null
866866
? {
867-
lazy: {
868-
...route.lazy,
869-
...updates.lazy,
870-
},
871-
}
867+
lazy: {
868+
...route.lazy,
869+
...updates.lazy,
870+
},
871+
}
872872
: {}),
873873
});
874874
}
@@ -1046,8 +1046,8 @@ function flattenRoutes<
10461046
invariant(
10471047
meta.relativePath.startsWith(parentPath),
10481048
`Absolute route path "${meta.relativePath}" nested under path ` +
1049-
`"${parentPath}" is not valid. An absolute child route path ` +
1050-
`must start with the combined path of all its parent routes.`,
1049+
`"${parentPath}" is not valid. An absolute child route path ` +
1050+
`must start with the combined path of all its parent routes.`,
10511051
);
10521052

10531053
meta.relativePath = meta.relativePath.slice(parentPath.length);
@@ -1065,7 +1065,7 @@ function flattenRoutes<
10651065
// @ts-expect-error
10661066
route.index !== true,
10671067
`Index routes must not have child routes. Please remove ` +
1068-
`all child routes from route path "${path}".`,
1068+
`all child routes from route path "${path}".`,
10691069
);
10701070
flattenRoutes(
10711071
route.children,
@@ -1166,9 +1166,9 @@ function rankRouteBranches(branches: RouteBranch[]): void {
11661166
a.score !== b.score
11671167
? b.score - a.score // Higher score first
11681168
: compareIndexes(
1169-
a.routesMeta.map((meta) => meta.childrenIndex),
1170-
b.routesMeta.map((meta) => meta.childrenIndex),
1171-
),
1169+
a.routesMeta.map((meta) => meta.childrenIndex),
1170+
b.routesMeta.map((meta) => meta.childrenIndex),
1171+
),
11721172
);
11731173
}
11741174

@@ -1211,13 +1211,13 @@ function compareIndexes(a: number[], b: number[]): number {
12111211

12121212
return siblings
12131213
? // If two routes are siblings, we should try to match the earlier sibling
1214-
// first. This allows people to have fine-grained control over the matching
1215-
// behavior by simply putting routes with identical paths in the order they
1216-
// want them tried.
1217-
a[a.length - 1] - b[b.length - 1]
1214+
// first. This allows people to have fine-grained control over the matching
1215+
// behavior by simply putting routes with identical paths in the order they
1216+
// want them tried.
1217+
a[a.length - 1] - b[b.length - 1]
12181218
: // Otherwise, it doesn't really make sense to rank non-siblings by index,
1219-
// so they sort equally.
1220-
0;
1219+
// so they sort equally.
1220+
0;
12211221
}
12221222

12231223
function matchRouteBranch<
@@ -1312,9 +1312,9 @@ export function generatePath<Path extends string>(
13121312
warning(
13131313
false,
13141314
`Route path "${path}" will be treated as if it were ` +
1315-
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
1316-
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
1317-
`please change the route path to "${path.replace(/\*$/, "/*")}".`,
1315+
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
1316+
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
1317+
`please change the route path to "${path.replace(/\*$/, "/*")}".`,
13181318
);
13191319
path = path.replace(/\*$/, "/*") as Path;
13201320
}
@@ -1342,7 +1342,7 @@ export function generatePath<Path extends string>(
13421342
const [, key, optional] = keyMatch;
13431343
let param = params[key as PathParam<Path>];
13441344
invariant(optional === "?" || param != null, `Missing ":${key}" param`);
1345-
return encodeURIComponent(stringify(param));
1345+
return safeEncode(stringify(param));
13461346
}
13471347

13481348
// Remove any optional markers from optional static segments
@@ -1478,9 +1478,9 @@ export function compilePath(
14781478
warning(
14791479
path === "*" || !path.endsWith("*") || path.endsWith("/*"),
14801480
`Route path "${path}" will be treated as if it were ` +
1481-
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
1482-
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
1483-
`please change the route path to "${path.replace(/\*$/, "/*")}".`,
1481+
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
1482+
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
1483+
`please change the route path to "${path.replace(/\*$/, "/*")}".`,
14841484
);
14851485

14861486
let params: CompiledPathParam[] = [];
@@ -1536,8 +1536,8 @@ export function decodePath(value: string) {
15361536
warning(
15371537
false,
15381538
`The URL path "${value}" could not be decoded because it is a ` +
1539-
`malformed URL segment. This is probably due to a bad percent ` +
1540-
`encoding (${error}).`,
1539+
`malformed URL segment. This is probably due to a bad percent ` +
1540+
`encoding (${error}).`,
15411541
);
15421542

15431543
return value;
@@ -1568,6 +1568,12 @@ export function stripBasename(
15681568
return pathname.slice(startIndex) || "/";
15691569
}
15701570

1571+
1572+
function safeEncode(value: string): string {
1573+
return encodeURIComponent(value).replace(/%25([0-9A-F]{2})/gi, "%$1");
1574+
}
1575+
1576+
15711577
export function prependBasename({
15721578
basename,
15731579
pathname,
@@ -1612,7 +1618,7 @@ export function resolvePath(to: To, fromPathname = "/"): Path {
16121618
warning(
16131619
false,
16141620
`Pathnames cannot have embedded double slashes - normalizing ` +
1615-
`${oldPathname} -> ${toPathname}`,
1621+
`${oldPathname} -> ${toPathname}`,
16161622
);
16171623
}
16181624
if (toPathname.startsWith("/")) {

0 commit comments

Comments
 (0)