Skip to content

Commit cd03d41

Browse files
pi0claude
andcommitted
fix(request): prevent decode:true from reintroducing path separators
`getRouterParams({ decode: true })` applied `decodeURIComponent` on top of the pathname that `event.ts` already decoded once (via `decodeURI`, which preserves reserved chars). A second decode could collapse encoded separators (`%2f`→`/`, `%5c`→`\`, and `%25`-nested forms) into raw separators, reintroducing `..` traversal that route matching and pathname-based middleware never saw. Decoding now skips over encoded path separators, keeping them in their encoded form while every other escape still decodes normally. Applies to `getRouterParam` and `getValidatedRouterParams` via delegation. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent ea2f2a3 commit cd03d41

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

src/utils/request.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ export function getValidatedQuery(
170170
/**
171171
* Get matched route params.
172172
*
173-
* If `decode` option is `true`, it will decode the matched route params using `decodeURIComponent`.
173+
* If `decode` option is `true`, it will decode the matched route params (like
174+
* `decodeURIComponent`), except encoded path separators (`%2f`, `%5c`) are kept
175+
* encoded so decoding can never reintroduce a `/` or `\` the router never matched.
174176
*
175177
* @example
176178
* app.get("/", (event) => {
@@ -187,12 +189,47 @@ export function getRouterParams(
187189
if (opts.decode) {
188190
params = { ...params };
189191
for (const key in params) {
190-
params[key] = decodeURIComponent(params[key]);
192+
params[key] = decodeRouterParam(params[key]);
191193
}
192194
}
193195
return params;
194196
}
195197

198+
// Percent-encoded path separators (`%2f` → `/`, `%5c` → `\`) at any `%25`-nesting
199+
// depth (`%2f`, `%252f`, ...). The pathname decode in `event.ts` (decodeURI)
200+
// deliberately preserves these, so route matching and any pathname-based
201+
// middleware only ever saw the matched param as one opaque, still-encoded
202+
// segment (a `:id` capture can never hold a raw separator).
203+
const ENCODED_SEP_RE_G = /%(?:25)*(?:2f|5c)/gi;
204+
205+
/**
206+
* `decodeURIComponent` a matched route param, but never let an encoded path
207+
* separator collapse into a raw `/` or `\`.
208+
*
209+
* A full second decode on top of the already-once-decoded pathname would
210+
* reintroduce a separator (and thus `..`-based traversal) the routing/middleware
211+
* layer could not see — a path desync / smuggling vector when the decoded param
212+
* feeds a filesystem or upstream path. So the encoded separators are kept in
213+
* their encoded form while every other escape (spaces, non-ASCII, ...) still
214+
* decodes normally, keeping `decode:true` human-readable.
215+
*/
216+
function decodeRouterParam(value: string): string {
217+
if (!value.includes("%")) {
218+
return value; // Fast path: nothing to decode.
219+
}
220+
// Decode around the encoded separators: split on them, decode the pieces, and
221+
// rejoin keeping each separator in its original (encoded) form so it can never
222+
// become a raw separator.
223+
let result = "";
224+
let lastIndex = 0;
225+
ENCODED_SEP_RE_G.lastIndex = 0;
226+
for (let m: RegExpExecArray | null; (m = ENCODED_SEP_RE_G.exec(value)); ) {
227+
result += decodeURIComponent(value.slice(lastIndex, m.index)) + m[0];
228+
lastIndex = m.index + m[0].length;
229+
}
230+
return result + decodeURIComponent(value.slice(lastIndex));
231+
}
232+
196233
export function getValidatedRouterParams<Event extends HTTPEvent, S extends StandardSchemaV1>(
197234
event: Event,
198235
validate: S,
@@ -216,7 +253,9 @@ export function getValidatedRouterParams<
216253
/**
217254
* Get matched route params and validate with validate function.
218255
*
219-
* If `decode` option is `true`, it will decode the matched route params using `decodeURIComponent`.
256+
* If `decode` option is `true`, it will decode the matched route params (like
257+
* `decodeURIComponent`), except encoded path separators (`%2f`, `%5c`) are kept
258+
* encoded so decoding can never reintroduce a `/` or `\` the router never matched.
220259
*
221260
* You can use a simple function to validate the params object or use a Standard-Schema compatible library like `zod` to define a schema.
222261
*
@@ -279,7 +318,9 @@ export function getValidatedRouterParams(
279318
/**
280319
* Get a matched route param by name.
281320
*
282-
* If `decode` option is `true`, it will decode the matched route param using `decodeURIComponent`.
321+
* If `decode` option is `true`, it will decode the matched route param (like
322+
* `decodeURIComponent`), except encoded path separators (`%2f`, `%5c`) are kept
323+
* encoded so decoding can never reintroduce a `/` or `\` the router never matched.
283324
*
284325
* @example
285326
* app.get("/", (event) => {

test/router.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,36 @@ describeMatrix("router", (t, { it, expect, describe }) => {
138138

139139
expect(await result.text()).toBe("200");
140140
});
141+
142+
it("decode does not reintroduce path separators or traversal", async () => {
143+
// `decode:true` must not be able to turn an encoded path separator
144+
// (`%2f`/`%5c`) that route matching and pathname-based middleware only
145+
// ever saw as one opaque, still-encoded segment into a raw `/` or `\`
146+
// (and thus `..`-based traversal) — a path desync / smuggling vector.
147+
t.app.get("/files/:id", (event) => {
148+
return getRouterParams(event, { decode: true }).id;
149+
});
150+
151+
const encodedSlash = await (await t.fetch("/files/%2F")).text();
152+
expect(encodedSlash).not.toContain("/");
153+
154+
const encodedBackslash = await (await t.fetch("/files/%5C")).text();
155+
expect(encodedBackslash).not.toContain("\\");
156+
157+
const encodedTraversal = await (await t.fetch("/files/%2E%2E%2Fetc")).text();
158+
expect(encodedTraversal).not.toContain("/");
159+
expect(encodedTraversal).not.toContain("../");
160+
161+
// Double-encoded separator must not decode down to a raw `/` either.
162+
const doubleEncodedSlash = await (await t.fetch("/files/%252F")).text();
163+
expect(doubleEncodedSlash).not.toContain("/");
164+
165+
// Legitimate decoding of other characters is preserved.
166+
const spaced = await (await t.fetch("/files/a%20b")).text();
167+
expect(spaced).toBe("a b");
168+
const nonAscii = await (await t.fetch("/files/caf%C3%A9")).text();
169+
expect(nonAscii).toBe("café");
170+
});
141171
});
142172

143173
describe("without router", () => {

0 commit comments

Comments
 (0)