Skip to content

fetch: SharedArrayBuffer-backed view as request body is silently coerced to a string (lossy data corruption) #5596

Description

@pcroarkin

Bug Description

When a fetch/Request body is an ArrayBufferView backed by a SharedArrayBuffer, undici does not treat it as a BufferSource. Instead the value falls through the BodyInit union conversion to string coercion, so the request body becomes String(view) — silent, lossy data corruption with no error thrown.

Two flavors, depending on the view type:

  • a plain Uint8Array over a SAB stringifies via %TypedArray%.prototype.toString → the body is literally "255,216,255,219";
  • a Node.js Buffer over a SAB stringifies via Buffer.prototype.toString('utf8') → the body is a lossy UTF-8 decode/re-encode of the bytes (every byte ≥ 0x80 becomes ef bf bd, JPEG magic ff d8 ff db arrives as ef bf bd ef bf bd …, payload inflated ~1.1–1.7×).

Per WebIDL, BufferSource without [AllowShared] must reject views backed by a SharedArrayBuffer with a TypeError (see whatwg/webidl#353). #4495 already tracked this non-compliance and described the then-current behavior as "views on SharedArrayBuffers are accepted, and result in the source being copied into a new SharedArrayBuffer internally" — i.e. bytes were at least preserved. Current behavior is worse than that description: the view is coerced to a string, so the bytes are destroyed.

Silent corruption rather than a loud TypeError makes this very hard to diagnose in production (see "real-world impact" below).

Reproducible By

// node >= 20, no dependencies; same result with npm undici@8.9.0 (import { Request } from 'undici')
const bytes = [255, 216, 255, 219]; // JPEG magic

const sabView = new Uint8Array(new SharedArrayBuffer(4));
sabView.set(bytes);
const req1 = new Request('http://example.com/', { method: 'POST', body: sabView });
console.log(Buffer.from(await req1.arrayBuffer()).toString());
// "255,216,255,219"  <-- String(view), 15 bytes, data destroyed

const sabBuf = Buffer.from(new SharedArrayBuffer(4));
Buffer.from(bytes).copy(sabBuf);
const req2 = new Request('http://example.com/', { method: 'POST', body: sabBuf });
console.log(Buffer.from(await req2.arrayBuffer()).toString('hex'));
// "efbfbdefbfbdefbfbdefbfbd"  <-- lossy UTF-8 roundtrip of ff d8 ff db

// control: same bytes in a normal ArrayBuffer-backed view are sent verbatim
const ok = new Request('http://example.com/', { method: 'POST', body: new Uint8Array(bytes) });
console.log(Buffer.from(await ok.arrayBuffer()).toString('hex')); // "ffd8ffdb"

Expected Behavior

A TypeError at Request construction / fetch() call time (spec behavior for a BufferSource not annotated [AllowShared]), or at minimum byte-preserving acceptance — anything but silent string coercion.

Logs & Screenshots

Observed identically with:

  • undici 7.22.0 bundled in Node.js v25.8.1 (darwin-arm64)
  • undici bundled in Node.js v24.18.0 (linux-x64, Vercel)
  • undici 8.9.0 from npm

Environment

macOS 15.1 arm64 / Node v25.8.1, and Amazon Linux (Vercel) x64 / Node v24.18.0.

Real-world impact

sharp 0.35's wasm32 runtime returns toBuffer() results as zero-copy Buffer views into the wasm heap, which is a SharedArrayBuffer (threaded Emscripten build). Since sharp 0.35 the wasm runtime installs on every platform and is a silent last-resort fallback when the native binary fails to load — which currently happens on popular stacks (Next.js 16 Turbopack on Vercel, lovell/sharp#4567). The combination means fetch(url, { method: 'POST', body: await sharp(...).toBuffer() }) silently uploads UTF-8-mangled garbage. We hit this in production: ~3,300 corrupted image uploads (supabase-js storage) over a day, zero errors raised anywhere, and no local reproduction because dev machines load the native runtime. Filed on the sharp side as lovell/sharp#4576.

Additional context

The Buffer-over-SAB case is the nastier one: the output is 100% valid UTF-8 that still looks like binary at a glance, and the size inflation depends on payload entropy, so checksum/length monitoring is the only way to catch it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions