Skip to content

fix(Bun.serve): HEAD response Transfer-Encoding/Content-Length freed before write - #30155

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/5c375bbb/fix-head-response-header-uaf
May 3, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/5c375bbb/fix-head-response-header-uaf

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Repro

Bun.serve({
  port: 0,
  fetch: () =>
    new Response("hello", {
      headers: [
        ["Transfer-Encoding", "gzip"],
        ["Transfer-Encoding", "chunked"],
      ],
    }),
});
// HEAD / → ASAN heap-use-after-free in uWS::HttpResponse::writeHeader

The duplicate entries make FetchHeaders combine them via makeString(), producing a StringImpl held only by the header map — the minimal condition for the free to actually happen.

StringImpl is allocated via bmalloc which ASAN doesn't instrument by default; with Malloc=1 (bmalloc → system heap) the debug build reports:

AddressSanitizer: heap-use-after-free
READ of size 13
  #2 uWS::HttpResponse<false>::writeHeader
  #5 doRenderHeadResponse  RequestContext.zig:1378
freed by:
  #23 HTTPHeaderMap::remove
  #28 doWriteHeaders       RequestContext.zig:2303
  #29 renderMetadata       RequestContext.zig:2209
  #30 doRenderHeadResponse RequestContext.zig:1377

Cause

doRenderHeadResponse() calls headers.fastGet(.TransferEncoding), which returns a ZigString that borrows the header map entry's StringImpl bytes (no ref taken). For an ASCII value, toSlice() also borrows rather than copying. It then calls this.renderMetadata(), whose doWriteHeaders() does headers.fastRemove(.TransferEncoding) (and renderMetadata also swapInitHeaders() + deref()s the whole FetchHeaders). When the map held the only reference to the StringImpl, it's destroyed right there — and the very next line resp.writeHeader("transfer-encoding", transfer_encoding_str.slice()) writes the freed bytes to the socket.

The adjacent Content-Length branch has the same bug: std.fmt.parseInt() runs on the borrowed slice after renderMetadata() has already fastRemove(.ContentLength)'d it.

Fix

  • Transfer-Encoding: use toSliceClone() instead of toSlice() so the value is owned and survives renderMetadata().
  • Content-Length: parse the integer before renderMetadata() (and drop the slice immediately), so the borrowed bytes are never touched after the header entry is removed. No extra allocation needed since only the parsed usize is used afterwards.

Verification

