Skip to content

XMLHttpRequest with responseType='arraybuffer' corrupts binary responses (fetch() is correct) #754

Description

@alexskinner

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).

Note on #716

#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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions