Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion crates/perry-runtime/src/string/base64_codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,22 @@ pub extern "C" fn js_atob(value: f64) -> *const StringHeader {
throw_invalid_character();
}
match base64::engine::general_purpose::STANDARD_NO_PAD.decode(&cleaned) {
Ok(decoded) => js_string_from_bytes(decoded.as_ptr(), decoded.len() as u32),
Ok(decoded) => {
// Spec (`atob`): the result is a "binary string" — each UTF-16
// code unit equals one decoded byte (0-255), so `charCodeAt(i)`
// returns byte `i`. Perry strings are UTF-8 backed, so the raw
// decoded bytes CANNOT be handed straight to `js_string_from_bytes`:
// any byte >= 0x80 is invalid UTF-8 on its own and `charCodeAt`
// then mis-decodes it (jose/Auth.js decode a JWE's random binary
// IV/ciphertext/tag through `atob`, so high bytes silently
// corrupted the session token → login "JWTSessionError"). Map each
// byte to its Latin-1 code point (U+0000..U+00FF) and encode THAT
// as UTF-8, which round-trips back to the byte value under
// `charCodeAt`. `btoa` already reads char code points and rejects
// > 0xff, so this is its exact inverse.
let s: String = decoded.iter().map(|&b| b as char).collect();
js_string_from_bytes(s.as_ptr(), s.len() as u32)
}
Err(_) => throw_invalid_character(),
}
}
Expand Down
27 changes: 27 additions & 0 deletions test-files/test_gap_atob_binary_high_bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// `atob` must return a "binary string" — each UTF-16 code unit equals one
// decoded byte (0-255), so `charCodeAt(i)` recovers byte i exactly, including
// bytes >= 128. Perry stored the raw decoded bytes as UTF-8, so high bytes
// (invalid UTF-8 on their own) were mis-decoded by charCodeAt — corrupting any
// binary payload round-tripped through base64 (jose/Auth.js decode a JWE's
// random binary IV/ciphertext/tag this way, which broke session-token
// decryption → login failure).

// Direct decode of bytes fa fb fc c8 80 7f.
const b = atob("+vv8yIB/");
console.log("length:", b.length);
console.log("codes:", Array.from(b, (c) => c.charCodeAt(0)).join(","));

// btoa/atob round-trip over the full 0..255 byte range.
let src = "";
for (let i = 0; i < 256; i++) src += String.fromCharCode(i);
const round = atob(btoa(src));
let ok = round.length === 256;
for (let i = 0; i < 256 && ok; i++) ok = round.charCodeAt(i) === i;
console.log("full 0-255 round-trip:", ok);

// ASCII still decodes correctly (the JSON-header case that always worked).
console.log("ascii:", atob("eyJhIjoxfQ"));

// A byte with the high bit set on its own.
const hi = atob("gA=="); // single byte 0x80
console.log("single high byte:", hi.length, hi.charCodeAt(0));
Loading