Skip to content

Backend proxy buffers request bodies in memory and drops Range — unusable for binary payloads #10

Closed

Description

@herbie-bot

createBackendProxyHandler is fine for JSON APIs but cannot carry binary payloads. Three defects,
all in the same function (src/server/route-handlers.ts, v0.7.0):

const headers = new Headers();
const contentType = req.headers.get("Content-Type");
if (contentType) headers.set("Content-Type", contentType);
const accept = req.headers.get("Accept");
if (accept) headers.set("Accept", accept);
if (session?.accessToken) {
  headers.set("Authorization", `Bearer ${session.accessToken}`);
}

const hasBody = req.method !== "GET" && req.method !== "HEAD";

const response = await fetch(url.toString(), {
  method: req.method,
  headers,
  body: hasBody ? await req.arrayBuffer() : undefined,
});

1. The request body is fully buffered in memory

await req.arrayBuffer() materialises the entire upload in the Next.js container's heap before a
single byte reaches the backend. For a JSON payload that is irrelevant; for a file upload it is the
difference between a constant-memory proxy and one whose peak memory equals the largest file a user
can send. A 500 MB audio import means 500 MB resident, per concurrent upload.

Suggested fix — stream the body instead:

const response = await fetch(url.toString(), {
  method: req.method,
  headers,
  body: hasBody ? req.body : undefined,
  // @ts-expect-error — undici requires this when body is a stream
  duplex: "half",
});

duplex: "half" is mandatory in undici (Node's fetch) whenever body is a ReadableStream;
without it the call throws. Worth a comment in the code, because it looks removable and is not.

2. Range is not forwarded

Only Content-Type and Accept are copied. A client asking for a byte range gets the whole
resource, so range requests are effectively unsupported end to end even when the backend implements
them properly. This breaks seeking in <audio> and <video> elements: browsers seek by issuing
Range requests, and without them the player must download the entire file before it can jump.

The response direction already works — new Response(response.body, { headers: response.headers })
streams and passes Accept-Ranges / Content-Range through — so only the request direction is
missing.

Suggested fix: forward Range, and alongside it the conditional headers that belong to the same
mechanism: If-Range, If-None-Match, If-Modified-Since.

3. No way to forward application-specific headers

The header handling is a fixed two-entry allowlist. An application that needs to send anything else
(an idempotency key, a client-generated id, a checksum) has no way to do it through the proxy and
must move the value into the path or the body.

Suggested fix: an optional forwardRequestHeaders?: readonly string[] in BackendProxyConfig,
merged with the built-in list.

Deliberately not "forward everything". The current allowlist is doing real security work: it
keeps Cookie out of the upstream request, so the Auth.js session cookie never reaches the backend,
which authenticates by bearer token only. A blanket forward would also pass Host,
Content-Length (wrong after re-encoding) and hop-by-hop headers. The fix should extend the
allowlist, not remove it — and that reasoning is worth a comment so the next change does not
"simplify" it away.

Impact

  • open-crm / open-tasks: no effect. Both send JSON only; the buffering is invisible at their
    payload sizes and neither serves ranged media.
  • open-transcript: blocking. The application uploads meeting audio and streams it back to an
    <audio> element. Its backend streams to and from S3-compatible storage with constant memory
    precisely so that hour-long recordings are possible — a proxy that buffers in front of it defeats
    the design, and without Range the player cannot seek.

Acceptance

  • a request body of arbitrary size passes through with the proxy's memory staying flat
  • Range on a request produces 206 Partial Content with the expected Content-Range
  • forwardRequestHeaders adds the named headers and nothing else
  • Cookie and Host are still never forwarded
  • a regression test asserts the Cookie exclusion, so the allowlist cannot be widened by
    accident

Found while specifying the upload and management API for open-transcript.

Activity

  1. herbie-bot commented on Sep 3, 2026

    @herbie-bot
    Author

    A fix is up in #11.

    It streams the request body (req.body + duplex: "half") instead of buffering via arrayBuffer(), always forwards the range/conditional headers (Range, If-Range, If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since), and adds an optional case-insensitive forwardRequestHeaders allowlist with a hard deny-set (Cookie, Host, Authorization, Content-Length, Connection). Includes tests covering streaming, 206 passthrough, custom-header forwarding, and the Cookie/Host exclusion regression.

  2. added a commit that references this issue on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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