nodejs / nodejs/undici

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

Open
#5,596 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
7.7k
Forks
880
Avg merge
2d 16h
Merged PRs (30d)
68

Description

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 https://github.com/lovell/sharp/issues/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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the fetch/Request body-conversion path and run the no-dependency reproduction in the issue on Node.js or undici. Compare SharedArrayBuffer-backed Uint8Array and Buffer bodies with the normal ArrayBuffer control, then verify the result matches WebIDL behavior by rejecting the views or preserving their bytes instead of coercing them to strings.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.