Repository navigation
Bun.serve http3: 501 or 400 before the router for a request line the HTTP/2 listener refuses #45357
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,8 +24,11 @@ | |
|
|
||
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <cstring> | ||
| #include <limits> | ||
|
|
||
| extern "C" int16_t Bun__HTTPMethod__from(const char *str, size_t len); | ||
|
|
||
| namespace uWS { | ||
|
|
||
| /* RFC 9113 §8.2.2 / RFC 9114 §4.2: connection-specific fields are not | ||
|
|
@@ -45,25 +48,69 @@ static inline bool isTokenByte(unsigned char c) { | |
| return false; | ||
| } | ||
|
|
||
| /* RFC 9113 §8.3.1 / RFC 9114 §4.3.1 request-target rules shared by the h2 | ||
| * and h3 request validators: :method is a token; :path is origin-form (or | ||
| * "*" for OPTIONS) and carries no byte the HTTP/1 request line could not | ||
| * (controls, SP); CONNECT carries no :path; there is an authority, Host | ||
| * doesn't contradict :authority, and :authority has no userinfo. */ | ||
| static inline bool validPseudoHeaderTarget(std::string_view method, std::string_view path, std::string_view authority, std::string_view host) { | ||
| if (method.empty()) return false; | ||
| for (unsigned char c : method) if (!isTokenByte(c)) return false; | ||
| bool isConnect = method == "CONNECT"; | ||
| if (!isConnect) { | ||
| if (!(path.size() && path[0] == '/') && !(path == "*" && method == "OPTIONS")) return false; | ||
| for (unsigned char c : path) if (c <= 0x20) return false; | ||
| /* RFC 9110 §9.1: method = token. */ | ||
| static inline bool isToken(std::string_view s) { | ||
| if (s.empty()) return false; | ||
| for (unsigned char c : s) if (!isTokenByte(c)) return false; | ||
| return true; | ||
| } | ||
|
|
||
| /* The methods Bun.serve can represent, in their case-sensitive wire form. The lookup also knows each name in lowercase. */ | ||
| static inline bool isKnownMethod(std::string_view method) { | ||
| return !method.empty() && (unsigned char) (method[0] - 'A') < 26 && Bun__HTTPMethod__from(method.data(), method.size()) != -1; | ||
| } | ||
|
|
||
| /* One of the 8 bytes of `word` is a control byte or SP. */ | ||
| constexpr bool wordHasControlOrSpace(uint64_t word) { | ||
| return ((word - 0x2121212121212121ull) & ~word & 0x8080808080808080ull) != 0; | ||
| } | ||
| static_assert([] { | ||
| for (uint64_t others = 0x2121212121212121ull; others; others = others == ~0ull ? 0 : ~0ull) { | ||
| for (unsigned lane = 0; lane < 64; lane += 8) { | ||
| for (uint64_t b = 0; b < 256; b++) { | ||
| if (wordHasControlOrSpace((others & ~(0xffull << lane)) | (b << lane)) != (b <= 0x20)) return false; | ||
| } | ||
| } | ||
| } | ||
| return true; | ||
| }(), "wordHasControlOrSpace() is `byte <= 0x20` for every byte value in every lane"); | ||
|
|
||
| /* A control byte or SP: what an HTTP/1 request-target cannot carry. */ | ||
| static inline bool hasControlOrSpace(std::string_view s) { | ||
| const char *p = s.data(), *end = p + s.size(); | ||
| for (; end - p >= 8; p += 8) { | ||
| uint64_t word; | ||
| memcpy(&word, p, 8); | ||
| if (wordHasControlOrSpace(word)) return true; | ||
| } | ||
| for (; p != end; p++) if ((unsigned char) *p <= 0x20) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /* RFC 9113 §8.3.1 / RFC 9114 §4.3.1: :path is origin-form, or "*" for OPTIONS; CONNECT has none. */ | ||
| static inline bool validPathForMethod(std::string_view method, std::string_view path) { | ||
| if (method == "CONNECT") return path.empty(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Bun's own fetch sending method CONNECT to a Bun.serve HTTP/3 listener now gets "400 Bad Request" where the base returned the handler's response. Bun's HTTP/3 client sends :path and :scheme with CONNECT, and validPathForMethod at Utilities.h:92 now returns path.empty() for CONNECT, so http3RequestLineRefusal answers 400 at Utilities.h:108 before the router. Fix: make the client omit :path and :scheme for CONNECT per RFC 9114 §4.3.1 in the same PR, or accept CONNECT with :path over HTTP/3 until the client is fixed; the new test pins fetchConnect: 400 instead of the client bug. Why this was flaggedAnyone calling fetch("https://host/x", { method: "CONNECT" }) from Bun to a Bun.serve({ http3: true }) server, or any client that sends Extended CONNECT with :path (RFC 9220 WebSocket/WebTransport), hits Http3Context.h:38. The PR's own test (serve-http3.test.ts, "Bun's fetch sends :scheme and :path with CONNECT") shows Bun's client emits :path on CONNECT. On the base branch there was no path check and the router dispatched the request, returning the handler's status. Now Utilities.h:92 Verification: A Bun process calls fetch(url, { method: "CONNECT", protocol: "http3" }) against a Bun.serve({ http3: true }) server. Bun's HTTP/3 client emits |
||
| if (!(path.size() && path[0] == '/') && !(path == "*" && method == "OPTIONS")) return false; | ||
| return !hasControlOrSpace(path); | ||
| } | ||
|
|
||
| /* HTTP/2: also needs an authority that Host does not contradict, with no userinfo. */ | ||
| static inline bool validPseudoHeaderTarget(std::string_view method, std::string_view path, std::string_view authority, std::string_view host) { | ||
| if (!isToken(method) || !validPathForMethod(method, path)) return false; | ||
| if (authority.empty() && host.empty()) return false; | ||
| if (!authority.empty() && !host.empty() && authority != host) return false; | ||
| if (authority.find('@') != std::string_view::npos) return false; | ||
| return true; | ||
| } | ||
|
|
||
| /* HTTP/3: the status that answers a request line before the router, or null. `authority` is :authority or Host. */ | ||
| static inline const char *http3RequestLineRefusal(std::string_view method, std::string_view path, std::string_view authority) { | ||
| bool known = isKnownMethod(method); | ||
| if (!known && !isToken(method)) return "400 Bad Request"; | ||
| if (!validPathForMethod(method, path) || authority.empty()) return "400 Bad Request"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: HTTP/3 clients can still send a literal Host that contradicts :authority, userinfo in :authority, or no :scheme and be routed, while the same request is reset over HTTP/2; the new shared predicate leaves this sibling gap open. http3RequestLineRefusal at packages/bun-uws/src/Utilities.h:106-110 only tests authority.empty(), and packages/bun-uws/src/Http3Context.h:38 passes req.getHeader("host"), which Http3Request.h:38-45 already collapsed to :authority, so the contradiction is invisible to the check. Fix: pass :authority and the literal Host separately and run the same validPseudoHeaderTarget rules (authority != host, '@' in authority) for HTTP/3 so one predicate guards both transports. Why this was flaggedAn HTTP/3 client sends Verification: Pre-existing. Any HTTP/3 peer sends |
||
| return known ? nullptr : "501 Not Implemented"; | ||
| } | ||
|
|
||
| static inline bool isConnectionSpecificResponseField(std::string_view name, std::string_view value) { | ||
| switch (name.size()) { | ||
| case 2: return asciiIEquals(name, "te") && !(value.size() == 8 && asciiIEquals(value, "trailers")); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 22258
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 17575
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 17442
🌐 Web query:
site:github.com/oven-sh/bun packages/bun-uws/src/Http3Request.h getHeader authority💡 Result:
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 8370
🌐 Web query:
RFC 9114 section 4.3.1 HTTP/3 :authority Host header fields different values💡 Result:
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 26495
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 16709
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 16582
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 9576
Compare the original
:authorityandHostfields before routing.The QUIC decoder retains both fields in the decoded header list.
Http3Requestthen stores:authorityasauthority, whilegetHeader("host")returns only that value. A conflicting literalHostvalue is not compared.A request with valid method and path but different authority values can therefore pass
http3RequestLineRefusaland reach routing. Preserve the literalHostvalue separately and return400when both non-empty values differ. KeepHostas the fallback when:authorityis absent.🤖 Prompt for AI Agents