Skip to content

bufferBodyWithin ignores request.signal and has no time bound: an aborted upload buffers forever, a slowloris body is bounded only by size #3218

Description

@frenzzy

Summary

The buffering loop in bufferBodyWithin bounds the request body by size and by nothing else. It never consults request.signal, and there is no deadline or rate floor anywhere in the file (grep -i timeout server.ts returns nothing).

Two consequences: an aborted request keeps being buffered and the handler never returns, and a slowloris body occupies a request slot for as long as the sender likes while staying under the size bound.

Reproduction

const ac = new AbortController();
let pulls = 0, sourceCancelled = false;
const slow = new ReadableStream({
  pull(c) {                                    // 1 byte every 5 ms
    pulls++;
    return new Promise(res => setTimeout(() => { c.enqueue(new Uint8Array(1)); res(); }, 5));
  },
  cancel() { sourceCancelled = true; }
});

const pending = handleServerFunctionRequest(new Request("http://x/_server/g", {
  method: "POST", body: slow, duplex: "half", signal: ac.signal,
  headers: { "Sec-Fetch-Site": "same-origin", "x-server-function-format": "8" }
}));
setTimeout(() => ac.abort(new Error("client gone")), 200);

Observed on next @ ee73e053:

abort during buffering: outcome=STILL BUFFERING after=1501ms pulls=264 sourceCancelled=false

The abort landed at 200 ms. At 1501 ms the loop is still reading, the source has never been cancelled, and the handler promise has not settled. My probe process then hung until I killed it — the handler never returns at all.

At that rate the default 1 MiB bound is reached after roughly 87 minutes, so the size bound is not a meaningful backstop for a slow sender.

Where

packages/web/server-functions/src/server.ts:1148-1163

async function bufferBodyWithin(request, limit) {
  const reader = request.clone().body.getReader();
  const chunks = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > limit) { reader.cancel().catch(() => {}); return null; }
    chunks.push(value);
    

Introduced in 51392f36 feat(web): bound server-function request payloads (#3115, #3119).

Scope, honestly stated

On stock Node/undici the platform errors the body stream when the client goes away, so in that deployment you land in #3217 (a rejection) rather than a hang. The hang is reachable on any adapter that builds a Request with a signal but does not error the body — and the slowloris half needs no adapter quirk at all: a sender that stays under the size bound is bounded by nothing.

This is the one place in an otherwise careful signal story where the signal is not consulted; every streaming path in this file does honour it (server.ts:2163 wires an abort listener, and teardown there is clean — I verified readers, sources and timers are all released).

Options

  1. Consult the signal in the loop — reject or return null when request.signal.aborted, and cancel the reader. Minimal, and it matches how the rest of the file treats the signal.
  2. Add a time bound alongside the size bound — a deadline, or a minimum-rate floor. Bounds a slowloris that never aborts, which (1) does not.
  3. Both, since they close different halves: (1) is about a client that left, (2) about one that is still there and dribbling.

Size, time and liveness are three separate bounds; the function currently has one. Whether the other two belong here or in the adapter is a judgement call I would rather leave to you — but with bodySizeLimit living in this config, a caller could reasonably expect its companions to as well.

Regression test

it("stops buffering when the request is aborted", async () => {
  const ac = new AbortController();
  const pending = handleServerFunctionRequest(slowUpload(id, ac.signal));
  ac.abort();
  await expect(withTimeout(pending, 200)).resolves.toBeDefined();  // settles, not hangs
});

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