fetch: read a Request argument through its internal state, not JS-visible getters

Closed
RO
5d ago
+168-52

Problem

fetch(request) only used the Request's internal state when the Request cell's Structure was pristine. Any Structure transition, meaning a class extends Request instance or a genuine Request that gained an own property (for example a router-style req.url rewrite), sent the Request down the RequestInit-dictionary path, which re-reads url, method, headers, body, signal, redirect, and keepalive through JS-visible getters.

The fetch spec says "if input is a Request object, set request to input's request", and Node v26/undici behaves that way: getters on a Request input are never invoked.

const r = new Request(url1);
Object.defineProperty(r, "url", { get() { return url2; } });
await fetch(r); // Bun sent "GET /FROM-GETTER" to url2; Node sends "GET /original" to url1

class R extends Request {
  get url() { return url2; }
  get headers() { return new Headers({ "x-from-getter": "1" }); }
}
await fetch(new R(url1, { headers: { "x-real": "1" } }));
// Bun hit url2 and dropped the constructor's x-real header; Node hits url1 with x-real

Loopback wire capture on 1.4.0 showed [L2]GET /FROM-GETTER HTTP/1.1 for both cases, with the subclass request carrying only x-from-getter and losing x-real.

Cause

fetch_impl downcast the first argument with as_direct::<Request>(), whose generated Request__fromJSDirect requires cell->structureID() to equal the global JSRequest structure id. Any transitioned Structure returns null, request stays None, and the Request object lands in the request_init_object dictionary arm.

This getter-reading path was introduced deliberately in #10543 and #10969. #10543 was written to fix #4718 (SvelteKit loses the headers of a Request passed to fetch()). It did not fix #4718: reports continued up to Bun 1.1.20. #12764 then traced the symptom to SvelteKit's dev server. It wraps fetch and calls fetch(info, init), so fetch(request) becomes fetch(request, undefined). #13337 fixed that and added the fetch(request, undefined) tests in fetch-args.test.ts. Those tests pass with this change, so it does not bring #4718 back.

Corrected on 2026-11-05: an earlier version of this paragraph said that SvelteKit passes a Request subclass and that the subclass once fell through to the dictionary path. Neither statement has evidence.

Fix

Bind request with the ClassInfo-based cast (as_::<Request>(), generated Request__fromJS, a plain dynamicDowncast), which matches subclass instances and transitioned Structures. Everything downstream of that binding already reads internal slots only (req.method, req.flags.redirect, req.signal, the body value, the fetch headers), and fetch(request, init) continues to apply init overrides on top.

Behavior note: code that added an own url property to a Request and relied on fetch(request) honoring it now gets the spec and Node behavior, fetching the internal URL. The supported way to retarget a request is fetch(newUrl, request) or fetch(new Request(newUrl, request)).

new Request(modifiedRequest) has a similar divergence; it is tracked separately since the constructor's second argument legitimately has dictionary semantics.

Tests

Five cases added to test/js/web/fetch/fetch-args.test.ts: own url getter ignored, own url data property ignored, a subclass overriding every getter (all throwing, so any getter read fails the test) fetches via internal state with constructor headers and body intact, fetch(subclassRequest, init) still honors init overrides, and a Bun.serve request with a rewritten url own property proxies to its internal URL. Four of the five fail on the unmodified build and all pass with the fix; the existing fetch-args.test.ts suite (66 tests) passes.

test/js/web/request/request-subclass.test.ts asserted the #10543/#10969 getter-reading behavior and is updated to the internal-state semantics while keeping its intent: constructor-set headers reach the wire and a headers getter returning invalid header names cannot make the fetch throw. Both updated tests fail on the unmodified build and pass with the fix. The tests also no longer contact example.com.


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/fetch-args.test.ts test/js/web/request/request-subclass.test.ts
bun test v1.4.0 (62ef1ce75)

test/js/web/request/request-subclass.test.ts:
46 |     server.url,
47 |     { method: "POST", headers: { "x-my-header": "123" } },
48 |     "http://127.0.0.1:1/decoy",
49 |   );
50 | 
51 |   const response = await fetch(request);
                              ^
TypeError: Unable to connect. Is the computer able to access the url?
  path: "http://127.0.0.1:1/decoy",
 errno: 0,
  code: "ConnectionRefused"

      at async <anonymous> (/workspace/bun/test/js/web/request/request-subclass.test.ts:51:26)
(fail) fetch() uses a subclass Request's internal state, not getter overrides [56.07ms]
76 |       });
77 |     }
78 |   }
79 | 
80 |   const request = new MyRequest(server.url, { headers: { "x-ok": "1" } });
81 |   const response = await fetch(request);
                              ^
TypeError: Invalid header name: '[I am not a valid header]!'
      at <anonymous> (/workspace/bun/test/js/web/request/request-subclass.test.ts:81:26)

... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (62f0b274e)

test/js/web/request/request-subclass.test.ts:
(pass) fetch() uses a subclass Request's internal state, not getter overrides [1.88ms]
(pass) fetch() ignores a subclass headers getter returning invalid header names [1.08ms]

test/js/web/fetch/fetch-args.test.ts:
(pass) fetch(request subclass with headers) [0.55ms]
(pass) fetch(RequestInit, headers) [0.17ms]
(pass) fetch(url, RequestSubclass) [0.20ms]
(pass) fetch({toString throwing}, {headers} isn't accessed) [0.18ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > url toString() throws [0.09ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.headers iterable throws [0.17ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.body getter throws [0.08ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.decompress getter throws [0.01ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.headers getter throws
(pass) fetch() rejects instead of throwing synchronously when 
... (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/web/fetch/fetch-args.test.ts test/js/web/request/request-subclass.test.ts
bun test v1.4.0 (62ef1ce75)

test/js/web/request/request-subclass.test.ts:
(pass) fetch() uses a subclass Request's internal state, not getter overrides [47.24ms]
(pass) fetch() ignores a subclass headers getter returning invalid header names [24.44ms]

test/js/web/fetch/fetch-args.test.ts:
(pass) fetch(request subclass with headers) [17.91ms]
(pass) fetch(RequestInit, headers) [8.33ms]
(pass) fetch(url, RequestSubclass) [10.42ms]
(pass) fetch({toString throwing}, {headers} isn't accessed) [11.56ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > url toString() throws [7.27ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.headers iterable throws [14.54ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.body getter throws [6.82ms]
(pass) fetch() rejects instead of throwing synchronously when option conversion throws > init.
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 641ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[2/22] gen JS modules (bundle-modules)
Preprocess modules (9012ms)
Bundle modules (49ms)
Postprocesss modules (171ms)
Bundle Functions (859ms)
Generate Code (20ms)

[10.13s] Bundled "src/js" for production
  2573 kb
  193 internal modules
  13 native modules
  84 internal functions across 17 files
[2/7] 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_zlib_s
... (truncated)
diff hotspot
src/runtime/webcore/fetch.rs                 |  13 +--
 test/js/web/fetch/fetch-args.test.ts         | 122 +++++++++++++++++++++++++++
 test/js/web/request/request-subclass.test.ts |  85 ++++++++++---------
 3 files changed, 168 insertions(+), 52 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                          reads  edits  tests
src/runtime/webcore/fetch.rs                      5      5      0
test/js/web/fetch/fetch-args.test.ts              1      2      0
test/js/web/request/request-subclass.test.ts      1      1      0