-
Notifications
You must be signed in to change notification settings - Fork 221
fix: Preserve encoded path delimiters in route discovery and matching #456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06802af
847405f
6150327
8f7874e
eb0ab60
c7c847f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,3 +78,55 @@ export function compareRoutes<T extends { pattern: string }>(a: T, b: T): number | |
| const diff = routePrecedence(a.pattern) - routePrecedence(b.pattern); | ||
| return diff !== 0 ? diff : a.pattern.localeCompare(b.pattern); | ||
| } | ||
|
|
||
| // Matches literal delimiter characters and their percent-encoded equivalents. | ||
| // Literal `/`, `#`, `?` can appear after decodeURIComponent when the input was | ||
| // originally encoded (e.g. `%2F` → `/`); they are re-encoded to preserve their | ||
| // role as delimiters. `\` is included to handle both `%5C` and Windows-style | ||
| // path separators that may appear in filesystem-derived route segments. | ||
| const PATH_DELIMITER_REGEX = /([/#?\\]|%(2f|23|3f|5c))/gi; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: This regex matches literal However, the backslash case is worth noting: literal |
||
|
|
||
| function encodePathDelimiters(segment: string): string { | ||
| return segment.replace(PATH_DELIMITER_REGEX, (char) => encodeURIComponent(char)); | ||
| } | ||
|
|
||
| /** | ||
| * Decode a filesystem or URL path segment while preserving encoded path delimiters. | ||
| * Mirrors Next.js segment-wise decoding so "%5F" becomes "_" but "%2F" stays "%2F". | ||
| */ | ||
| export function decodeRouteSegment(segment: string): string { | ||
| try { | ||
| return encodePathDelimiters(decodeURIComponent(segment)); | ||
| } catch { | ||
| return segment; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Strict variant for request pipelines that should reject malformed percent-encoding. | ||
| */ | ||
| export function decodeRouteSegmentStrict(segment: string): string { | ||
| return encodePathDelimiters(decodeURIComponent(segment)); | ||
| } | ||
|
|
||
| /** | ||
| * Normalize a pathname for route matching by decoding each segment independently. | ||
| * This prevents encoded slashes from turning into real path separators. | ||
| */ | ||
| export function normalizePathnameForRouteMatch(pathname: string): string { | ||
| return pathname | ||
| .split("/") | ||
| .map((segment) => decodeRouteSegment(segment)) | ||
| .join("/"); | ||
| } | ||
|
|
||
| /** | ||
| * Strict pathname normalization for live request handling. | ||
| * Throws on malformed percent-encoding so callers can return 400. | ||
| */ | ||
| export function normalizePathnameForRouteMatchStrict(pathname: string): string { | ||
| return pathname | ||
| .split("/") | ||
| .map((segment) => decodeRouteSegmentStrict(segment)) | ||
| .join("/"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -126,6 +126,46 @@ function __normalizePath(pathname) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Returns generated JavaScript source for route-path normalization that | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * preserves encoded path delimiters within a single segment. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * This mirrors decodeRouteSegment()/normalizePathnameForRouteMatch() in | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * routing/utils.ts so "%5F" becomes "_" while "%2F" remains "%2F". | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @param style - "modern" emits const/let, "es5" emits var | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function generateRouteMatchNormalizationCode(style: "modern" | "es5" = "modern"): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const v = style === "modern" ? "const" : "var"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const l = style === "modern" ? "let" : "var"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${v} __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function __decodeRouteSegment(segment) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: codegen The runtime This difference matters at the config headers call site: try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }If one segment is malformed (e.g. For the strict call sites (returning 400), this doesn't matter. But the non-strict codegen path should match the runtime's lenient behavior.
Suggested change
Then use |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return encodeURIComponent(char); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function __decodeRouteSegmentSafe(segment) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { return __decodeRouteSegment(segment); } catch (e) { return segment; } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function __normalizePathnameForRouteMatch(pathname) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${v} segments = pathname.split("/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${v} normalized = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (${l} i = 0; i < segments.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalized.push(__decodeRouteSegmentSafe(segments[i])); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return normalized.join("/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function __normalizePathnameForRouteMatchStrict(pathname) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${v} segments = pathname.split("/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${v} normalized = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (${l} i = 0; i < segments.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalized.push(__decodeRouteSegment(segments[i])); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return normalized.join("/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+138
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: codegen only generates a safe/lenient normalizer, but two call sites depend on it throwing for malformed input. The codegen emits But two other call sites wrap it in try/catch expecting it to throw on malformed percent-encoding:
Since The fix is to also generate a strict variant in the codegen:
Suggested change
Then update the request-handling call sites in |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Returns the generated JavaScript source for middleware pattern matching. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: stale comment. The code no longer calls
decodeURIComponentdirectly — it usesnormalizePathnameForRouteMatchStrict. Consider updating to match the new behavior: