Skip to content

Commit 483ec80

Browse files
committed
fix(codemode): block encoded dot-segment escape
Path traversal via percent-encoded dot-segments still bypassed the BACKEND_URL prefix check. RFC 3986 §3.3 treats "%2e" as identical to ".", so URL normalisation collapsed "/api/%2e%2e/admin" to "/admin" before any request-time check ran. buildUrl now decodeURIComponent()s each path segment before checking for "." / ".." and rejects invalid percent-encoding outright. Tests added: %2e%2e, %2E%2e (mixed case), %2e. (partial), %ZZ (invalid encoding). 28/28 bun pass.
1 parent 9967ae3 commit 483ec80

2 files changed

Lines changed: 93 additions & 5 deletions

File tree

codemode/workerd/api-proxy.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,21 @@ function buildUrl(base, path, query) {
7878
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) {
7979
throw new Error("path must be a server-relative path starting with '/'");
8080
}
81-
// Reject dot-segments. URL normalisation collapses "/api/../admin" to
82-
// "/admin", which would let user code escape any BACKEND_URL path
83-
// prefix and reach unrelated routes.
81+
// Reject dot-segments — both raw and percent-encoded. URL normalisation
82+
// collapses "/api/../admin" (and "/api/%2e%2e/admin", "/api/%2E./admin",
83+
// etc., because RFC 3986 §3.3 treats percent-encoded "." as identical
84+
// to literal ".") to "/admin", which would let user code escape any
85+
// BACKEND_URL path prefix and reach unrelated routes.
8486
const [pathOnly, reqQuery] = path.split("?", 2);
8587
for (const seg of pathOnly.split("/")) {
86-
if (seg === "." || seg === "..") {
87-
throw new Error("path may not contain '.' or '..' segments");
88+
let decoded;
89+
try {
90+
decoded = decodeURIComponent(seg);
91+
} catch {
92+
throw new Error("path contains invalid percent-encoding");
93+
}
94+
if (decoded === "." || decoded === "..") {
95+
throw new Error("path may not contain '.' or '..' segments (raw or encoded)");
8896
}
8997
}
9098
const baseUrl = new URL(base);

codemode/workerd/test/api-proxy.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,86 @@ describe("api-proxy", () => {
133133
expect(called).toBe(false);
134134
});
135135

136+
test("rejects percent-encoded dot-segment ('/%2e%2e/admin')", async () => {
137+
let called = false;
138+
globalThis.fetch = mock(async () => { called = true; return new Response("nope"); });
139+
const r = await apiProxy.fetch(
140+
new Request("https://internal/proxy", {
141+
method: "POST",
142+
headers: { "content-type": "application/json" },
143+
body: JSON.stringify({
144+
mode: "authenticated",
145+
method: "GET",
146+
path: "/%2e%2e/admin",
147+
authCtx: { apiKey: "k" },
148+
}),
149+
}),
150+
{ BACKEND_URL: "https://backend.test/api" },
151+
);
152+
expect(r.status).toBe(400);
153+
expect(called).toBe(false);
154+
});
155+
156+
test("rejects mixed-case encoded dot-segment ('/%2E%2e/admin')", async () => {
157+
let called = false;
158+
globalThis.fetch = mock(async () => { called = true; return new Response("nope"); });
159+
const r = await apiProxy.fetch(
160+
new Request("https://internal/proxy", {
161+
method: "POST",
162+
headers: { "content-type": "application/json" },
163+
body: JSON.stringify({
164+
mode: "authenticated",
165+
method: "GET",
166+
path: "/%2E%2e/admin",
167+
authCtx: { apiKey: "k" },
168+
}),
169+
}),
170+
{ BACKEND_URL: "https://backend.test/api" },
171+
);
172+
expect(r.status).toBe(400);
173+
expect(called).toBe(false);
174+
});
175+
176+
test("rejects partial-encoded dot-segment ('/%2e./admin')", async () => {
177+
let called = false;
178+
globalThis.fetch = mock(async () => { called = true; return new Response("nope"); });
179+
const r = await apiProxy.fetch(
180+
new Request("https://internal/proxy", {
181+
method: "POST",
182+
headers: { "content-type": "application/json" },
183+
body: JSON.stringify({
184+
mode: "authenticated",
185+
method: "GET",
186+
path: "/%2e./admin",
187+
authCtx: { apiKey: "k" },
188+
}),
189+
}),
190+
{ BACKEND_URL: "https://backend.test/api" },
191+
);
192+
expect(r.status).toBe(400);
193+
expect(called).toBe(false);
194+
});
195+
196+
test("rejects invalid percent-encoding ('/%ZZ/admin')", async () => {
197+
let called = false;
198+
globalThis.fetch = mock(async () => { called = true; return new Response("nope"); });
199+
const r = await apiProxy.fetch(
200+
new Request("https://internal/proxy", {
201+
method: "POST",
202+
headers: { "content-type": "application/json" },
203+
body: JSON.stringify({
204+
mode: "authenticated",
205+
method: "GET",
206+
path: "/%ZZ/admin",
207+
authCtx: { apiKey: "k" },
208+
}),
209+
}),
210+
{ BACKEND_URL: "https://backend.test/api" },
211+
);
212+
expect(r.status).toBe(400);
213+
expect(called).toBe(false);
214+
});
215+
136216
test("rejects dot-segment in middle of path ('/v1/foo/../../admin')", async () => {
137217
let called = false;
138218
globalThis.fetch = mock(async () => { called = true; return new Response("nope"); });

0 commit comments

Comments
 (0)