Skip to content

S3: read the endpoint the way new URL() reads it - #39933

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/aead0c3c/s3-endpoint-whatwg
Aug 21, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/aead0c3c/s3-endpoint-whatwg

Conversation

@robobun

@robobun robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new Bun.S3Client({ endpoint: "http://127.0.0.1:A#@127.0.0.1:B" }) signs for 127.0.0.1:B and sends PUT /bkt/k, Authorization and the body there. new URL(endpoint).host is 127.0.0.1:A. http://A\@B behaves the same. A service that checks a tenant's endpoint with new URL() sends signed requests to a host it did not approve.
  • Cause: credentials_jsc.rs:119 and env_loader.rs:281 give the raw string to bun_url::URL::parse, whose credential and host scanning stop only at / and ?, and store its host_with_path(). Dot segments and 127.1 are sent as written for the same reason.

Fix

  • URL::parse_s3_endpoint (src/url/lib.rs) keeps the existing parse host check, so the accepted set does not change. It then parses the string with WTF::URL (https:// prepended when there is no scheme) and stores its scheme, host with port, and path. When WTF::URL rejects the input, it stores what parse reads, as today. Both stores call it.
  • WTF::URL ends the authority at #, \ and the last @, resolves dot segments, canonicalizes the host and never puts credentials in it. The stored form is unchanged, so guess_region, inspect output and path-prefix endpoints still work. The notes list the visible side effects.
  • Verified: test/js/bun/s3/s3-list-encode-overflow.test.ts (the network-free presign tests), 8 of the 12 new cases fail on 1.4.0. Also test/js/bun/s3/: 156 pass, 1 timeout (notes).

Background

  • S3Credentials.endpoint stores host[:port][/prefix] without a scheme. sign_request (src/s3_signing/credentials.rs:384) signs the part before the first / as the host and sends it as Host. The stored bytes are both signed for and connected to.
  • Bun has two URL parsers. WTF::URL (WebKit) is the WHATWG parser behind new URL(). bun_url::URL::parse slices an href that is already normalized. URL::from_string and OwnedURL bridge the two.
  • Normalize http_proxy/https_proxy env values through the WHATWG parser #38043 changes URL::parse itself for user@host:port and keeps # in the authority on purpose. The two changes are independent. About url.zig #16183 is the umbrella issue for that parser.
Notes

Repro on release 1.4.0 with two Bun.serve listeners A and B on 127.0.0.1 (run without HTTP_PROXY set, since S3 ignores NO_PROXY, #32045):

endpoint http://127.0.0.1:A#@127.0.0.1:B    new URL().host = 127.0.0.1:A
  A: (nothing)
  B: PUT /bkt/k host=127.0.0.1:B x-amz-security-token=SESSIONTOKENEXAMPLE
endpoint http://127.0.0.1:A\@127.0.0.1:B    same

presign() on 1.4.0 for the spellings in the test:

http://127.0.0.1:A#@127.0.0.1:B   -> http://127.0.0.1:B/bkt/k?...
http://127.0.0.1:A\@127.0.0.1:B   -> http://127.0.0.1:B/bkt/k?...
http://127.0.0.1:A?@127.0.0.1:B   -> http://A/bkt/k?...           (the port text becomes the host)
http://u:p@ss@127.0.0.1:A         -> http://ss@127.0.0.1:A/bkt/k?...   (a request resolves ss@127.0.0.1)
http://127.0.0.1:A/x/../prefix/   -> http://127.0.0.1:A/x/../prefix/bkt/k?...
http://127.1:A                    -> http://127.1:A/bkt/k?...

With the change every one of them starts with http://127.0.0.1:A/. \@ becomes the path prefix /@127.0.0.1:B/, which is what new URL() reports for it. S3_ENDPOINT and AWS_ENDPOINT (env_loader.rs) had the same problem and get the same treatment; the test covers S3_ENDPOINT in a child process.

The test compares the presigned URL as text. new URL(presigned) would itself strip a leaked ss@, resolve /x/../ and canonicalize 127.1, and so hide the credential, dot-segment and 127.1 cases.

Why the parse host check is kept: s3.test.ts expects endpoint: "🙂.🥯" and "..asd.@%&&&%%" to throw ERR_INVALID_ARG_TYPE, and WTF::URL accepts the first as an IDN host. With the old check in front, this change only alters what is stored for endpoints that were already accepted.

Visible side effects besides the fix: a default port in the endpoint (http://h:80) is now left out of the Host header, and a mixed-case host is lowercased. Both come from the WTF::URL serialization.

Why the raw fallback: WTF::URL rejects a port such as :99999, which today is accepted and connects to 80/443. #37003 turns that into an error. This change leaves it alone so the two do not overlap.

Also unchanged: guess_bucket, the scheme-less R2 and s3.us-west-1.amazonaws.com endpoints in s3.test.ts (stored byte for byte as before), and a Supabase style https://host/storage/v1/s3 prefix (covered by the new test).

The S3 suite was run with the proxy variables of this environment unset. The one timeout is s3-list-objects.test.ts "Should fall back to NoSuchKey ...". Its describe.concurrent block runs it next to "Should work with big responses", which takes about 18 s in this debug ASAN build, so it exceeds its 5 s budget. It passes when run alone with this build, and the stored endpoint for server.url.href is byte-identical with and without the change.

bun_s3_signing does not depend on bun_url, and bun_dotenv must not name bun_s3_signing types, so the helper lives in bun_url next to host_with_path(), whose only callers were these two stores.

An earlier revision normalized the string with WTF::URL and sliced the resulting href with URL::parse again. Review caught that this inherited the user@host:port reading of URL::parse (#16181): WTF::URL serializes http://user:@h:1 as http://user@h:1/, so that endpoint would have stored user@h:1 where it stores h:1 today. The helper now reads scheme, host with port and path from the WTF::URL components directly, and the user:@ and user@ spellings are in the test. The second one is wrong on 1.4.0 as well.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/s3/s3-list-encode-overflow.test.ts"
bun test v1.4.0 (6e906e468)

test/js/bun/s3/s3-list-encode-overflow.test.ts:
(pass) S3Client.list() option encoding > should not panic when prefix is longer than 1024 bytes when encoded [25.53ms]
(pass) S3Client.list() option encoding > should not panic when delimiter is longer than 1024 bytes when encoded [2.12ms]
(pass) S3Client.list() option encoding > should not panic when continuationToken is longer than 1024 bytes when encoded [1.78ms]
(pass) S3Client.list() option encoding > should not panic when startAfter is longer than 1024 bytes when encoded [2.24ms]
(pass) S3 object keys containing '?' or '#' > includes the full object key in the presigned URL path [9.33ms]
(pass) S3Client region option > rejects the region us-east-1/other.example.com because it is not a valid host name component [5.83ms]
(pass) S3Client region option > rejects the region us-east-1?x because it is not a valid host name component [1.85ms]
(pass) S3Client region option > rejects the region us-east-1#x because i
... (truncated)

release without fix: 8 FAILED
bun test v1.4.0-canary.1 (58d38cf2f)

test/js/bun/s3/s3-list-encode-overflow.test.ts:
(pass) S3Client.list() option encoding > should not panic when prefix is longer than 1024 bytes when encoded [0.15ms]
(pass) S3Client.list() option encoding > should not panic when delimiter is longer than 1024 bytes when encoded [0.03ms]
(pass) S3Client.list() option encoding > should not panic when continuationToken is longer than 1024 bytes when encoded [0.02ms]
(pass) S3Client.list() option encoding > should not panic when startAfter is longer than 1024 bytes when encoded [0.01ms]
(pass) S3 object keys containing '?' or '#' > includes the full object key in the presigned URL path [0.14ms]
(pass) S3Client region option > rejects the region us-east-1/other.example.com because it is not a valid host name component [0.07ms]
(pass) S3Client region option > rejects the region us-east-1?x because it is not a valid host name component [0.01ms]
(pass) S3Client region option > rejects the region us-east-1#x because it is not a valid host name component
(pass) S3Client region option > rejects the region us east 1 because it is not a valid host name component
(pass) S3Client region option 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/s3/s3-list-encode-overflow.test.ts"
bun test v1.4.0 (6e906e468)

test/js/bun/s3/s3-list-encode-overflow.test.ts:
(pass) S3Client.list() option encoding > should not panic when prefix is longer than 1024 bytes when encoded [10.88ms]
(pass) S3Client.list() option encoding > should not panic when delimiter is longer than 1024 bytes when encoded [28.39ms]
(pass) S3Client.list() option encoding > should not panic when continuationToken is longer than 1024 bytes when encoded [3.34ms]
(pass) S3Client.list() option encoding > should not panic when startAfter is longer than 1024 bytes when encoded [2.50ms]
(pass) S3 object keys containing '?' or '#' > includes the full object key in the presigned URL path [14.80ms]
(pass) S3Client region option > rejects the region us-east-1/other.example.com because it is not a valid host name component [8.69ms]
(pass) S3Client region option > rejects the region us-east-1?x because it is not a valid host name component [2.69ms]
(pass) S3Client region option > rejects the region us-east-1#x because
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 770ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_paths v0.0.0
... (truncated)
diff hotspot
src/dotenv/env_loader.rs                       |  8 ++--
 src/runtime/webcore/s3/credentials_jsc.rs      |  9 ++--
 src/url/lib.rs                                 | 62 +++++++++++++++++++++++++-
 test/js/bun/s3/s3-list-encode-overflow.test.ts | 57 +++++++++++++++++++++++
 4 files changed, 125 insertions(+), 11 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
src/dotenv/env_loader.rs                            3      2      0
src/runtime/webcore/s3/credentials_jsc.rs           2      2      0
src/url/lib.rs                                      6     11      0
test/js/bun/s3/s3-list-encode-overflow.test.ts      2      4      0

The S3 endpoint option and the S3_ENDPOINT / AWS_ENDPOINT variables went
straight to bun_url::URL::parse, whose authority does not end at "#",
"\" or an unencoded "@" in the credentials. An endpoint such as
http://a:1#@b:2 therefore signed for and connected to b:2, while
new URL(endpoint).host says a:1. Dot segments and IPv4 shorthand in the
endpoint were sent as written.

Add URL::from_s3_endpoint, which keeps the existing host check and then
normalizes the endpoint with WTF::URL (https when no scheme is given,
the input as written when WTF::URL rejects it), and use it at both
places that store an endpoint.
@coderabbitai

coderabbitai Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 30 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 2 minutes), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 538afbc6-f9c4-4126-93b3-513d72c1ac3c

📥 Commits

Reviewing files that changed from the base of the PR and between 4448a2e and af03dda.

📒 Files selected for processing (4)
  • src/dotenv/env_loader.rs
  • src/runtime/webcore/s3/credentials_jsc.rs
  • src/url/lib.rs
  • test/js/bun/s3/s3-list-encode-overflow.test.ts

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

Comment thread src/url/lib.rs Outdated
Slicing the normalized href with URL::parse again inherited its
user@host:port reading, so http://user:@host:1 (which WTF::URL
serializes as http://user@host:1/) stored user@host:1 where it used to
store host:1. Read the scheme, host-with-port and path from the parsed
WTF::URL instead, which never puts credentials in the host.
Comment thread src/url/lib.rs Outdated
@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:12 AM PT - Aug 21st, 2026

✅ @robobun, your commit af03ddac2751a1c688b2eb9bacdf65ffec835974 passed in Build #102588! 🎉


🧪   To try this PR locally:

bunx bun-pr 39933

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

bun-39933 --bun

Comment thread src/url/lib.rs Outdated
Comment thread src/url/lib.rs Outdated

@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 re-reviewed after 1d52512/af03dda and didn't find further issues — the user:@ / user@ regression is fixed by reading host+port and path straight from WTF::URL, and both spellings are in the test matrix. Because this changes which host receives signed S3 requests and has documented side effects on the Host header (default-port stripping, host lowercasing), a human sign-off is still worthwhile.

What was reviewed:

  • parse_s3_endpoint: gate via URL::parse unchanged, WTF::URL components read directly (no href round-trip), fallback path preserves today's behaviour for inputs WTF rejects.
  • whatwg::Parsed RAII wrapper — Drop deletes the heap WTF::URL once; OwnedString wraps each getter result so the returned BunStrings are deref()ed.
  • Both call sites (credentials_jsc.rs, env_loader.rs) and the S3_ENDPOINT env path covered by the new tests.
Extended reasoning...

Overview

The PR routes S3 endpoint parsing through WTF::URL (the WHATWG parser behind new URL()) so the stored host[:port][/prefix] matches what new URL(endpoint) reports. It adds URL::parse_s3_endpoint and an RAII whatwg::Parsed handle in src/url/lib.rs, wires both endpoint stores (credentials_jsc.rs for the JS option, env_loader.rs for S3_ENDPOINT/AWS_ENDPOINT) through it, and adds a presign-based test matrix in s3-list-encode-overflow.test.ts.

Security risks

This is a security fix for a parser-differential issue: today bun_url::URL::parse and new URL() disagree on #@, \\@, multi-@ userinfo, dot segments and non-canonical IPv4, so a service that validates a tenant endpoint with new URL() can have Bun send signed requests (with Authorization and x-amz-security-token) to a different host. The change decides which host receives those credentials, which is exactly why it warrants human eyes. The earlier revision's user:@host:port regression (my prior comment) is fixed — the helper no longer re-parses the WTF href with URL::parse; it reads protocol(), hostname() (host+port) and pathname() directly and both user:@ and user@ are asserted in the test.

Level of scrutiny

High. Beyond the security surface, the PR documents visible behaviour changes to the SigV4 Host header (default port omitted, host lowercased) and adds a public whatwg::Parsed FFI wrapper with unsafe Deref/Drop. Those are reasonable but a maintainer should confirm the Host-header changes are acceptable for known S3-compatible backends.

Other factors

I checked memory ownership on the new path: whatwg::Parsed deletes the heap WTF::URL exactly once in Drop, and each url.protocol()/hostname()/pathname() result is wrapped in OwnedString whose Drop calls .deref(), so no BunString leaks. The URL::parse gate is kept unchanged so previously-rejected endpoints (the s3.test.ts emoji/garbage cases) still throw, and the raw fallback preserves today's behaviour for inputs WTF::URL rejects (e.g. :99999, deferred to #37003). All prior review threads on this PR are resolved.

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