Repository navigation
Conversation
|
Status Reproduced on main 3f1765a (release build, linux x64) with a raw TCP server, so that the request line is visible: import net from "node:net";
const lines = [];
const server = net.createServer(socket => {
socket.on("data", data => {
lines.push(String(data).split("\r\n")[0]);
socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
});
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
for (const method of ["PatCh", "Put", "BREW"]) {
await (await fetch(url, { method })).arrayBuffer();
console.log(JSON.stringify(method), "->", lines.at(-1), "| Request.method:", new Request(url, { method }).method);
}
server.close();main prints The six touched test files fail on main's debug build and pass on this branch (counts in the Notes of the description). State: CI is green on f038c68 and every review thread is resolved. The next step is a maintainer's review. The decisions that are theirs are listed first in the Notes of the description (the two new method types, three behaviours kept from main, and the release for the behaviour change). |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe pull request adds support for custom HTTP method tokens across Request construction and fetch transport. It classifies invalid and forbidden methods, preserves valid token spelling, updates method handling in HTTP clients, and adds tests for request construction, redirects, and HTTP protocols. ChangesHTTP Method Token Support
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
Beyond the inline findings, I also checked the token's lifetime on the HTTP thread: MethodRef::Token is an offset/length into headers.buf, so later appends to that buffer (proxy/auth headers) do not invalidate it, the detach_lifetime in build_request borrows the same header_buf the header values already borrow, and the retry-after-reset path restores the method from the JS-side copy over the same buffer. The redirect arm keeps a token on 301/302/307/308 and rewrites to GET on 303 as described, and the HTTP/2 encoder has no 16-bit field limit, so the H3-only guard is the right scope for the method itself.
Extended reasoning...
The change replaces the closed Method enum with OwnedMethod/MethodRef across fetch, Request, server.fetch, the HTTP/1-2-3 client and S3 signing, and is a user-visible behaviour change on a released API (false/0 sent as tokens, TypeErrors for non-tokens and TRACK/Trace/Connect, Response no longer feeding a method into Request). It touches unsafe lifetime detachment on the request header buffer and the request line that goes on the wire, which I traced as offset-based and sound. With three confirmed inline findings plus two unposted verified ones, and open design questions the author explicitly leaves to a maintainer, a human review is required rather than approval.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/runtime/webcore/Request.rs— nit: the GC is told nothing about the bytes a token method holds, so a Request built with a very long method is under-reported to the collector after merging.calculate_estimated_byte_size(src/runtime/webcore/Request.rs:463-467) andmemory_cost(src/runtime/webcore/Request.rs:374-379) sum body, url andsize_of::<Request>()only, whileOwnedMethod::Tokennow owns a heapBox<[u8]>of user-chosen length. Fix: add the token's length (0 forKnown) to both sums so every owning field ofRequestis reported, as the url already is.Why this was flagged
new Request(url, { method })with a long token string, orfetch(url, { method })throughRequest::init2(src/runtime/server/server_body.rs:2341), stores the method asOwnedMethod::Token(Box<[u8]>)(src/http_jsc/method_jsc.rs:60).calculate_estimated_byte_sizeat src/runtime/webcore/Request.rs:463-467 andmemory_costat src/runtime/webcore/Request.rs:374-379 never add that allocation, so JSC's reported extra memory for the wrapper omits it. On the base branchmethodis a 1-byte enum and nothing is owned, so no accounting is lost there. The PR text itself notes the bytes are not counted; no guard elsewhere adds them.Verification:
request_method_missat /home/claude/bun/src/http_jsc/method_jsc.rs:62 doesOwnedMethod::Token(Box::from(utf8.slice()))for any RFC 9110 token of any length. Neithermemory_cost(Request.rs:374-379) norcalculate_estimated_byte_size(Request.rs:462-467) includes it. On the base branchmethod: Methodwas an inline 1-byte enum, so the omission is new with this change.
a8dc861 to
9f54c1f
Compare
|
Updated 2:38 PM PT - Oct 14th, 2026
✅ @robobun, your commit f038c68f7a4138b2c0e7cf487c66d75868cc1c53 passed in 🧪 To try this PR locally: bunx bun-pr 44718That installs a local version of the PR into your bun-44718 --bun |
There was a problem hiding this comment.
I re-reviewed the new push and found no new bugs; because this changes the wire behaviour of a released API and introduces two cross-crate types (OwnedMethod/MethodRef) that the description itself asks a maintainer to rule on, a human look is still worthwhile.
What was reviewed:
- The push routes a
server.fetch()method error throughrejected_promise_with_caught_exception, which closes the synchronous-throw point from the earlier review;fetch()'s newdata:rejection also goes throughreject_on_exception, so neither entry point throws. - Token lifetime: the method is appended to
request_headers.bufright beforeFetchOptionsis built, nothing touches that buffer beforeAsyncHTTP::inittakes it asheaders_buf, andHTTPClient.header_bufis never reassigned, so thedetach_lifetimeinbuild_requestand theis_inassert hold. OwnedMethod::Tokenownership onRequest: thebail!paths drop the localRequestnormally,clone_into'sptr::writeonly overwrites the GET seed (no Box leak), andBox::from_rawinfinalizefrees it.- The
u32::try_from(...).unwrap()instore_tokenmirrorsHeaders::appendand a single JS string cannot exceedu32, so it is not a new reachable panic; the HTTP/3 length guard is unchanged from the earlier push.
Extended reasoning...
The change replaces the closed Method enum fallback-to-GET with OwnedMethod on Request and a Copy MethodRef (a StringPointer into the request header buffer) in FetchOptions/AsyncHTTP/HTTPClient, adds is_token/classify_miss validation that throws TypeError for non-tokens and forbidden methods, removes Response::Init.method, and adds tests across fetch, Request, server.fetch, redirects, proxies and HTTP/3. The security-relevant surface is request-line injection, which is_token (RFC 9110 tchar set, unit-tested over all 256 bytes) now rejects before anything is sent. The earlier run's server.fetch sync-throw finding is addressed in this push; the HTTP/3 pre-existing truncation note is unchanged. Deferring rather than approving because the diff is ~1,400 lines across the HTTP client, Request layout and S3 paths, changes behaviour on a released API, and the author explicitly requests a maintainer's ruling on the new type design.
Still open from earlier reviews (1):
- 🔴
src/http/h3_client/encode.rs:53—pre-existing: over HTTP/3 a header value, :path or a batch of headers whose bytes exceed 65,535 still goes out truncate…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
|
On the optional finding about
If a maintainer wants them counted, it is one |
…he method table A method string that was not an exact all-upper or all-lower entry of the method table became GET with no error, in fetch(), new Request() and server.fetch(). One reader now takes the method of a request from JS (method_jsc::request_method_from_init). A table hit is that verb, as before. After a miss, DELETE, GET, HEAD, OPTIONS, POST and PUT match in any case, every other RFC 9110 token is kept as written, and a string that is not a token throws a TypeError. So do TRACK, and CONNECT and TRACE in mixed case. Request.method is an OwnedMethod: a verb, or the bytes of a token. The HTTP client holds a Copy MethodRef whose token is a StringPointer into the request header buffer, and build_request writes it to the request line for HTTP/1.1, a proxy, HTTP/2 and HTTP/3. Response no longer stores a method. Request read its method from response::Init, which can hold a verb only. The HTTP/3 client rejects a method over 65,535 bytes. QPACK takes a 16-bit length, and a longer method would go out as its first bytes. Kept as before: all-lower table verbs are upper-cased, CONNECT and TRACE in the spellings of the table are sent, and "" and null name no method.
9f54c1f to
f038c68
Compare
|
The comment check flagged 23 comment groups that this PR added under |
Part of #42497
Problem
fetch(),new Request()andserver.fetch()silently sendGETfor a method that is not an all-upper or all-lower entry of the method table ("PatCh","Put","BREW").GETinfetch_impl(fetch.rs:615),response::Init::init(Response.rs:1303) andon_fetch(server_body.rs:2303). The closedMethodenum cannot hold another token.Fix
method_jsc::request_method_from_init, replaces them. After a miss, Fetch's six normalized verbs match in any case, another token is kept as written, and a non-token,TRACK,TraceorConnectis aTypeError.Requeststores anOwnedMethod(verb or token bytes). The HTTP client holds aCopyMethodRef, whichbuild_requestwrites. HTTP/3 rejects a method over 65,535 bytes.Background
OwnedMethodandMethodRefneed a maintainer's ruling: Support MKADDRESSBOOK HTTP method #28955 grewMETHOD_MAPinstead, which fixes one token per row and widens thenode:httpallowlist."patch"is sent asPATCH,CONNECTandTRACEare sent,""andnullmean no method.Downsides
falseis sent asfalse, non-tokens throw,new Response(body, { method })no longer gives aRequestits method, and a token sent toBun.servefails (Custom HTTP methods via Bun.serve() #6556).Requestgrows 104 to 112 bytes (same mimalloc block)..methodcosts 44 instructions (41), finalize 137 (124)..text+5,888 bytes.Notes
For a maintainer to decide
OwnedMethod(onRequest) andMethodRef(inFetchOptions,AsyncHTTP,HTTPClient) are new types inbun_http_types,bun_http_jsc,bun_httpandbun_runtime. fetch: stop replacing unrecognized HTTP methods with GET #33469 and fix: don't silently convert unknown HTTP methods to GET in fetch() #27265 had the same design and closed with no review. The other way is a largerMETHOD_MAP, as in Support MKADDRESSBOOK HTTP method #28955 and Add obscure HTTP methods #3553. A row forLISTfixes that one token and no mixed-case verb. Each row is also accepted bynode:http, where Node answers 400."patch"asPATCH). Fetch and Node sendpatch. Bun replacesnode-fetch, andopenai@4.76.0,cloudflare@3.5.0and@mux/mux-node@9.0.1call fetch withmethod: "patch"(core.js:this.methodRequest('patch', ...)).CONNECTandTRACEin the two spellings of the table are sent. Fetch forbids them (Request and Response constructors skip several validations the Fetch spec requires #42499).""andnullmean no method. Fetch throws for""and sendsnull. The bundledundici.requestpassesmethod: null.TRACK, and mixed-caseTraceandConnect, were a silentGETand are now aTypeError, which is the Fetch result. It is Bun's first forbidden-method check. A throw can be relaxed later. A method that is sent cannot be taken back.Behaviour change on a released API
method"PatCh","BREW","LIST"GET"Put","pOsT"GETPUT,POSTPUT,POSTfalse,0,123GETfalse,0,123false,0,123"GET POST",{},[]GETTypeErrorTypeError"TRACK","Trace","Connect"GETTypeErrorTypeError"patch"PATCHPATCHpatch"CONNECT","TRACE"TypeError"",nullGETGETTypeError,nullThe table holds for
new Request(url, { method }).methodand for the request line offetch(url, { method })to a raw socket. More flips:method: cond && "POST"sendsfalsewhencondis false. Before, it sentGET.TypeErrorwith codeERR_INVALID_ARG_VALUE:"GET POST" is not a valid HTTP method.and"TRACK" HTTP method is unsupported.fetch()andserver.fetch()reject, andnew Request()throws. Nothing is sent.Content-Length. A 301, 302, 307 or 308 keeps the token and the body, and a 303 changes it toGET. A request with a token is not sent again after a reset on a reused connection.fetch("s3://..."): DELETE, GET, HEAD, POST and PUT in any case now reach S3 as that verb."Delete"deletes the object."Put"or"pOsT"uploads the body, and with no body writes an empty object. Before,"Delete"and"Put"with no body were a read, and"Put"with a body was aTypeError. Every other method rejects withERR_S3_INVALID_METHODand signs nothing. Before, it was a read.data:,file:orblob:URL rejects a non-token. Before, every method was answered.Responseno longer stores a method.response::Init.methodexisted so thatRequestcould read a method out ofInit::init, and it can hold a verb only.Requestnow reads the method itself. Sonew Response(body, init)no longer readsinit.method(Node does not read it).new Request(url, new Response("x", { method: "POST" })).methodwasPOSTand isGET, also for a cloned orHTMLRewriter-transformedResponse, and afetch()of thatRequestnow rejects with "fetch() request with GET/HEAD method cannot have body."new Request(putRequest, new Response(null, { method: "DELETE" })).methodwasDELETEand isGET(Node:PUT, the subject of Request: take a method from RequestInit only when it has one #41994). For aResponsebuilt without a method, main and this PR agree.verbose: "curl"prints the method after-Xwith no quotes, as it prints header values today with no escaping. A token can hold characters such as a backtick or$. fetch, audit: write the verbose curl line and the audit fix --ignore line as shell words #41726 writes every word of that line as a shell word.Requestreports to the GC.Bun to Bun (
Bun.servehandler that returnsreq.method, clientfetch(url, { method })of this PR)BREW,customPatChPURGE1GETECONNRESETECONNRESETGETGETGETPATCHGETserver.fetch("/", { method })GETPatChPURGE1So the
Bun.servesnippet of #42497, and #6021, still need the server side.Not in this PR
Bun.serveand a method outside its table (rows above): Custom HTTP methods via Bun.serve() #6556 and Unknown random HTTP methods will return a 200 #18406. A 501 answer for HTTP/1.1 and HTTP/3 is commit 8924618 onrobobun/5627effc/serve-unknown-method-501, on top of uws: resolve a request method to its id inline #44510. It has no pull request yet.GET:RequestContext.rs:1426,StaticRoute.rs:292,FileRoute.rs:233,DirectoryRoute.rs:107,bake/DevServer.rs:2121.POST:DevServer.rs:1766and:2091.OPTIONS:NodeHTTPResponse.rs:2716,server_body.rs:2902,server/mod.rs:756. They belong to the server work above.undici.requestdecides the method in JS before it calls fetch (src/js/thirdparty/undici.js:190): undici.request(): the method is upper-cased, and a method that is not a string becomes GET #44716.blob:,file:anddata:answer every valid method: fetch: blob:, file: and data: URLs ignore the request method #44715 (fetch: implement the blob: URL scheme fetch (method gate, Content-Length, Range) #36265 had theblob:part).RequestInitinRequest::construct_into(new Request(request, {}), aResponseas init,new Request(url, { status: 99 })): Request: take a method from RequestInit only when it has one #41994. The reads ofinit.methodand their order are as on main: 1, 2, 1, 1 fornew Request(url, init),new Request(request, init),fetch,server.fetch.GETorHEADinnew Request(): Validate GET/HEAD bodies in new Request() and statusText in new Response() #42513. ExactCONNECTandTRACE: Request and Response constructors skip several validations the Fetch spec requires #42499.initargument ofserver.fetch(url, init)is not in the types or the docs (serve.d.ts:990). It is read only with a string URL. Its method read is fixed here as the third site of the same lookup, and a method error rejects like thebodyerror. The URL and headers errors still throw, as on main. server.fetch(): pass the server as the handler's second argument, accept a URL #41755 makes every argument error of this function reject and owns the declaration. server.fetch: copy a Headers argument instead of adopting the wrapper's pointer #40888 touches the same function.us_quic_stream_send_headersnarrows each length to 16 bits with no check. On main a header value of 65,541 bytes arrives as 5 bytes, from the client and fromBun.serve(Http3Response.h:212). Bun.serve(http3): reset the stream when lsquic refuses the response headers or a body write #42895 and Bun.serve(http3): send a response whose header block is larger than 64 KB #42905 put the check in that function for both callers, and Bun.serve(http3): a response with more than 64 KB of encoded headers cannot be sent #42887 is the issue. This PR checks only the method, inh3_client/encode.rs, because it makes the method the caller's text. When Bun.serve(http3): reset the stream when lsquic refuses the response headers or a body write #42895 lands, that check can go.Bun.serveHTTP/3 server spins at 100% CPU after a client gives up a stream before its HEADERS: Bun.serve (HTTP/3): 100% CPU after a client abandons a request stream before its HEADERS #44714. That is why the over-long method test has an origin of its own.UnableToEncode, for example a 70,000-byte header value), the client retires the pooled connection. Requests in flight on it complete. Main does the same.Sequencing
response.get_method()arm, and itsmethod_jsc::from_js(..).unwrap_or(Method::GET)becomesrequest_method_from_init.OwnedMethod::known()gives the verb.Method.rsnext towhich_roundtrip. This PR adds a section afterimpl Methodand leavesMethod,METHOD_MAPandMethod::whichunchanged.build_request.Measurements (release builds of main 3f1765a and of this diff on it, linux x64. perf, valgrind and bloaty are not in the container. Taken before the review changes, which are 9 lines on an error path of
server.fetchand shorter comments)stepifrom function entry to return onbun-profile, 5 equal repeats):.methodgetter 41 to 44.new Request(url)781 to 776.new Request(url, { method: "POST" })3719 to 3735.new Request(request, { method: "POST" })5542 to 5559. Table lookup for"POST"183 to 183.HTTPClient::build_requestfor a GET 116 to 120.RequestClass__finalize124 to 137.Bun.serverequest (server::trampoline::on_request, production mode, lowest repeated value of 15 samples, runs interleaved): 8191 to 8175 forfetch() { return new Response("Hello") }, 8227 to 8214 when the handler readsreq.method.size_of::<Request>()104 to 112 bytes (assert_ffi_layout!). mimalloc serves 97 to 112 bytes from its 112-byte block.estimateShallowMemoryUsageOf(new Request("http://a/"))193 to 201.sizeof, debug build:AsyncHTTP2208 to 2224 (the same 2560-byte mimalloc block),HTTPClientinside it 1680 to 1688,FetchOptions936 to 944,FetchTasklet1128 andResponse496 unchanged.MethodRefis 12 bytes andOwnedMethodis 16..text58,536,037 to 58,541,925 bytes (+5,888), file +4,096 bytes (size -A). Copies of the method compare tree in.text: 3 to 3.new RequestPOST, 1000clone(), 1000fetchPOST), two interleaved runs each: 46,548 and 47,959 on main, 46,459 and 46,491 with the PR. The count moves more between runs than between builds.undefined,""andnull: 0 of 75 differ from main. Sockets opened forfetch(url, { method: "GET POST" }): 1 to 0.bench/snippets/request-method-getter.mjs, 12 interleaved runs per binary on 2 pinned CPUs:request.clone().method251 ± 23 ns to 253 ± 23 ns,new Request(url).method309 ± 17 ns to 306 ± 27 ns.Bun.servehello world underab -k -c 50, 10 interleaved runs: 22,178 ± 1,514 to 22,331 ± 1,516 requests per second. Both deltas are below the noise.src/install,src/runtime/cli, the S3 client,src/standalone_graphandpackages/.Tests (each file fails on main's debug build and passes here)
test/js/web/request/request.test.ts: 66 of 87 tests fail on main.test/js/web/fetch/fetch-args.test.ts: 27 of 133. A raw-socket server, six native entry points andnode-fetch, an HTTP proxy, HTTP/2 with anode:http2server,s3:with the in-process S3 server,data:,file:andblob:.test/js/web/fetch/fetch-redirect.test.ts: 5 of 35.test/js/web/fetch/fetch-http3-client.test.ts: 2 of 69. Anode:quicorigin reads:method.test/js/bun/http/proxy-stress-matrix.test.ts: 8 of 343.BREWandPatChthrough an HTTP and an HTTPS proxy to an HTTP and an HTTPS origin, which covers the CONNECT tunnel.test/js/bun/http/bun-serve-fetch-invalid-args.test.ts: 3 of 17.src/http_types/Method.rsunder miri (cargo miri test -p bun_http_types --lib -- Method, 6 pass).cargo fmt,test/internal/source-lints/,BUN_JSC_validateExceptionChecks=1on three of the files,cargo checkforx86_64-pc-windows-msvc,aarch64-apple-darwinandaarch64-unknown-linux-musl, and 43 more test files undertest/js/web/fetch,test/js/web/request,test/js/bun/http,test/js/workerd,test/js/node/httpandtest/js/first_party/undici. Every failure there is a timeout that main's debug build has too.Self-review
Two rounds. The first asked for a rework of the first cut: a plain enum in place of a bit-packed method slot, no change to the presence rule of #41994, a
TypeErrorforTRACK, and more coverage. The second raised 12 concerns on this diff. 11 are addressed: the title, the docs line and the root-cause lines, thes3:rows, the scheme test, the HTTP/3 check cut down to the method, an origin of its own for the over-long method test, the field order of theRequestliterals, theserver.fetchtest calls, and a tracker for each item of "Not in this PR". One is not taken: an issue for the HTTP/2UnableToEncodeobservation, because requests in flight complete and nothing is lost.no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-http3-client.test.ts