Skip to content

http: fix use-after-free of the redirect URL on a retried request - #33242

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/3a1887b0/http-retry-redirect-uaf
Jul 2, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/3a1887b0/http-retry-redirect-uaf

Conversation

@robobun

@robobun robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

After a 3xx redirect, handle_response_metadata rewrites per-hop request state on the HTTP-thread clone of the AsyncHTTP:

  • client.url (and connected_url) become a self-borrow into client.redirect, a Vec<u8> the clone owns and frees in the final-callback teardown (AsyncHTTP::on_async_http_callback_raw).
  • On a cross-origin hop, Authorization/Proxy-Authorization/Cookie/Host are removed from client.header_entries in place.
  • The method may be downgraded to GET.

NetworkTask::notify's bitwise copy-back (ptr::write(real, ptr::read(async_http))) carries all of that into the JS-thread AsyncHTTP. When bun install retries the task after a retryable failure (5xx or a connection reset on the redirect target), the re-scheduled request therefore:

  1. connects through the freed redirect buffer (use after free), and
  2. if the redirect was cross-origin, goes out without Authorization, so an authorized registry answers 401.

ASAN (debug build), deterministic on the first try:

ERROR: AddressSanitizer: heap-use-after-free ... thread T1 (HTTP Client)
READ of size 1
    #0 bun_core::fmt::parse_int::<u16>              src/bun_core/fmt.rs:929
    #1 <bun_url::URL>::get_port                     src/url/lib.rs:470
    #2 <bun_url::URL>::get_port_auto                src/url/lib.rs:474
    #3 <bun_http::http_thread::HttpThread>::connect src/http/HTTPThread.rs:602
    #4 <bun_http::HTTPClient>::start_               src/http/lib.rs:2635
    #6 <bun_http::async_http::AsyncHTTP>::on_start  src/http/AsyncHTTP.rs:893
freed by thread T1 (HTTP Client):
    <bun_http::async_http::AsyncHTTP>::on_async_http_callback_raw src/http/AsyncHTTP.rs:774
previously allocated by thread T1 (HTTP Client):
    <bun_http::HTTPClient>::handle_response_metadata src/http/lib.rs:5038

On a release build the same sequence does not crash, but the retries never reach the server (each one connects through freed memory) and the install fails.

Repro

A scripted registry where the manifest URL 302-redirects and the redirect target answers a 500 once, then the real packument:

GET /BaR              -> 302 Location: /redirected/BaR
GET /redirected/BaR   -> 500 on the first hit, then the packument
GET /BaR-0.0.2.tgz    -> tarball

bun install against it aborts under ASAN and fails on release. Any 301/302/307/308 and 1- or 2-hop chains hit the same path. With an authorized registry that redirects cross-origin (the common Artifactory / CodeArtifact / GitHub Packages shape), the retry also loses Authorization; that variant fails with GET <registry>/BaR - 401 even once the URL is fixed.

Fix

