Skip to content

Commit 9581407

Browse files
committed
feat(resolveDotSegments): add mergeSlashes option
1 parent 95ea248 commit 9581407

3 files changed

Lines changed: 137 additions & 7 deletions

File tree

docs/2.utils/4.security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,6 @@ Also decodes percent-encoded dot segments at any `%25`-nesting depth (`%2e`, `%2
140140

141141
`%2f`/`%5c` (encoded path separators) are left untouched by default — see {@link ResolveDotSegmentsOptions.decodeSlashes}.
142142

143-
Only `.`/`..` resolution and the decodes above alter the string; every other percent-encoding (`%20`, non-ASCII, `%3A`, and any `%2e` not forming a whole segment) is left intact, so the result stays in the same representation as an un-decoded `event.url.pathname` and matches routes/rules consistently. Interior empty segments are preserved (`/a//b` stays `/a//b`, per WHATWG URL normalization) — only the leading slash is guaranteed single, so a consumer doing exact prefix matching should normalize its allowlist the same way.
143+
Only `.`/`..` resolution and the decodes above alter the string; every other percent-encoding (`%20`, non-ASCII, `%3A`, and any `%2e` not forming a whole segment) is left intact, so the result stays in the same representation as an un-decoded `event.url.pathname` and matches routes/rules consistently. Interior empty segments are preserved (`/a//b` stays `/a//b`, per WHATWG URL normalization) — only the leading slash is guaranteed single, so a consumer doing exact prefix matching should normalize its allowlist the same way. To collapse them instead (the reading a slash-merging downstream resolves), see {@link ResolveDotSegmentsOptions.mergeSlashes}.
144144

145145
<!-- /automd -->

src/utils/path.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,33 @@ export interface ResolveDotSegmentsOptions {
3131
* @default false
3232
*/
3333
decodeSlashes?: boolean;
34+
35+
/**
36+
* Collapse runs of consecutive path separators (interior empty `//` segments)
37+
* instead of preserving them, producing the *maximal-traversal* canonical
38+
* form — the path a slash-merging downstream (nginx `merge_slashes`, or any
39+
* backend that decodes then normalizes) actually resolves. It operates on the
40+
* separator set that is active after the normalizations above: a literal `/`,
41+
* a `\` normalized to `/`, and — with {@link decodeSlashes} — a decoded
42+
* `%2f`/`%5c` (so the same bounded `%25`-nesting boundary is inherited, and a
43+
* hex-of-hex form like `%25%32%66` is no more collapsed here than it is
44+
* decoded there).
45+
*
46+
* This is the reading in which a `..` next to an empty segment is no longer
47+
* shielded by it: `/a//..` resolves to `/`, not `/a`. The two readings diverge
48+
* exactly there, so a scope check that only looks at the empty-preserving form
49+
* can pass a path that still escapes downstream. Enable this for a fail-closed
50+
* scope/security check that must also hold against a slash-merging downstream
51+
* — but note a `/`-splitting router (rou3) does not merge slashes, so this
52+
* form is one of two readings such a check has to consider, not a replacement
53+
* for the other. Never use the result for routing/dispatch.
54+
*
55+
* Only *runs* collapse: a single trailing slash is preserved (`/a/` stays
56+
* `/a/`, `/a//` becomes `/a/`), as with nginx.
57+
*
58+
* @default false
59+
*/
60+
mergeSlashes?: boolean;
3461
}
3562

3663
// A dot segment — the only `.`-related input that changes the path — is a `.`
@@ -68,7 +95,9 @@ const ENCODED_DOT_RE_G = /%(?:25)*2e/gi;
6895
* un-decoded `event.url.pathname` and matches routes/rules consistently.
6996
* Interior empty segments are preserved (`/a//b` stays `/a//b`, per WHATWG URL
7097
* normalization) — only the leading slash is guaranteed single, so a consumer
71-
* doing exact prefix matching should normalize its allowlist the same way.
98+
* doing exact prefix matching should normalize its allowlist the same way. To
99+
* collapse them instead (the reading a slash-merging downstream resolves), see
100+
* {@link ResolveDotSegmentsOptions.mergeSlashes}.
72101
*/
73102
export function resolveDotSegments(path: string, opts?: ResolveDotSegmentsOptions): string {
74103
// Normalize to a single leading slash (treating a leading `\` as a
@@ -78,12 +107,18 @@ export function resolveDotSegments(path: string, opts?: ResolveDotSegmentsOption
78107
path = "/" + path.replace(/^[/\\]+/, "");
79108
}
80109
const decodeSlashes = opts?.decodeSlashes;
81-
// A `\` always needs normalizing, and (with `decodeSlashes`) an encoded
82-
// separator always needs decoding — both are cheap `includes`/`test` scans
83-
// checked first so a dot-free path skips the dot-segment regex entirely.
110+
const mergeSlashes = opts?.mergeSlashes;
111+
// A `\` always needs normalizing, (with `decodeSlashes`) an encoded separator
112+
// always needs decoding, and (with `mergeSlashes`) a literal separator run
113+
// always needs collapsing — all cheap `includes`/`test` scans checked first so
114+
// a dot-free path skips the dot-segment regex entirely. An encoded separator
115+
// only forms a run once decoded, so `hasEncodedSep` already covers the runs
116+
// `mergeSlashes` can additionally see; the leading normalization above has
117+
// taken care of any leading run, so a remaining `//` is interior or trailing.
84118
const hasBackslash = path.includes("\\");
85119
const hasEncodedSep = decodeSlashes && ENCODED_SEP_RE.test(path);
86-
if (!hasBackslash && !hasEncodedSep && !DOT_SEGMENT_RE.test(path)) {
120+
const hasSepRun = mergeSlashes && path.includes("//");
121+
if (!hasBackslash && !hasEncodedSep && !hasSepRun && !DOT_SEGMENT_RE.test(path)) {
87122
return path;
88123
}
89124
// Normalize backslashes to forward slashes to prevent traversal via `\`
@@ -92,8 +127,10 @@ export function resolveDotSegments(path: string, opts?: ResolveDotSegmentsOption
92127
normalized = normalized.replace(ENCODED_SEP_RE_G, "/");
93128
}
94129
const segments = normalized.split("/");
130+
const lastIndex = segments.length - 1;
95131
const resolved: string[] = [];
96-
for (const segment of segments) {
132+
for (let i = 0; i <= lastIndex; i++) {
133+
const segment = segments[i]!;
97134
// Decode percent-encoded dots at any %25-nesting depth (%2e, %252e, ...)
98135
// to catch multi-encoded traversal — skipped for the common `%`-free
99136
// segment, which cannot contain an encoded dot.
@@ -105,6 +142,12 @@ export function resolveDotSegments(path: string, opts?: ResolveDotSegmentsOption
105142
if (resolved.length > 1) {
106143
resolved.pop();
107144
}
145+
} else if (mergeSlashes && normalizedSegment === "" && i > 0 && i < lastIndex) {
146+
// Drop an empty segment that is neither the root (`i === 0`, always empty
147+
// since `path` starts with `/`) nor the trailing one — exactly the
148+
// separators a `/{2,}` -> `/` collapse would remove. Skipping them here,
149+
// rather than collapsing the string first, is equivalent and lets a `..`
150+
// that an empty segment would otherwise shield pop its real parent.
108151
} else if (normalizedSegment !== ".") {
109152
resolved.push(segment);
110153
}

test/unit/path.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,91 @@ describe("resolveDotSegments", () => {
104104
expect(resolveDotSegments("/api/orders/%252e%252e/admin")).toBe("/api/admin");
105105
expect(resolveDotSegments("/a/%25252e%25252e/b")).toBe("/b");
106106
});
107+
108+
it("preserves interior empty segments by default", () => {
109+
expect(resolveDotSegments("/api/orders//list.json")).toBe("/api/orders//list.json");
110+
// The empty segment shields the `..` from popping `a`
111+
expect(resolveDotSegments("/a//..")).toBe("/a");
112+
expect(resolveDotSegments("/api/foo/%2e%2e/%2fadmin/secret", { decodeSlashes: true })).toBe(
113+
"/api//admin/secret",
114+
);
115+
});
116+
117+
describe("mergeSlashes", () => {
118+
it("collapses runs of literal separators", () => {
119+
expect(resolveDotSegments("/api/orders//list.json", { mergeSlashes: true })).toBe(
120+
"/api/orders/list.json",
121+
);
122+
expect(resolveDotSegments("/a////b", { mergeSlashes: true })).toBe("/a/b");
123+
});
124+
125+
it("resolves a `..` an empty segment would otherwise shield", () => {
126+
expect(resolveDotSegments("/a//..", { mergeSlashes: true })).toBe("/");
127+
expect(resolveDotSegments("/a/b//../c", { mergeSlashes: true })).toBe("/a/c");
128+
expect(resolveDotSegments("/a//../..", { mergeSlashes: true })).toBe("/");
129+
});
130+
131+
it("collapses runs formed by decoded separators", () => {
132+
// The maximal-traversal reading: `..` traverses and reaches /api/admin,
133+
// which is what a slash-merging downstream resolves.
134+
expect(
135+
resolveDotSegments("/api/foo/%2e%2e/%2fadmin/secret", {
136+
decodeSlashes: true,
137+
mergeSlashes: true,
138+
}),
139+
).toBe("/api/admin/secret");
140+
expect(
141+
resolveDotSegments("/api/foo/%2e%2e/%2fadmin", { decodeSlashes: true, mergeSlashes: true }),
142+
).toBe("/api/admin");
143+
expect(resolveDotSegments("/a/%2f%2fb", { decodeSlashes: true, mergeSlashes: true })).toBe(
144+
"/a/b",
145+
);
146+
// ...at any `%25`-nesting depth, and for `%5c` too
147+
expect(
148+
resolveDotSegments("/a/%252f%255cb", { decodeSlashes: true, mergeSlashes: true }),
149+
).toBe("/a/b");
150+
expect(
151+
resolveDotSegments("/a/b/%2e%2e/%252f..%2fadmin", {
152+
decodeSlashes: true,
153+
mergeSlashes: true,
154+
}),
155+
).toBe("/admin");
156+
});
157+
158+
it("collapses runs formed by normalized backslashes", () => {
159+
expect(resolveDotSegments("/a/\\/b", { mergeSlashes: true })).toBe("/a/b");
160+
expect(resolveDotSegments("/a\\\\..", { mergeSlashes: true })).toBe("/");
161+
});
162+
163+
it("leaves an encoded separator opaque without decodeSlashes", () => {
164+
// `%2f` is not part of the active separator set, so it forms no run.
165+
expect(resolveDotSegments("/a/%2f%2fb", { mergeSlashes: true })).toBe("/a/%2f%2fb");
166+
expect(resolveDotSegments("/a/%2f..", { mergeSlashes: true })).toBe("/a/%2f..");
167+
});
168+
169+
it("collapses only runs, preserving a single trailing slash", () => {
170+
expect(resolveDotSegments("/a/", { mergeSlashes: true })).toBe("/a/");
171+
expect(resolveDotSegments("/a//", { mergeSlashes: true })).toBe("/a/");
172+
expect(resolveDotSegments("/a//b/../", { mergeSlashes: true })).toBe("/a/");
173+
expect(resolveDotSegments("/", { mergeSlashes: true })).toBe("/");
174+
});
175+
176+
it("still never escapes above the root or yields a protocol-relative path", () => {
177+
expect(resolveDotSegments("/..//../etc/passwd", { mergeSlashes: true })).toBe("/etc/passwd");
178+
expect(resolveDotSegments("//evil.com", { mergeSlashes: true })).toBe("/evil.com");
179+
expect(
180+
resolveDotSegments("/app/..%2f..%2f%2fevil.com/steal", {
181+
decodeSlashes: true,
182+
mergeSlashes: true,
183+
}),
184+
).toBe("/evil.com/steal");
185+
});
186+
187+
it("keeps a run-free path on the fast path", () => {
188+
expect(resolveDotSegments("/assets/app.1a2b.js", { mergeSlashes: true })).toBe(
189+
"/assets/app.1a2b.js",
190+
);
191+
expect(resolveDotSegments("/foo%20bar", { mergeSlashes: true })).toBe("/foo%20bar");
192+
});
193+
});
107194
});

0 commit comments

Comments
 (0)