Skip to content

Bun.serve: sign the new Response(S3File) redirect for HEAD on a HEAD request - #44645

Open
robobun wants to merge 2 commits into
mainfrom
robobun/53fad7d1/s3-redirect-sign-at-send
Open

robobun wants to merge 2 commits into
mainfrom
robobun/53fad7d1/s3-redirect-sign-at-send

Conversation

@robobun

@robobun robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #44644 (row 1)

Problem

  • A Bun.serve handler returns new Response(s3file). GET works. HEAD ends in 403 SignatureDoesNotMatch from S3. No user reported it.
  • The constructor signs the URL for GET before a request exists (src/runtime/webcore/Response.rs:1076). SigV4 signs the method, and a client keeps HEAD across a 302.

Fix

  • The Response keeps its S3 store and the URL it wrote (S3Redirect in Init). On HEAD, Bun.serve signs for HEAD into a copy of the headers and sends the copy.
  • presign_redirect is the one signer. PUT, DELETE and POST keep the GET URL.
  • Static routes do not change (new Response(s3file): a static route and some copies answer a URL that S3 refuses #44644).
  • Verified: test/js/bun/s3/s3.test.ts (16 new tests, 15 fail on main). Self-reviewed: 30 concerns raised, 27 addressed (Notes).

Background

  • A presigned URL carries a signature over method, path, date and lifetime.
  • Init is what clone() and new Response(null, res) copy. Hono answers HEAD with the second form.
  • A choice for a maintainer: the state is on Init, so a response rebuilt from res.headers loses it.
  • Considered signing at send for GET and static routes: that needs a new route kind and a signature per static request.

Downsides

  • Every Response grows from 280 to 288 bytes (same 320-byte mimalloc block). Without S3: HEAD +2 instructions, finalize +8.
  • A handler HEAD of this redirect signs twice: 3,146 to 37,150 instructions, +20 mi_malloc.
  • A client that sends HEAD, then GET to that Location, worked before and gets 403 now.
Notes

History. #35584 was the first attempt. It signed a second time on HEAD and kept the store in a field of Response. A cleanup of stale pull requests closed it, and no person reviewed the approach. This PR keeps its idea. The state is now in Init, so the two copies that Init::clone makes carry it.

What the tests do. They start the S3 server of test/packages/s3-server in the test process. That server checks the signature of a presigned URL, the method included, and it records each request.

  • 11 forms of a handler: sync, fulfilled promise, pending promise, route function, { GET: fn }, { GET, HEAD }, error() after a throw and after a rejection, response.clone(), new Response(null, response), new Response(body, response). Each one asserts that a followed GET and a followed HEAD get 200 and that the origin saw GET 200, HEAD 200.
  • The redirect for GET, HEAD, POST, PUT and DELETE: the URL of a HEAD request is accepted for HEAD only, and each other URL for GET only. A replayed PUT and DELETE get 403, and the object is intact.
  • A Location that the handler replaced (8-bit and 16-bit string) or deleted is sent as it is. A Location that the handler set again to the same value is still signed for HEAD. An added header is kept.
  • A HEAD signature that cannot be made calls error(). The signer writes the text it signs into a buffer of 4096 bytes. The test finds the longest access key id for which the GET text fits. The HEAD text is then one byte too long.
  • After a HEAD request, the Headers object that the handler took from response.headers still has the GET URL.
  • The Response that code sees does not change: a 302, a GET URL in Location, a throw for missing credentials and for a ResponseInit. This test passes on main too.

Suites on the debug build of this branch (main at bbdc5a5). test/js/bun/s3/s3.test.ts 323 pass. test/js/bun/http/: bun-server 80, bun-serve-static 46, bun-serve-routes 61, serve-reused-response 11, bun-serve-headers 9, serve.test.ts -t HEAD 29. test/js/web/fetch/body-clone 100. test/js/web/fetch/response.test.ts: 22 pass, and handle stack overflow exceeds its 5 s limit on the debug build (5.7 s). It passes on the release build of this change. Clippy on bun_runtime and test/internal/source-lints pass.

Measurements. Release builds of main at 13a98b0 and of this change on that commit. The later rebase onto bbdc5a5 changes no file of this diff. Instruction counts are from a ptrace single-step counter over one function, callees included. perf and valgrind are not available in the container.

What main this change
size_of::<Response>() (objdump, the allocation in ResponseClass__constructError) 280 B 288 B
mimalloc block for it (mi_usable_size) 320 B 320 B
estimateShallowMemoryUsageOf(new Response()) 360 368
new Response(s3file), allocator calls per construction (gdb breakpoint counts, N = 1000 and 3000) 21.64 mi_malloc, 23.02 mi_realloc 22.64, 23.02
Signatures per request: handler GET, handler HEAD, static GET, static HEAD (gdb breakpoint on sign_request) 1, 1, 0, 0 1, 2, 0, 0
Instructions in on_response, GET with a body 4,345 4,344
The same, HEAD with a body 2,417 2,419
The same, GET with no body (a 302) 3,612 3,611
The same, HEAD with no body 3,057 3,059
The same, GET of the S3 redirect 3,701 3,657
The same, HEAD of the S3 redirect 3,146 36,967 to 37,351 in 12 calls, with 20 mi_malloc and 23 mi_realloc calls
Instructions in Response::destroy 110 118
Instructions in StaticRoute::on, a plain static route 2,513 2,513
Allocator calls that a request without S3 adds 0
Text of the release binary (size) 80,732,991 B 80,737,343 B (+4,352)
  • render_metadata, do_render_head_response, do_render_with_body, AnyRoute::from_js and StaticRoute::{on, on_head, on_request, do_write_headers, do_render_blob_corked} have the same instructions on both builds (objdump, addresses masked).
  • The bytes by nm: sign_s3_redirect_for_head 801, presign_redirect 172, render_s3_redirect_head 8 x 149, Init::clone 339 (it is no longer inlined), Response::constructor +143, Response::destroy +92.
  • The state is a 32-byte box. Its URL string is the one that the header map holds, so a clone adds no copy of the URL.

Which copies keep the state. A HEAD request, this change:

Handler returns Location is signed for
new Response(s3file), also from a subclass of Response HEAD
response.clone() HEAD
new Response(null, response), new Response(body, response) HEAD
new Response(null, response) where response has an own property, is frozen, or is a subclass instance GET
new Response(null, { status: response.status, headers: response.headers }) GET
Response.redirect(response.headers.get("location")) GET
new HTMLRewriter().transform(response) GET

The GET rows are as on main. The docs now give Response.redirect(s3file.presign({ method })) for code that does not send the Response of the constructor.

The choice. The state is on Init. That costs 8 bytes for each Response and follows clone() and new Response(null, res). A FetchHeaders member would also follow a response that code built again from res.headers. It changes the class behind each Headers, Request and Response, and the state would follow a copy of the headers into objects that are not this redirect.

The HEAD URL goes to the wire only. sign_s3_redirect_for_head copies the header map, puts the HEAD URL into the copy, and the Response sends the copy. A Headers object that the handler took from response.headers shares the first map, so it keeps the GET URL. The first push wrote into the shared map and asserted in debug builds that the send took the headers. Review found two faults in that: the Headers object of the handler showed the HEAD URL, and new Response([s3file], response) failed the assertion, because that body answers HEAD after a request to S3. That shape is the one case where the copy stays on the Response after render_s3_redirect_head returns: it is there until S3 answers. Each other body is sent in that call, and the send takes the copy.

Self-review. 30 concerns, 27 addressed. The main ones: the static route kind and the signature at send for GET are out of this PR, the state does not change after the constructor, the URL is one string that the state and the header map share, the HEAD signature is made only when a response is written, the docs example has a fixed key and shows the presign() form. Not taken:

Left for later.


[human-review] gate passed · iteration 4 · 5 files touched

fails on main (without fix)
ASAN without fix: 15 failed, 270 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/s3/s3.test.ts"
bun test v1.4.3 (367d939d9)

test/js/bun/s3/s3.test.ts:
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key does not exist
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set cont
... (truncated)

release without fix: 270 skipped
bun test v1.4.3-canary.1 (b91c9f638)

test/js/bun/s3/s3.test.ts:
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key does not exist
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-type
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-disposition
(skip) R2 > s3 > Bun.S3Client > bucket in path > sh
... (truncated)
passes on PR (with fix)
ASAN with fix: 270 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/s3/s3.test.ts"
bun test v1.4.3 (367d939d9)

test/js/bun/s3/s3.test.ts:
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key does not exist
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set cont
... (truncated)

release with fix: 270 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     c35a3e0200
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 1262ms

ninja: Entering directory `/workspace/bun/build/release'
[0/2] cargo plan → /workspace/bun/build/release/rust-target/plan.json
247 units: 175 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/2] reconfigure
[1/261] gen compressed/completions/bun.bash.zst
[2/261] gen compressed/completions/bun.fish.zst
[3/261] gen compressed/completions/bun.zsh.zst
[4/261] cc obj/codegen/InternalModuleRegistryConstants.S.o
[5/261] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 248 extern-C blocks audited
[6/261] gen cpp.rs (cppbind)
[7/261] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[8/261] rustc bun_libuv_sys 
[9/261] rustc bun_output_tags 
[10/261] rustc bun_windows_sys 
[11/261] rustc bu
... (truncated)
diff hotspot
docs/runtime/s3.mdx                  |  23 ++++
 src/runtime/server/RequestContext.rs |  73 +++++++-----
 src/runtime/webcore/Response.rs      | 115 +++++++++++++------
 src/runtime/webcore/blob/Store.rs    |  29 +++++
 test/js/bun/s3/s3.test.ts            | 217 ++++++++++++++++++++++++++++++++++-
 5 files changed, 396 insertions(+), 61 deletions(-)

gate history · 1 passed · 0 rejected · iteration 4

evidence per changed file
file                                  reads  edits  tests
docs/runtime/s3.mdx                       3      2     37
src/runtime/server/RequestContext.rs     10      4     40
src/runtime/webcore/Response.rs          11     14     45
src/runtime/webcore/blob/Store.rs         2      1     37
test/js/bun/s3/s3.test.ts                 1      0     36

@robobun

robobun commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

How this was reproduced: bun repro.mjs from #44644, against an origin on loopback that checks the signature of a presigned URL as S3 does.

On a release build of main at 13a98b0:

/handler  GET: 200  | HEAD: 403 SignatureDoesNotMatch
/clone    GET: 200  | HEAD: 403 SignatureDoesNotMatch

On a release build of this change:

/handler  GET: 200  | HEAD: 200
/clone    GET: 200  | HEAD: 200

The other rows of #44644 (a static route, a Response that is older than its signature, a Response built again from the headers) are the same on both builds. This PR does not change them.

…equest is HEAD

`new Response(s3file)` is a 302 whose Location is a presigned URL. The
constructor signs that URL for GET before a request exists. SigV4 signs
the method, and a client keeps HEAD across a 302, so S3 answers a
followed HEAD request with 403 SignatureDoesNotMatch.

The Response now keeps its S3 store and the URL that it wrote. When a
handler returns it for a HEAD request, Bun.serve signs the URL for HEAD
before it writes the headers. A Location that the handler changed is
sent as it is. `presign_redirect` is the one place that signs this
redirect, and only a HEAD request gets a HEAD signature.

Static routes are not changed.
@robobun
robobun force-pushed the robobun/53fad7d1/s3-redirect-sign-at-send branch from 3ac420d to 1759627 Compare October 6, 2026 19:11
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 5a8ae5da-7704-4b6b-9831-4e7b65e16241
📥 Commits

Reviewing files that changed from the base of the PR and between 1759627 and c35a3e0.

📒 Files selected for processing (3)
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/Response.rs
  • test/js/bun/s3/s3.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/server/RequestContext.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

S3 responses now use 15-minute presigned URLs. Bun.serve() signs HEAD requests for HEAD and other requests for GET. HEAD rendering re-signs an unchanged S3 redirect location and routes signing errors to the error handler. Tests and documentation cover method selection and response behavior.

Changes

S3 response redirects

Layer / File(s) Summary
Presigned redirect state
src/runtime/webcore/Response.rs, src/runtime/webcore/blob/Store.rs
Response stores the S3 redirect state and its original location. Redirect signing selects HEAD only for HEAD requests and GET otherwise.
HEAD response rendering
src/runtime/server/RequestContext.rs, src/runtime/webcore/Response.rs
HEAD response paths use render_head. For S3 redirects, it signs an unchanged location before rendering. Signing failures go to the error handler.
Redirect behavior validation and documentation
test/js/bun/s3/s3.test.ts, docs/runtime/s3.mdx
Tests cover request methods, response forms, location changes, and signing errors. Documentation describes URL expiration and method selection.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to c35a3

The change’s HEAD redirect behavior and documented edge cases are supported by the inspected implementation and tests. No new material merge risk was identified; proceed with normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and specifically describes the main change: signing the S3 redirect for HEAD requests in Bun.serve.
Description check ✅ Passed The description explains the problem and fix, and reports how the change was verified. It does not use the template’s exact headings, but it provides the information those sections request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the S3Redirect ownership through Init::clone/Drop (the RefPtr<Store> and the BunString are both RAII, so response.clone() and new Response(null, res) balance their refs), the detached-server branch of render_s3_redirect_head (it falls through to the normal HEAD render with the GET-signed Location, no crash), and the Latin-1 vs 16-bit Location comparison in sign_s3_redirect_for_head (compares code units, not bytes, so a user-set 16-bit header is left untouched) — none of those turned up a problem.

Extended reasoning...

The change touches the Bun.serve HEAD rendering path in RequestContext.rs, Response.rs construction and Init state, and the S3 presigning helper in Store.rs, plus tests and docs. The security-relevant surface is SigV4 presigned URL generation for S3 redirects. Four inline findings are posted, so a human look is already signalled; this note only records the adjacent concerns examined and ruled out.

Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/webcore/blob/Store.rs Resolved
Comment thread src/runtime/webcore/Response.rs Resolved
Comment thread src/runtime/webcore/blob/Store.rs Resolved
…ders

The HEAD signature was written into the header map of the Response.
A `Headers` object that the handler got from `response.headers` shares
that map, so it showed the HEAD URL after the send.

`sign_s3_redirect_for_head` now signs into a copy of the map, and the
Response sends the copy. The `Headers` object of the handler keeps the
GET URL.

The debug assertion after the send is removed. A Response that has an
S3 file in its body answers HEAD after a request to S3, so its headers
are still there when the assertion runs.

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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