Skip to content

Commit 8e9993f

Browse files
committed
fix(static): prevent path traversal via double-encoded dot segments
`%252e%252e` decodes to `%2e%2e` after `decodeURI()` which bypassed `resolveDotSegments` since it only matched literal `..` segments
1 parent 0295f90 commit 8e9993f

File tree

2 files changed

+15
-6
lines changed

2 files changed

+15
-6
lines changed

src/utils/internal/path.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,25 @@ export function getPathname(path: string = "/"): string {
5656
* Decode percent-encoded pathname, preserving %25 (literal `%`).
5757
*/
5858
export function decodePathname(pathname: string): string {
59-
return decodeURI(
60-
pathname.includes("%25") ? pathname.replace(/%25/g, "%2525") : pathname,
61-
);
59+
return decodeURI(pathname.includes("%25") ? pathname.replace(/%25/g, "%2525") : pathname);
6260
}
6361

6462
export function resolveDotSegments(path: string): string {
65-
if (!path.includes(".")) {
63+
if (!path.includes(".") && !path.includes("%2")) {
6664
return path;
6765
}
6866
// Normalize backslashes to forward slashes to prevent traversal via `\`
6967
const segments = path.replaceAll("\\", "/").split("/");
7068
const resolved: string[] = [];
7169
for (const segment of segments) {
72-
if (segment === "..") {
70+
// Decode percent-encoded dots (%2e/%2E) to catch double-encoded traversal
71+
const normalized = segment.replace(/%2e/gi, ".");
72+
if (normalized === "..") {
7373
// Never pop past the root (first empty segment from leading slash)
7474
if (resolved.length > 1) {
7575
resolved.pop();
7676
}
77-
} else if (segment !== ".") {
77+
} else if (normalized !== ".") {
7878
resolved.push(segment);
7979
}
8080
}

test/static.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ describeMatrix("serve static", (t, { it, expect }) => {
130130
}
131131
});
132132

133+
it("does not pass double-encoded dot segments as traversal to backend", async () => {
134+
const res = await t.fetch("/%252e%252e/%252e%252e/etc/passwd");
135+
const text = await res.text();
136+
// After first decode: %2e%2e/%2e%2e/etc/passwd
137+
// Backend must NOT see %2e%2e which could be resolved as .. by downstream
138+
expect(text).not.toContain("%2e%2e");
139+
expect(text).not.toContain("%2E%2E");
140+
});
141+
133142
it("allows legitimate paths with dots", async () => {
134143
const allowed = [
135144
"/_...grid_123.js",

0 commit comments

Comments
 (0)