New test in test/js/bun/http/bun-server.test.ts (inside the existing HEAD requests #15355 block) spawns a subprocess with Malloc=1 (non-Windows), serves HEAD responses whose Transfer-Encoding / Content-Length values are makeString()-combined (sole-owner StringImpl), and asserts the raw wire output.

git stash push -- src/   → test fails with "AddressSanitizer: heap-use-after-free" in stderr
git stash pop            → test passes

All other tests in the HEAD requests #15355 describe block continue to pass.

… value freed before use

doRenderHeadResponse() read the Transfer-Encoding / Content-Length header
values via fastGet(), which returns a ZigString borrowing the header map
entry's StringImpl. For ASCII values toSlice() keeps borrowing. It then
called renderMetadata(), which in doWriteHeaders() calls
fastRemove(.TransferEncoding/.ContentLength) and derefs the FetchHeaders,
dropping the only reference to that StringImpl when the map is the sole
owner. The subsequent writeHeader() / parseInt() read freed memory.

Clone the Transfer-Encoding value before renderMetadata(); parse
Content-Length before renderMetadata() so the borrowed slice is never
touched after the map entry is gone.
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 minutes and 49 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8389ab1c-e3d6-4662-9c1b-d281635ef33c

📥 Commits

Reviewing files that changed from the base of the PR and between 9615455 and 1dfb0d9.

📒 Files selected for processing (2)
  • src/bun.js/api/server/RequestContext.zig
  • test/js/bun/http/bun-server.test.ts

Review rate limit: 0/5 reviews remaining, refill in 10 minutes and 49 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:19 PM PT - May 2nd, 2026

❌ @robobun, your commit 1dfb0d9 has 1 failures in Build #50344 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30155

That installs a local version of the PR into your bun-30155 executable, so you can run:

bun-30155 --bun

@github-actions github-actions Bot added the claude label May 3, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues — the lifetime fix (clone for Transfer-Encoding, parse-before-free for Content-Length) looks correct and the ASAN regression test is solid — but this is a memory-safety change in the core Bun.serve request path, so it's worth a human confirming the renderMetadata() / fastRemove lifetime reasoning before merge.

Extended reasoning...

Overview

This PR fixes a heap-use-after-free in doRenderHeadResponse() (src/bun.js/api/server/RequestContext.zig). fastGet(.TransferEncoding/.ContentLength) returns a ZigString that borrows the header map's StringImpl bytes; the subsequent renderMetadata() call invokes doWriteHeaders(), which fastRemove()s those headers and derefs the FetchHeaders, freeing the borrowed bytes before they are written to the socket / parsed. The fix: (1) Transfer-Encoding now uses bun.handleOom(transfer_encoding.toSliceClone(...)) so the bytes are owned and outlive renderMetadata(); (2) Content-Length is parsed into a usize and the slice is deinit()'d before renderMetadata() runs, avoiding a clone since only the integer is needed afterwards. A new subprocess test in bun-server.test.ts reproduces the sole-owner-StringImpl condition (duplicate header entries → makeString() combine) under Malloc=1 so ASAN builds catch a regression.

Security risks

The bug being fixed is itself a memory-safety issue (UAF on the HTTP response write path). The fix strictly tightens lifetimes — cloning or consuming the borrowed bytes earlier — and introduces no new untrusted-input parsing, auth, or crypto surface. No new security risk introduced; if anything this removes one.

Level of scrutiny

High. RequestContext.zig is the per-request hot path for Bun.serve — every HTTP response flows through here. The diff is small (~10 LOC of logic), but correctness depends on subtle ownership reasoning across fastGet / renderMetadata / doWriteHeaders / fastRemove / swapInitHeaders. I verified ZigString.toSliceClone returns an owned Slice (via toOwnedSlice) and that bun.handleOom is the standard unwrap pattern, and the Content-Length reorder is straightforward. Still, memory-lifetime changes in the server core warrant a maintainer's eyes rather than bot-only approval.

Other factors

No CODEOWNERS match these files. No prior human reviews on the PR. CI build was still in progress at review time. The bug-hunting system found no issues. The added test is well-constructed (Windows-guards Malloc=1, disables LSan/symbolizer appropriately) and asserts both wire output and clean stderr/exit, so regressions should be caught in ASAN lanes.

@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Status

✅ Reproduced both UAFs under ASAN with Malloc=1 (Transfer-Encoding at RequestContext.zig:1378, Content-Length at :1388).
✅ Fix applied; gate verified locally (test fails with heap-use-after-free on main, passes on branch).
✅ bun-server.test.ts green on all CI lanes.

CI

The one red job (debian-13-x64-asan-test-bun) is test/js/web/fetch/fetch-http2-client.test.ts → "Expect: 100-continue with final status before 100 skips body upload" exiting 134 after 35s. That test spawns a subprocess doing fetch() over HTTP/2 to a raw-frame server — it doesn't touch Bun.serve or the HEAD-response path modified here. The same failure shows up on unrelated branches in builds 50331/50332/50336/50341/50350/50358, so it's pre-existing ASAN-lane flake, not introduced by this PR.

@Jarred-Sumner
Jarred-Sumner merged commit d6f2156 into main May 3, 2026
77 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/5c375bbb/fix-head-response-header-uaf branch May 3, 2026 23:49
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…before write (oven-sh#30155)

## Repro

```js
Bun.serve({
  port: 0,
  fetch: () =>
    new Response("hello", {
      headers: [
        ["Transfer-Encoding", "gzip"],
        ["Transfer-Encoding", "chunked"],
      ],
    }),
});
// HEAD / → ASAN heap-use-after-free in uWS::HttpResponse::writeHeader
```

The duplicate entries make `FetchHeaders` combine them via
`makeString()`, producing a `StringImpl` held only by the header map —
the minimal condition for the free to actually happen.

StringImpl is allocated via bmalloc which ASAN doesn't instrument by
default; with `Malloc=1` (bmalloc → system heap) the debug build
reports:

```
AddressSanitizer: heap-use-after-free
READ of size 13
  oven-sh#2 uWS::HttpResponse<false>::writeHeader
  oven-sh#5 doRenderHeadResponse  RequestContext.zig:1378
freed by:
  oven-sh#23 HTTPHeaderMap::remove
  oven-sh#28 doWriteHeaders       RequestContext.zig:2303
  oven-sh#29 renderMetadata       RequestContext.zig:2209
  oven-sh#30 doRenderHeadResponse RequestContext.zig:1377
```

## Cause

`doRenderHeadResponse()` calls `headers.fastGet(.TransferEncoding)`,
which returns a `ZigString` that **borrows** the header map entry's
`StringImpl` bytes (no ref taken). For an ASCII value, `toSlice()` also
borrows rather than copying. It then calls `this.renderMetadata()`,
whose `doWriteHeaders()` does `headers.fastRemove(.TransferEncoding)`
(and `renderMetadata` also `swapInitHeaders()` + `deref()`s the whole
`FetchHeaders`). When the map held the only reference to the
`StringImpl`, it's destroyed right there — and the very next line
`resp.writeHeader("transfer-encoding", transfer_encoding_str.slice())`
writes the freed bytes to the socket.

The adjacent `Content-Length` branch has the same bug:
`std.fmt.parseInt()` runs on the borrowed slice *after*
`renderMetadata()` has already `fastRemove(.ContentLength)`'d it.

## Fix

- **Transfer-Encoding**: use `toSliceClone()` instead of `toSlice()` so
the value is owned and survives `renderMetadata()`.
- **Content-Length**: parse the integer *before* `renderMetadata()` (and
drop the slice immediately), so the borrowed bytes are never touched
after the header entry is removed. No extra allocation needed since only
the parsed `usize` is used afterwards.

## Verification

New test in `test/js/bun/http/bun-server.test.ts` (inside the existing
`HEAD requests oven-sh#15355` block) spawns a subprocess with `Malloc=1`
(non-Windows), serves HEAD responses whose Transfer-Encoding /
Content-Length values are `makeString()`-combined (sole-owner
StringImpl), and asserts the raw wire output.

```
git stash push -- src/   → test fails with "AddressSanitizer: heap-use-after-free" in stderr
git stash pop            → test passes
```

All other tests in the `HEAD requests oven-sh#15355` describe block continue to
pass.

Co-authored-by: robobun <robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants