docs: stop double-encoding redirectTo in the Task 7 plan snippet

The previous fix used encodeURI to keep a non-ASCII redirect target from
throwing inside new Response. But encodeURI also escapes "%", so an
already-percent-encoded target is corrupted: /login?next=%2Fdash becomes
/login?next=%252Fdash, which single-decodes to the literal "%2Fdash" rather
than the intended path. That is the most common real use of redirectTo -
"send them to login, then bounce back".

Replaced with headerSafePath, a codepoint loop that encodes only what cannot
be sent in a Latin-1 header and leaves existing escapes and reserved ASCII
untouched. My prescription, my defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:07:15 +05:30
co-authored by Claude Opus 5
parent 77b9e49bf2
commit 798f56734a
@@ -2155,6 +2155,20 @@ function wantsJson(ctx: Context): boolean {
return accept.includes("application/json") && !accept.includes("text/html");
}
/**
* 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;
}
/** Reject anything that could navigate off-site or inject a header. */
function isLocalPath(value: string): boolean {
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
@@ -2196,9 +2210,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
return new Response(null, {
status: 303,
headers: {
// Header values must be Latin-1; a localized path like /accounts
// in non-ASCII would otherwise throw and 500 on a denial path.
location: encodeURI(options.redirectTo),
location: headerSafePath(options.redirectTo),
"cache-control": "private, no-store",
},
});