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
+19 -3
View File
@@ -166,6 +166,20 @@ function isLocalPath(target: string): boolean {
return true;
}
/**
* Header values must be Latin-1, so a localized path would otherwise throw
* inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
* that cannot be sent: encodeURI would also escape "%", corrupting a target
* that already carries a percent-encoded return path.
*/
function headerSafePath(value: string): string {
let out = "";
for (const character of value) {
out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
}
return out;
}
const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const;
export interface GuardOptions {
@@ -203,9 +217,11 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
if (isLocalPath(options.redirectTo)) {
return new Response(null, {
status: 303,
// encodeURI: a non-ASCII local path (e.g. a localized login route)
// is valid config but not a valid raw header value.
headers: { location: encodeURI(options.redirectTo), ...NO_STORE_HEADERS },
// headerSafePath, not encodeURI: a non-ASCII local path (e.g. a
// localized login route) is valid config but not a valid raw
// header value, while encodeURI would also mangle a target that
// already carries a percent-encoded return path.
headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS },
});
}
// JSON.stringify, not string interpolation: this branch exists precisely