@@ -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 = / % (?: 2 5 ) * (?: 2 f | 5 c ) / 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+
196233export 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) => {
0 commit comments