Before you start
obscura version or commit
9326880 (main)
OS and architecture
macOS arm64
How are you using obscura?
Embedded Rust library and CDP server.
Affected area
XMLHttpRequest response bodies.
Build variant
Render-enabled.
Reproduction
Serve a file containing every byte value and read it back both ways.
python3 -c "open('bytes.bin','wb').write(bytes(range(256)))"
python3 -m http.server 9977 --bind 127.0.0.1
const viaFetch = new Uint8Array(await (await fetch('bytes.bin')).arrayBuffer());
const viaXhr = await new Promise(res => {
const x = new XMLHttpRequest();
x.open('GET', 'bytes.bin', true);
x.responseType = 'arraybuffer';
x.onload = () => res(new Uint8Array(x.response));
x.send();
});
console.log(viaFetch.length, viaXhr.length); // expect 256 256
(Loopback needs OBSCURA_ALLOW_PRIVATE_NETWORK=1, or serve the fixture from anywhere else.)
Expected behavior
Both are 256 bytes and byte-for-byte identical to the file. Chrome 147:
fetch len 256 | xhr len 256 | diverge at 256
FETCH 78 79 7a 7b 7c 7d 7e 7f 80 81 82 83 84 85 86 87 88 89 8a 8b …
XHR 78 79 7a 7b 7c 7d 7e 7f 80 81 82 83 84 85 86 87 88 89 8a 8b …
Actual behavior
fetch() is correct. XMLHttpRequest returns 255 bytes, and every byte from 0x80 on is wrong:
fetch len 256 | xhr len 255 | diverge at 128
FETCH 78 79 7a 7b 7c 7d 7e 7f 80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f …
XHR 78 79 7a 7b 7c 7d 7e 7f 01 c2 83 c4 85 c6 87 c8 89 ca 8b cc 8d ce 8f d0 …
Bytes 0x00–0x7F survive; everything above is mangled. The pattern identifies the
transform exactly — the body is being decoded as UTF-8 (leniently, treating any
high byte as a lead byte) and then re-encoded:
| source bytes |
lenient UTF-8 decode |
re-encoded |
82 83 |
(0x82 & 0x1F) << 6 | (0x83 & 0x3F) = U+0083 |
c2 83 |
84 85 |
U+0105 |
c4 85 |
86 87 |
U+0187 |
c6 87 |
88 89 |
U+0209 |
c8 89 |
So the ArrayBuffer holds the UTF-8 encoding of the decoded code points rather
than the original octets, and the length changes with the content.
Because ASCII passes through untouched, JSON and text over XHR look fine, which
is why this hides.
Impact
Any binary read over XMLHttpRequest. In particular every Emscripten/WASM
application, since the generated loader fetches .wasm and data files with
XMLHttpRequest + responseType = 'arraybuffer' rather than fetch(). A real
4 MB asset came back 77,065 bytes short with a corrupted header, and the
application aborted on a nonsense allocation derived from it — the failure
surfaces far from the cause.
Does headless Chrome behave the same?
No. Chrome 147 returns 256 identical bytes on both paths (output above).
#716 covers binary request bodies (fetch(..., { body }), XHR.send()) and
the CDP fulfill path. This is the response side of XMLHttpRequest, and it
is a separate code path: fetch() responses are already byte-correct here while
XHR responses are not. Same root cause — a String where Vec<u8> belongs —
so it may well fold into the same fix, and I'm happy for this to be closed into
#716 if you'd rather track it there.
Before you start
main.obscura version or commit
9326880(main)OS and architecture
macOS arm64
How are you using obscura?
Embedded Rust library and CDP server.
Affected area
XMLHttpRequestresponse bodies.Build variant
Render-enabled.
Reproduction
Serve a file containing every byte value and read it back both ways.
python3 -c "open('bytes.bin','wb').write(bytes(range(256)))" python3 -m http.server 9977 --bind 127.0.0.1(Loopback needs
OBSCURA_ALLOW_PRIVATE_NETWORK=1, or serve the fixture from anywhere else.)Expected behavior
Both are 256 bytes and byte-for-byte identical to the file. Chrome 147:
Actual behavior
fetch()is correct.XMLHttpRequestreturns 255 bytes, and every byte from0x80on is wrong:Bytes
0x00–0x7Fsurvive; everything above is mangled. The pattern identifies thetransform exactly — the body is being decoded as UTF-8 (leniently, treating any
high byte as a lead byte) and then re-encoded:
82 83(0x82 & 0x1F) << 6 | (0x83 & 0x3F)= U+0083c2 8384 85c4 8586 87c6 8788 89c8 89So the
ArrayBufferholds the UTF-8 encoding of the decoded code points ratherthan the original octets, and the length changes with the content.
Because ASCII passes through untouched, JSON and text over XHR look fine, which
is why this hides.
Impact
Any binary read over
XMLHttpRequest. In particular every Emscripten/WASMapplication, since the generated loader fetches
.wasmand data files withXMLHttpRequest+responseType = 'arraybuffer'rather thanfetch(). A real4 MB asset came back 77,065 bytes short with a corrupted header, and the
application aborted on a nonsense allocation derived from it — the failure
surfaces far from the cause.
Does headless Chrome behave the same?
No. Chrome 147 returns 256 identical bytes on both paths (output above).
Note on #716
#716 covers binary request bodies (
fetch(..., { body }),XHR.send()) andthe CDP fulfill path. This is the response side of
XMLHttpRequest, and itis a separate code path:
fetch()responses are already byte-correct here whileXHR responses are not. Same root cause — a
StringwhereVec<u8>belongs —so it may well fold into the same fix, and I'm happy for this to be closed into
#716 if you'd rather track it there.