fix(app): decode percent-encoded path segments to prevent auth bypass · h3js/h3@313ea52
1+import supertest, { SuperTest, Test } from "supertest";
2+import { describe, it, expect, beforeEach } from "vitest";
3+import {
4+createApp,
5+createRouter,
6+App,
7+Router,
8+toNodeListener,
9+eventHandler,
10+getHeader,
11+createError,
12+getRouterParams,
13+getQuery,
14+useBase,
15+} from "../src";
16+17+describe("security: path encoding bypass", () => {
18+let app: App;
19+let router: Router;
20+let request: SuperTest<Test>;
21+22+beforeEach(() => {
23+app = createApp({ debug: false });
24+25+// Middleware that protects /api/admin routes
26+app.use(
27+eventHandler((event) => {
28+if (event.path.startsWith("/api/admin")) {
29+const token = getHeader(event, "authorization");
30+if (token !== "Bearer admin-secret-token") {
31+throw createError({ statusCode: 403, statusMessage: "Forbidden" });
32+}
33+}
34+}),
35+);
36+37+router = createRouter();
38+39+// Protected admin endpoint with dynamic param
40+router.get(
41+"/api/admin/:action",
42+eventHandler((event) => {
43+const params = getRouterParams(event, { decode: true });
44+return { admin: true, action: params.action };
45+}),
46+);
47+48+// Public endpoint
49+router.get(
50+"/api/public",
51+eventHandler(() => {
52+return { public: true };
53+}),
54+);
55+56+app.use(router);
57+request = supertest(toNodeListener(app)) as any;
58+});
59+60+it("blocks unauthenticated access to /api/admin/users", async () => {
61+const res = await request.get("/api/admin/users");
62+expect(res.status).toBe(403);
63+});
64+65+it("allows authenticated access to /api/admin/users", async () => {
66+const res = await request
67+.get("/api/admin/users")
68+.set("Authorization", "Bearer admin-secret-token");
69+expect(res.status).toBe(200);
70+expect(res.body).toEqual({ admin: true, action: "users" });
71+});
72+73+it("allows access to public endpoint", async () => {
74+const res = await request.get("/api/public");
75+expect(res.status).toBe(200);
76+});
77+78+// Percent-encoding a single character in the protected prefix
79+it("should NOT bypass auth via /api/%61dmin/users (%61 = a)", async () => {
80+const res = await request.get("/api/%61dmin/users");
81+expect(res.status).not.toBe(200);
82+});
83+84+it("should NOT bypass auth via /api/admi%6e/users (%6e = n)", async () => {
85+const res = await request.get("/api/admi%6e/users");
86+expect(res.status).not.toBe(200);
87+});
88+89+// Encoding in a different path segment
90+it("should NOT bypass auth via /%61pi/admin/users (%61 = a)", async () => {
91+const res = await request.get("/%61pi/admin/users");
92+expect(res.status).not.toBe(200);
93+});
94+95+// Double-encoded percent (%25 = literal %) should not resolve to the protected path
96+it("should NOT bypass auth via double encoding /api/%2561dmin/users", async () => {
97+const res = await request.get("/api/%2561dmin/users");
98+expect(res.status).not.toBe(200);
99+});
100+101+// Uppercase hex variants
102+it("should NOT bypass auth via /api/%41dmin/users (uppercase %41 = A)", async () => {
103+const res = await request.get("/api/%41dmin/users");
104+expect(res.status).not.toBe(200);
105+});
106+107+// Multiple encoded characters
108+it("should NOT bypass auth via fully encoded /api/admin path", async () => {
109+// /api/%61%64%6d%69%6e/users = /api/admin/users
110+const res = await request.get("/api/%61%64%6d%69%6e/users");
111+expect(res.status).not.toBe(200);
112+});
113+});
114+115+describe("security: path encoding bypass with wildcard routes", () => {
116+let app: App;
117+let router: Router;
118+let request: SuperTest<Test>;
119+120+beforeEach(() => {
121+app = createApp({ debug: false });
122+123+// Middleware protecting /api/admin
124+app.use(
125+eventHandler((event) => {
126+if (event.path.startsWith("/api/admin")) {
127+const token = getHeader(event, "authorization");
128+if (token !== "Bearer admin-secret-token") {
129+throw createError({ statusCode: 403, statusMessage: "Forbidden" });
130+}
131+}
132+}),
133+);
134+135+router = createRouter();
136+137+// Catch-all route (simulates Nuxt/Nitro file-based routing)
138+router.get(
139+"/api/**",
140+eventHandler((event) => {
141+return { path: event.path, params: getRouterParams(event) };
142+}),
143+);
144+145+app.use(router);
146+request = supertest(toNodeListener(app)) as any;
147+});
148+149+it("blocks /api/admin/users without auth via wildcard", async () => {
150+const res = await request.get("/api/admin/users");
151+expect(res.status).toBe(403);
152+});
153+154+it("should NOT bypass auth with wildcard via /api/%61dmin/users", async () => {
155+const res = await request.get("/api/%61dmin/users");
156+expect(res.status).not.toBe(200);
157+});
158+159+it("should NOT bypass auth with wildcard via /api/admi%6e/users", async () => {
160+const res = await request.get("/api/admi%6e/users");
161+expect(res.status).not.toBe(200);
162+});
163+});
164+165+describe("path decoding: no regressions", () => {
166+let app: App;
167+let router: Router;
168+let request: SuperTest<Test>;
169+170+beforeEach(() => {
171+app = createApp({ debug: false });
172+router = createRouter();
173+174+// Echo handler returning path and query info
175+router.get(
176+"/echo/**",
177+eventHandler((event) => {
178+return {
179+path: event.path,
180+query: getQuery(event),
181+params: getRouterParams(event),
182+};
183+}),
184+);
185+186+router.get(
187+"/item/:id",
188+eventHandler((event) => {
189+return {
190+path: event.path,
191+params: getRouterParams(event),
192+};
193+}),
194+);
195+196+app.use(router);
197+request = supertest(toNodeListener(app)) as any;
198+});
199+200+it("preserves query strings without double-decoding", async () => {
201+const res = await request.get("/echo/test?val=%2561");
202+expect(res.status).toBe(200);
203+// %2561 should stay raw in the query portion of event.path
204+expect(res.body.path).toBe("/echo/test?val=%2561");
205+// getQuery decodes once: %2561 -> %61 (literal percent-sixty-one)
206+expect(res.body.query.val).toBe("%61");
207+});
208+209+it("preserves encoded values in query strings", async () => {
210+const res = await request.get(
211+"/echo/test?name=hello%20world&redirect=%2Fhome",
212+);
213+expect(res.status).toBe(200);
214+expect(res.body.query.name).toBe("hello world");
215+expect(res.body.query.redirect).toBe("/home");
216+});
217+218+it("preserves query string with multiple params", async () => {
219+const res = await request.get("/echo/test?a=1&b=2&c=%263");
220+expect(res.status).toBe(200);
221+expect(res.body.query).toMatchObject({ a: "1", b: "2", c: "&3" });
222+});
223+224+it("decodes path but not query in the same request", async () => {
225+// Path has %74 (t), query has %2561 (should stay as-is)
226+const res = await request.get("/echo/%74est?key=%2561");
227+expect(res.status).toBe(200);
228+expect(res.body.path).toBe("/echo/test?key=%2561");
229+expect(res.body.query.key).toBe("%61");
230+});
231+232+it("preserves encoded slash %2F in path (not decoded to /)", async () => {
233+const res = await request.get("/echo/a%2Fb");
234+expect(res.status).toBe(200);
235+// decodePath from ufo preserves %2F
236+expect(res.body.path).toBe("/echo/a%2Fb");
237+});
238+239+it("decodes space %20 in path segments", async () => {
240+const res = await request.get("/echo/hello%20world");
241+expect(res.status).toBe(200);
242+expect(res.body.path).toBe("/echo/hello world");
243+});
244+245+it("handles already-decoded paths unchanged", async () => {
246+const res = await request.get("/echo/normal/path");
247+expect(res.status).toBe(200);
248+expect(res.body.path).toBe("/echo/normal/path");
249+});
250+251+it("handles path with no query string", async () => {
252+const res = await request.get("/item/42");
253+expect(res.status).toBe(200);
254+expect(res.body.path).toBe("/item/42");
255+expect(res.body.params).toEqual({ id: "42" });
256+});
257+258+it("handles empty query string", async () => {
259+const res = await request.get("/echo/test?");
260+expect(res.status).toBe(200);
261+// Node.js/supertest strips trailing empty "?", so path has no query
262+expect(res.body.path).toBe("/echo/test");
263+});
264+265+it("originalUrl preserves the raw request URL", async () => {
266+const router2 = createRouter();
267+const app2 = createApp({ debug: false });
268+let capturedOriginalUrl: string | undefined;
269+app2.use(
270+eventHandler((event) => {
271+capturedOriginalUrl = event.node.req.originalUrl;
272+}),
273+);
274+router2.get(
275+"/test/**",
276+eventHandler(() => "ok"),
277+);
278+app2.use(router2);
279+const req2 = supertest(toNodeListener(app2)) as any;
280+await req2.get("/test/%61bc");
281+expect(capturedOriginalUrl).toBe("/test/%61bc");
282+});
283+});
284+285+describe("path decoding with useBase", () => {
286+let app: App;
287+let request: SuperTest<Test>;
288+289+beforeEach(() => {
290+app = createApp({ debug: false });
291+const baseHandler = eventHandler((event) => {
292+return { path: event.path };
293+});
294+app.use("/api", useBase("/api", baseHandler));
295+request = supertest(toNodeListener(app)) as any;
296+});
297+298+it("decodes path with useBase prefix", async () => {
299+const res = await request.get("/api/t%65st");
300+expect(res.status).toBe(200);
301+// useBase strips the /api prefix, so handler sees /test
302+expect(res.body.path).toBe("/test");
303+});
304+});
305+306+describe("path decoding with onRequest hook", () => {
307+it("event.path is decoded in onRequest", async () => {
308+let hookPath: string | undefined;
309+const app = createApp({
310+debug: false,
311+onRequest(event) {
312+hookPath = event.path;
313+},
314+});
315+const router = createRouter();
316+router.get(
317+"/api/**",
318+eventHandler(() => "ok"),
319+);
320+app.use(router);
321+const request = supertest(toNodeListener(app)) as any;
322+await request.get("/api/%61dmin");
323+expect(hookPath).toBe("/api/admin");
324+});
325+});