diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 10c01502..f8b6ee49 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -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", }, });