src/http/AsyncHTTP.rs: the !has_more teardown block already releases every clone-owned allocation. Before freeing client.redirect, restore the per-hop state that a re-scheduled attempt must not inherit:

  • client.url back to the caller-owned pre-redirect URL (AsyncHTTP.url, which borrows memory valid for the original's whole lifetime), and client.connected_url (which connect derives from it) to default.
  • client.header_entries back to the untouched AsyncHTTP.request_headers. The list is bitwise-shared with the JS-thread original, so it must not be dropped or reallocated on the HTTP thread; it was cloned from request_headers at init and only ever shrinks, so clear_retaining_capacity() + append_list_assume_capacity() restores it in place.
  • client.method back to AsyncHTTP.method.

Nothing that crosses back to the JS thread references clone-freed memory anymore, and a retried request restarts from the original URL with the original headers instead of the last redirect hop's, which is what the install-level retry is meant to do.

Tests

test/cli/install/bun-install-retry.test.ts:

  • retries a manifest whose redirect target 500s once
  • retries a tarball whose redirect target 500s once (the sibling retry site in runTasks)
  • retries an authorized manifest whose cross-origin redirect target 500s once (also asserts the cross-origin hop itself still does NOT carry Authorization, so the spec-mandated strip is unchanged)

All three fail on the unfixed build (ASAN abort under bun bd, install error with USE_SYSTEM_BUN=1). The third additionally fails with a 401 if only the URL is restored and not the headers, so each restore is load-bearing. test/js/web/fetch/fetch-redirect.test.ts and fetch-url-after-redirect.test.ts still pass, so response.url after a redirect is unaffected (it comes from the owned metadata.url copy, not from client.url).

After a redirect, handle_response_metadata repoints client.url at a
self-borrow into client.redirect, a Vec the HTTP-thread clone owns and
frees in the final-callback teardown (on_async_http_callback_raw). The
bitwise copy-back into the JS-thread AsyncHTTP (NetworkTask::notify)
kept those dangling slices, so when bun install retried the request
after a retryable failure (5xx or connection reset) on the redirect
target, the retry connected through freed memory.

ASAN: heap-use-after-free READ on the HTTP-client thread in
URL::get_port <- HTTPThread::connect. In release builds the retries
never reached the server at all and the install failed.

Restore the caller-owned pre-redirect URL (and reset connected_url,
which is derived from it) in the same teardown that frees the redirect
buffer. This also makes a retry restart from the original URL rather
than the last redirect hop, which is what the install retry intends.
@github-actions github-actions Bot added the claude label Jul 2, 2026
@robobun

robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 749eecc6-91b8-4b63-b3d6-eea7bdb87698

📥 Commits

Reviewing files that changed from the base of the PR and between 48158ae and 15c4a1a.

📒 Files selected for processing (2)
  • src/http/AsyncHTTP.rs
  • test/cli/install/bun-install-retry.test.ts

Walkthrough

Updates redirect cleanup in async HTTP to restore per-hop client URL state before teardown, and expands bun install retry coverage to verify manifest and tarball redirect chains after transient 500 responses.

Changes

Redirect Retry URL Fix

Layer / File(s) Summary
Reset client URL fields in cleanup path
src/http/AsyncHTTP.rs
Clears and rebuilds client.header_entries, then snapshots and restores client.url, client.connected_url, and client.method before redirect-owned state is dropped.
Install retry regression tests for redirect chains
test/cli/install/bun-install-retry.test.ts
Adds manifest, cross-origin authorized manifest, and tarball redirect retry tests, and removes the previous generic retry-on-500 test.

Possibly related PRs

  • oven-sh/bun#29894: Updates redirect URL lifetime handling across redirect hops, which matches the cleanup-state change in on_async_http_callback_raw.
  • oven-sh/bun#32702: Adjusts HTTP client header state handling during request building, which is adjacent to the per-request state reset in this PR.
🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes an HTTP redirect retry bug, but #39 asks for Node.js-compatible build output and related blockers, so it doesn't satisfy the issue. Address #39's Node.js output blockers instead, such as require/runtime separation, node:* externals, build parallelism, and CommonJS or loader support.
Out of Scope Changes check ⚠️ Warning All code changes are for HTTP redirect retry safety and tests, which are unrelated to the linked Node.js build-output requirements. Re-scope the PR to the linked Node.js output issue, or move this HTTP bugfix to a separate unrelated PR.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing a redirect URL use-after-free on retried HTTP requests.
Description check ✅ Passed The description matches the changeset by explaining the redirect retry use-after-free fix and the added install retry tests.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/bun-install-retry.test.ts`:
- Around line 93-97: The install retry tests are asserting on generated files
before checking the process exit status, which can hide the real failure behind
an ENOENT. In the affected test cases in bun-install-retry.test.ts, move the
exit-code assertion (and any early stdout/stderr status guard) ahead of the
filesystem reads for node_modules/BaR/package.json, keeping the existing
artifact checks afterward. Apply the same ordering in both test blocks so the
install diagnostics from the retry flow are surfaced first via the exitCode
assertion and then the package.json validation runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5488f293-cbe0-43b7-9191-e7ba82795938

📥 Commits

Reviewing files that changed from the base of the PR and between 5b55beb and 84b84b5.

📒 Files selected for processing (2)
  • src/http/AsyncHTTP.rs
  • test/cli/install/bun-install-retry.test.ts

Comment thread test/cli/install/bun-install-retry.test.ts
Matches the existing test in this file. A failed install now surfaces the
exit code and process output instead of an ENOENT on node_modules.
Comment thread src/http/AsyncHTTP.rs
robobun and others added 2 commits July 2, 2026 05:53
…t too

A cross-origin redirect strips Authorization/Proxy-Authorization/Cookie/Host
from client.header_entries in place (handle_response_metadata), and the same
bitwise copy-back that leaked the dangling URL leaked the stripped list into
the JS-thread AsyncHTTP. A retried request to an authorized registry whose
redirect target failed transiently then went out without Authorization and
got a 401.

header_entries is bitwise-shared with the JS-thread original, so it must not
be dropped or reallocated on the HTTP thread. It was cloned from
request_headers at init and only ever shrinks, so restore it in place with
clear_retaining_capacity + append_list_assume_capacity. Also restore
client.method, which the same function may downgrade to GET for a redirect.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/cli/install/bun-install-retry.test.ts (1)

43-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move bug history out of test comments.

These comments document implementation history and ASAN details rather than the durable invariant. Keep the test comment to the expected behavior, and leave the detailed failure narrative in the PR description.

As per coding guidelines, comments should be concise, avoid bug history, and carry only durable non-obvious content.

Also applies to: 100-104

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/install/bun-install-retry.test.ts` around lines 43 - 47, The comment
in bun-install-retry.test.ts currently records bug history and ASAN internals
instead of the lasting test invariant. Update the comment near the
retry/redirect scenario in this test to state only the expected behavior:
retries after a 302/500 chain must restart from the original URL and still reach
the server. Remove the implementation-history narrative and memory-safety
details from the test comment, including the similar block referenced elsewhere
in the test file.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/AsyncHTTP.rs`:
- Around line 783-791: Replace the release-only safety gap in AsyncHTTP’s header
repopulation path: the capacity invariant checked with debug_assert! before
append_list_assume_capacity must also hold in release builds. Update the code
around client.header_entries and request_headers to use assert! or a checked
append path before calling clear_retaining_capacity and
append_list_assume_capacity, so unchecked mutation cannot proceed if the
invariant is violated.

---

Outside diff comments:
In `@test/cli/install/bun-install-retry.test.ts`:
- Around line 43-47: The comment in bun-install-retry.test.ts currently records
bug history and ASAN internals instead of the lasting test invariant. Update the
comment near the retry/redirect scenario in this test to state only the expected
behavior: retries after a 302/500 chain must restart from the original URL and
still reach the server. Remove the implementation-history narrative and
memory-safety details from the test comment, including the similar block
referenced elsewhere in the test file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b5416c60-c407-4c35-8bc2-fefb02db07bc

📥 Commits

Reviewing files that changed from the base of the PR and between 6f1f74a and 48158ae.

📒 Files selected for processing (2)
  • src/http/AsyncHTTP.rs
  • test/cli/install/bun-install-retry.test.ts

Comment thread src/http/AsyncHTTP.rs Outdated
append_list_assume_capacity writes into the shared header allocation
unchecked; a broken capacity invariant there would corrupt memory rather
than just misbehave, so the assertion must survive release builds.

Also trim the new test comments to the behavior they assert.
@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for this PR, since the red here is not from the diff.

The diff is complete: the use-after-free fix, the header/method restore it exposed, the release-safe capacity assertion from review, and 3 regression tests that fail on main (ASAN abort under bun bd, install error with USE_SYSTEM_BUN=1) and pass with the fix. All review threads are resolved.

Every failure in build 67881 traces to something unrelated:

  • darwin 26 aarch64 - test-bun (both shards): buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. The shards never got a binary; zero tests ran.
  • test/bake/dev/production.test.ts (Windows 2019 x64 and x64-baseline, 4/4 attempts): failing identically with an error annotation on 13 of the last 14 PR builds across unrelated branches, so it is currently broken on main, not caused by this PR. It never touches the HTTP client.
  • test/cli/install/migration/complex-workspace.test.ts (x64-asan) and test/cli/update_interactive_install.test.ts (Windows): each failed once and passed on the built-in retry, and neither shard is in the build's failed-job set. Both do real-network installs.

Re-running CI on this branch cannot go green while bake/dev/production.test.ts is red for every PR, so I am not pushing a retrigger. Happy to rebase once that is fixed on main if it helps.

@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.

My earlier header_entries concern was addressed well in 48158ae (and the capacity-invariant hardening in 15c4a1a is the right call). No further issues found, but this restores state into an allocation that's bitwise-shared with the JS-thread original inside an unsafe teardown path — a maintainer should confirm the no-drop/no-realloc contract holds across all copy-back consumers (NetworkTask::notify, FetchTasklet, send_sync, preconnect).

Extended reasoning...

Overview

This PR fixes a use-after-free in src/http/AsyncHTTP.rs (on_async_http_callback_raw's !has_more teardown block) where, after a 3xx redirect, client.url was left as a self-borrow into client.redirect — which the same block frees — and the bitwise copy-back in NetworkTask::notify propagated that dangling slice (plus a stripped header list and a possibly-downgraded method) into the JS-thread original. A retried bun install request would then connect through freed memory. The fix restores client.url/connected_url/method from the untouched AsyncHTTP-level fields and repopulates client.header_entries in-place from request_headers via clear_retaining_capacity() + append_list_assume_capacity(), guarded by a release-mode assert! on the capacity invariant. Three new tests in test/cli/install/bun-install-retry.test.ts cover the manifest, tarball, and cross-origin-authorized variants.

Security risks

None introduced. The change is a memory-safety fix; the cross-origin Authorization strip on the redirect hop itself is preserved (and now explicitly asserted by the third test via cdnAuth = [null, null]). No new attack surface, auth logic, or user-controlled input parsing.

Level of scrutiny

High. This is ~26 lines of new native code inside an unsafe block that manipulates an allocation (header_entries) which is bitwise-shared between the HTTP thread and the JS thread via ptr::read/ptr::write. The correctness of append_list_assume_capacity — an unchecked write — depends on a non-local invariant (the list was cloned from request_headers at init and only ever shrinks via ordered_remove in handle_response_metadata). The author's response to my earlier inline comment demonstrated a solid grasp of why a naive .clone() assignment would double-free here, and the release-mode assert! is the right backstop. But per this repo's own review history, cross-thread bitwise-shared ownership is the single most-blocked category, and the fix's soundness depends on how four separate consumers (NetworkTask::notify, FetchTasklet, send_sync, preconnect) copy back or don't. That's a maintainer-level confirmation, not a bot approval.

Other factors

  • My prior inline comment (header restoration gap on cross-origin retry) was addressed in 48158ae with a fix that correctly improved on my suggested diff; the follow-up debug_assert! → assert! hardening in 15c4a1a is also sound. All review threads are resolved.
  • Test coverage is thorough: each of the three restores (URL, headers, method) is load-bearing for at least one test, and the PR description confirms all three fail on the unfixed build.
  • The bug-hunting system found no issues on the current revision.
  • CI build #67881 is in progress; status not yet visible in the timeline.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

The review summary above asks a maintainer to confirm the no-drop/no-realloc contract across the copy-back consumers. Laying out what I verified so that confirmation is a read rather than a re-derivation.

The restore itself never drops or reallocates anything.
clear_retaining_capacity() only sets len = 0, and append_list_assume_capacity() only bumps len and copy_rows_froms into the existing allocation (src/collections/multi_array_list.rs:1150, :1050). Neither allocates, frees, or runs element destructors, and HeaderEntry is two StringPointer { u32, u32 } with no Drop. client.url, connected_url, and method are slice/enum fields with no Drop either. So the change adds zero ownership edges: it rewrites the contents of the already-shared allocation in place and leaves every existing drop site exactly as it was.

Per consumer:

  • NetworkTask::notify (ptr::write(real, ptr::read(clone))): the copy-back was already bitwise and the (ptr, cap) already identical on both sides; the only thing that changes is which len and element bytes get written back. The real remains the sole owner and drops the list once when the task returns to the pool.
  • FetchTasklet::sync_progress_from and send_sync_callback: neither copies header_entries at all, so the real's field is never overwritten and its single Drop is unchanged. The threadlocal clone's storage is raw-deallocated without running Drop, same as before.
  • Preconnect::on_result: drops the whole real AsyncHTTP once. Unchanged.
  • S3 (simple_request.rs:514, download_stream.rs:375): frees request_headers and client.header_entries on the real explicitly at its own completion (the existing comment there calls them "the two copies clear_data() skips"). Unchanged.

The capacity invariant the assert! guards.
client.header_entries is created in exactly one place, AsyncHTTP::init, as headers.clone() of the same list stored in request_headers, and both index the same buffer (client.header_buf == AsyncHTTP.request_header_buf). Grepping the whole workspace, the only post-init mutation of client.header_entries anywhere is the ordered_remove in handle_response_metadata (lib.rs:5163, :5191), which only shrinks; nothing ever reassigns it (HeaderBuilder::apply, which would, has no callers). So capacity() >= request_headers.len() always holds, and the restore reproduces the exact init-time entry set against the correct buffer. If a future change does start growing or replacing that list, the release-mode assert! trips loudly on the very first request instead of letting the unchecked write proceed.

@Jarred-Sumner
Jarred-Sumner merged commit d4f3c54 into main Jul 2, 2026
69 of 76 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/3a1887b0/http-retry-redirect-uaf branch July 2, 2026 06:44
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