fix(authz): stop encodeURI from double-encoding a percent-escaped redirectTo

Fix round 3 for Task 7 (N5, minor-to-important): fix round 2's
encodeURI(options.redirectTo) fixed the non-ASCII crash but broke the
most common real use of redirectTo -- a return-path query param that's
already percent-encoded (e.g. /login?next=%2Fdash) -- because encodeURI
also escapes "%", double-encoding it to %252Fdash. Replaced with
headerSafePath(), a codepoint loop that encodes only codepoints above
0x7f (matching isLocalPath's style: no regex, no source escapes) and
leaves "%" alone.

Added tests: an already-percent-encoded target round-trips unchanged;
a non-ASCII target still 303s without throwing and the location is
ASCII-only; a plain ASCII target passes through byte-identical.
This commit is contained in:
2026-08-04 19:10:09 +05:30
parent 798f56734a
commit 3f1fcd0d2d
2 changed files with 52 additions and 7 deletions
+33 -4
View File
@@ -319,17 +319,46 @@ describe("guardPermission hardening", () => {
expect(res.headers.get("cache-control")).toBe("private, no-store");
});
test("a non-ASCII redirectTo returns 303 with an encoded location rather than throwing", async () => {
test("a non-ASCII redirectTo returns 303 without throwing, and the location is ASCII-only", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
// Built at runtime (no \u escapes in source) per the repo-wide constraint.
const target = "/" + String.fromCharCode(0x65e5) + String.fromCharCode(0x672c);
// Built at runtime via String.fromCodePoint (no non-ASCII characters
// typed into the source) per the repo-wide constraint.
const target = "/" + String.fromCodePoint(0x65e5) + String.fromCodePoint(0x672c);
const res = await guardPermission("post:write", { redirectTo: target })(
ctx,
async () => new Response("passed"),
);
expect(res.status).toBe(303);
expect(res.headers.get("location")).toBe(encodeURI(target));
const location = res.headers.get("location");
expect(location).not.toBeNull();
for (const ch of location ?? "") {
expect(ch.codePointAt(0)! <= 0x7f).toBe(true);
}
});
test("an already-percent-encoded redirectTo round-trips unchanged", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
const res = await guardPermission("post:write", { redirectTo: "/login?next=%2Fdash" })(
ctx,
async () => new Response("passed"),
);
expect(res.status).toBe(303);
const location = res.headers.get("location");
expect(location).toBe("/login?next=%2Fdash");
expect(location).not.toContain("%25");
});
test("a plain ASCII redirectTo is passed through byte-identical", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
const res = await guardPermission("post:write", { redirectTo: "/login?next=/dashboard" })(
ctx,
async () => new Response("passed"),
);
expect(res.status).toBe(303);
expect(res.headers.get("location")).toBe("/login?next=/dashboard");
});
test("redirectTo is ignored for an /api/ request, which gets 403 instead", async () => {