Skip to content

Commit b5219f5

Browse files
quinnjKenoclaude
authored
Harden HTTP.jl security defaults for v2.4.0 (#1316)
* Bound HTTP/2 server concurrent streams and mitigate Rapid Reset The HTTP/2 server advertised an empty initial SETTINGS frame, leaving SETTINGS_MAX_CONCURRENT_STREAMS effectively unlimited, and the HEADERS path allocated per-stream state, a send-window entry, and a Threads.@Spawn handler with no check on the open-stream count. RST_STREAM was un-rate-limited, so a single connection could drive unbounded stream state and handler-task creation (CVE-2023-44487 "Rapid Reset" / unbounded-stream DoS). Separately, the new-stream guard used `hf.stream_id < max_stream_id`, which let a client reuse the highest closed stream id once it was cleaned up, including after GOAWAY. Add a configurable per-connection concurrent-stream cap (`max_concurrent_streams`, default 100, threaded through Server/listen!/ serve!/serve; values <= 0 disable it for backward compatibility). When the cap is positive it is advertised via SETTINGS_MAX_CONCURRENT_STREAMS and enforced on incoming HEADERS: a new stream over the cap is refused with RST_STREAM(REFUSED_STREAM) without allocating state or spawning a handler. The refusal path still drains and HPACK-decodes the (possibly multi-frame) header block so the shared decoder stays in sync. Track peer-initiated stream resets; once they exceed `max_concurrent_streams + 100`, send GOAWAY(ENHANCE_YOUR_CALM) and stop accepting new streams (counting only the first OPEN->RESET transition per stream). Require new client-initiated stream ids to be strictly greater than every previously-seen id, treating reuse of a closed id as a connection error. The new-stream policy is factored into a pure `_h2_server_new_stream_decision` helper for offline unit testing. Tests cover the decision helper, the SETTINGS advertisement, REFUSED_STREAM enforcement, HPACK resynchronization after a refused stream, and stream-id monotonicity (reuse -> connection error). Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-611 (Anthropic references: ANT-2026-7CVAC8Q1, ANT-2026-08Y5Y8H4, ANT-2026-9W57Y1XN, ANT-2026-7393V1MS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit d61bf13acdd2050bd4de9826e179e68d19c49e98) * Harden fileserver request-path mapping against separator/absolute segments The static file server decoded the request path, split it on '/', and rejected only segments exactly equal to '.' or '..'. Because URL-decoding runs before the '/' split, an encoded backslash (%5c), a drive specifier ("C:\..."), or a UNC prefix ("\\host\share") survived inside a single segment and passed validation. On Windows joinpath then honors '\' as a separator and treats drive/UNC segments as absolute, discarding the configured document root, enabling arbitrary file read and outbound SMB/NTLM via UNC paths. Add _is_unsafe_request_path_segment(segment), which rejects any decoded segment that is '.'/'..', contains a path separator ('/' or '\'), contains a colon (Windows drive specifier / alternate data stream), or is absolute (isabspath). The '\' and ':' checks are explicit and platform-independent so Windows drive/UNC/backslash forms are rejected even on POSIX hosts, making the hardening uniform and testable everywhere. _decoded_request_path_segments now validates each split segment with this helper and throws ArgumentError on any unsafe segment (callers map this to 400). Add a defense-in-depth backstop in _join_request_path: after joining the validated segments onto the root, compute normpath and require the result to equal the normalized root or start with root plus the path separator, otherwise reject. The separator suffix prevents a sibling like "<root>_evil" from matching. normpath/startswith are used (rather than realpath) to keep the check deterministic and offline, matching the existing code's avoidance of realpath. Add a regression test ("HTTP fileserver Windows backslash path traversal") covering the segment validator, the decode-and-split path, the join+containment backstop, and an end-to-end fileserver check that the encoded-backslash, drive, and UNC traversal requests return 400 while a normal request is still served. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-612 (Anthropic references: ANT-2026-VHDP7ANW). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit eb6f34d24d65cebd61ec5a942d992824d685ffc3) * Scope redirect credential forwarding to the full request origin Redirect handling previously decided whether to retain credential-bearing headers (Authorization, Cookie, Proxy-Authorization, etc.) by comparing only the hostname, ignoring scheme and port. As a result an https->http downgrade or a same-host different-port redirect was treated as same-origin and replayed credentials over plaintext or to a different service. Additionally, per-call cookies= cookies were captured once and re-appended by _cookie_header on every hop even after the Cookie header was stripped, and the TLS verification host was pinned to the original host across redirects because request() always supplies an auto-derived server_name. - _should_copy_sensitive_headers_on_redirect now takes initial/redirect secure flags, returns false on any scheme change, splits host and port (falling back to the scheme default port so bare-host and host:default-port compare equal), returns false on any port change, and only then applies the existing domain-or-subdomain host check. Callers in the HTTP and WebSocket redirect loops pass the scheme flags they already track for Referer handling. - On any cross-origin hop the loops now also reset the explicit-kwarg cookies vector to empty so _cookie_header no longer re-attaches them; the host-scoped cookie jar remains active. - The unreliable explicit_server_name flag is replaced with a check that treats server_name as user-pinned only when it differs from the host auto-derived from the initial URL; otherwise the SNI/verification host is recomputed from the redirect target on every hop so TLS verifies the host actually dialed. Adds helper unit tests for the scheme/port/host origin semantics and an offline two-listener integration test proving a cookies= secret reaches the original origin but not a cross-origin (different-port) redirect target. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-613 (Anthropic references: ANT-2026-5F0FFVVR, ANT-2026-5HZS0066, ANT-2026-HTKXQYJX, ANT-2026-K7VHJB7S, ANT-2026-MBWGTHMA, ANT-2026-PW5H10EB, ANT-2026-R3BBBRAW, ANT-2026-WB9V4R8Q, ANT-2026-8SP7TV55, ANT-2026-GM4FVXDB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit f1ed18df97a642256bafdc58006fafe2c84e638c) * Enforce scheme/host/port in default WebSocket Origin check The default WebSocket Origin validator (_origin_allowed_default) only enforced the host component of the same-origin tuple. It never read the parsed Origin's scheme (parsed.secure), and when the request Host header carried no explicit port (the norm for default-port 80/443 servers, where browsers omit the port) it fell through to a hostname-only comparison, discarding the Origin's port. As a result a server at wss://example.com accepted cross-scheme/cross-port origins such as http://example.com or http://example.com:8080, letting an attacker who controls another port or scheme on the same hostname open an authenticated WebSocket from a victim's browser using the victim's cookies. Rework _origin_allowed_default(request) into _origin_allowed_default(request, server_secure::Bool=false). It now (1) requires the Origin's scheme to match the server transport via parsed.secure == server_secure, rejecting http/ws origins on an https/wss server and vice versa, and (2) compares the Origin's effective port against the server's effective port. The server host/port is derived from the Host header with a new helper _split_host_with_default_port(value, default_port), which substitutes the server scheme's default port (443 when secure, else 80) when the Host header omits a port and safely handles bracketed IPv6 literals instead of blindly splitting on ':'. The Origin's parsed.address already carries a port (defaulting to the Origin scheme's 80/443 when implicit). Final acceptance requires lowercase host equality and port equality, with scheme equality enforced earlier. The server_secure flag is plumbed from all upgrade call sites: _origin_allowed and the _upgrade_response/server convenience method use server.tls_config !== nothing, and the manual upgrade(stream) path uses conn isa TLS.Conn. The empty-Origin allowance, cross-host rejection, and malformed-Origin rejection are preserved; server_secure defaults to false for backward compatibility. Add a regression testset covering same-origin acceptance, cross-port and cross-scheme rejection on default-port servers (the previously mishandled case), cross-host rejection, IPv6 origins, and malformed-Origin rejection for both ws/http and wss/https transports. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-614 (Anthropic references: ANT-2026-DXGTQBSK, ANT-2026-X2Q1J9M4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 63d17cc6272f11da6614207373828e14a5fc506a) * Enforce Secure/prefix protections on the cookie-jar store path setcookies! previously stored every parsed Set-Cookie after only checking that the response scheme was http or https, with no symmetric protection to the read path (shouldsend, which already withholds Secure cookies from non-secure requests). A plaintext (http) origin could therefore plant a Secure cookie, plant a __Secure-/__Host--prefixed cookie, or overwrite/delete (via Max-Age=-1) an existing Secure cookie set over https, enabling cookie fixation against hosts that mix http and https. Add three store-path checks in setcookies!, per RFC 6265bis 5.4/5.6: - Drop any cookie carrying the Secure attribute that arrives over a non-secure scheme. - Enforce the __Secure- (Secure + https origin) and __Host- (Secure + https + Path=/ + no Domain attribute) name prefixes, evaluated on the raw Path/Domain attributes before normalization. The prefix comparison is ASCII case-insensitive (new has_prefix helper) to match RFC 6265bis 4.1.3.1/4.1.3.2 and prevent case-variant evasion such as "__host-". - When the origin is non-secure, refuse to overwrite or delete an existing stored cookie of the same domain;path;name identity that has the Secure attribute. Behavior over https is unchanged. Add regression tests covering Secure-over-http rejection, prefix enforcement (including case-variant prefixes), and clobber/delete protection. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-615 (Anthropic references: ANT-2026-9G63DER3, ANT-2026-GY6HYQTG, ANT-2026-ZK96ACV8). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit c452a62eb7e789e65d9f6090bd520ba94fdd6ca2) * Validate HTTP/1 client request start line for CR/LF/control bytes The HTTP/1 client serialized request.method and request.target (and, in forward-proxy absolute-form, the host) verbatim onto the wire via print(), with no CR/LF/CTL filtering. The only target validator, _validate_request_target!, was wired solely into the server parse path. A caller passing an attacker-influenced URL or method to the client could embed \r\n in the method, path, or query to inject arbitrary request headers, or \r\n\r\n to smuggle a second pipelined request onto a pooled keep-alive (or proxy-forwarded) connection. The HTTP/2 client already rejected CR/LF in :path, so this was an HTTP/1-specific omission. Add _validate_request_start_line!(method, target, host=nothing) in src/http1.jl, placed next to _validate_request_target!. It validates the method against the RFC 7230 token grammar via the existing _valid_header_field_name (which excludes space, CR, LF and other control bytes), delegates target validation to _validate_request_target! (which rejects control bytes and handles origin-form, asterisk-form, absolute-form and CONNECT authority-form), and, when a host is supplied, rejects it if it contains a control byte. Reject with a ParseError, matching the server-side behavior rather than silently rewriting a caller's intended target. Wire the guard into all three HTTP/1 wire start-line writers before any bytes are emitted: _write_start_line! and _append_start_line! in src/http1.jl, and the forward-proxy absolute-form _write_start_line!(io, request, plan) in src/http_transport.jl. The proxy variant validates request.method, request.target and the forward address separately before they are concatenated into the absolute target, so a CRLF-laced host cannot be smuggled through the proxy. This covers origin-form, CONNECT authority-form (tunnel establishment), asterisk-form, websocket, and proxy-forward absolute-form write paths. Add a regression testset in test/http1_wire_tests.jl covering validator-level rejection (method/target/host CR, LF, NUL, embedded space), end-to-end origin-form and proxy-forward write rejection, and benign no-false-positive serialization. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-616 (Anthropic references: ANT-2026-5W0B63VQ, ANT-2026-8WT2MXD5, ANT-2026-23PZ587V, ANT-2026-P0SGS9PG). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit eaabf6d9fd6fedfd8503045adce8e2197bbf9697) * Normalize file-server redirect Location to authority-free single-rooted path The static file server's canonical 301 redirects (index-file strip, directory trailing-slash add, and file trailing-slash strip in _servefile_response) built the Location header verbatim from the un-normalized request target. Request-target validation only requires a leading '/', has no CTL bytes, and the '..' check plus segment decoding (keepempty=false) drop empty segments, so a target like //evil.example/index.html or /\evil.example/ survived validation and produced a Location such as //evil.example/ -- a scheme-relative network-path reference (RFC 3986 4.2) that browsers resolve to a foreign authority, i.e. an open redirect. Add _sanitize_redirect_location, which collapses any leading run of '/' or '\\' separators down to a single '/', re-rooting the Location at the server's own origin so it can never carry an authority component. Backslashes are treated as separators because browsers normalize '\\' to '/' in URLs, closing the /\\evil.example bypass. _redirect_response routes every Location through this sanitizer before setting the header, covering all three canonical-redirect branches and any future callers uniformly. Single-rooted local paths (/docs/, /hello.txt, /) are left untouched, preserving existing behavior and backward compatibility. Add regression coverage exercising the sanitizer directly and end-to-end through servefile/fileserver for the index-strip, directory trailing-slash-add, and file trailing-slash-strip branches; the assertions fail on the un-normalized code. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-617 (Anthropic references: ANT-2026-59AETZK3, ANT-2026-APXT86GW). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 444956f05ad3a7724a5cc05af97eeff27b1a17a4) * Harden HTTP/1 request parsing against framing-differential smuggling The HTTP/1 server request parser had three framing primitives that could make HTTP.jl disagree with a fronting proxy about message boundaries on a reused keep-alive connection. 1. Bare-LF line termination. _readline_crlf(::_ConnReader) tolerated a bare LF on its buffered fast path but required CRLF on the slow path, so the accepted header grammar depended on TCP segmentation; an absorbed bare LF later became a space and silently merged headers. Both paths now require a strict CRLF terminator and reject any LF not preceded by CR with a ParseError, so framing is independent of buffer-fill state. 2. Lenient chunk-size. _parse_chunk_size delegated to Base.parse(Int64, ...; base=16), which tolerates a leading sign, a 0x prefix, and isspace padding (including a trailing bare CR). It is replaced with a strict byte-by-byte 1*HEXDIG parser (chunk extension split off at the first ';') that rejects signs, prefixes, surrounding/embedded whitespace, and empty input, with the Int64-range overflow guard preserved. 3. HTTP/1.0 Transfer-Encoding and TE+Content-Length. For proto < HTTP/1.1, _parse_transfer_encoding! previously stripped Transfer-Encoding and fell back to Content-Length while the connection could stay open. It now rejects such messages with a ProtocolError. read_request additionally rejects any request carrying both Transfer-Encoding and Content-Length instead of silently preferring chunked. All rejections surface as a 400 with the connection force-closed, so no ambiguous trailing bytes remain on the transport. The response-reading and write paths are unchanged except that constructing an HTTP/1.0 message with a Transfer-Encoding header now raises rather than silently dropping the header. Tests cover bare-LF rejection on both the fast and slow _ConnReader paths (including a legitimate CRLF spanning buffer fills), strict chunk-size validation, and the HTTP/1.0-TE and TE+Content-Length rejections. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-618 (Anthropic references: ANT-2026-CN279YCX, ANT-2026-MG2WTZ8Z, ANT-2026-SRPX7DN1, ANT-2026-YD5QTQDZ). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 705b9a63c67ba8f1737c5508a681710409886b85) * Reject CR/LF injection in SSE single-line fields and normalize data line breaks The server-side SSE serializer in src/http_sse.jl wrote the single-line fields event, id and retry verbatim to the text/event-stream wire with no CR/LF filtering, and split the multi-line data field only on '\n', ignoring a bare '\r' that is also a valid SSE line terminator. The SSEEvent constructor validated nothing. An application echoing attacker-influenced text into id/event/retry (e.g. a Last-Event-ID or correlation id) could embed CR/LF to forge additional SSE fields or a blank-line dispatch boundary, injecting complete events into every connected EventSource client. Changes: - Add _reject_sse_line_breaks(name, value) that throws ArgumentError on '\n' or '\r'. - The SSEEvent(data; event, id, retry) keyword constructor now rejects CR/LF in non-nothing event and id, additionally rejects '\0' in id (browser EventSource parsers drop NUL ids), and rejects negative retry so it can only serialize as digits. - write(::SSEStream, ::SSEEvent) re-validates event and id with the same helper before serialization as defense-in-depth, since an SSEEvent can also be built via the raw 5-arg positional constructor used by the client parser. retry is an Int and string() can only emit digits (plus a leading '-' the constructor rejects), so it cannot carry a line break. - Add _split_sse_data_lines using split(value, r"\r\n|\r|\n"; keepempty=true) and use it in place of split(data, '\n') so CR, LF and CRLF in data all normalize to separate data: lines and a bare '\r' can no longer smuggle a wire-level field. The public keyword API stays ergonomic; only malicious or malformed inputs throw. A regression test asserts the exact wire output of a well-formed event, that CR/LF/NUL/negative inputs are rejected via both the keyword and positional construction paths, and that CR/CRLF/LF in data are normalized to separate data: lines with no raw CR on the wire. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-619 (Anthropic references: ANT-2026-G8DXNHAE, ANT-2026-VATEAP9Z, ANT-2026-7273TGMW, ANT-2026-C4BNTGKK). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 04c29bc045b2f6ca5e95ce10382734be2cfcd79f) * Serialize WebSocket reader auto-PONG/CLOSE-echo under sendlock The WebSocket reader task processed incoming frames by calling ws_on_incoming_data! without holding ws.sendlock. That function is not a pure parser: its auto-PONG and CLOSE-echo paths push! onto the shared ws.codec.outgoing_frames Vector (via ws_send_pong!/ws_send_frame!). Application send/ping/pong/close mutate the same Vector (push!/iterate/empty! through _flush_ws_output_locked! -> ws_get_outgoing_data!) while holding ws.sendlock. Because the reader did not take the lock, it provided no exclusion: a remote peer flooding PING frames against a multithreaded server (julia -t N, N>1) could drive concurrent push!/empty! of a Julia Vector from two OS threads, which is undefined behavior and can corrupt the array metadata or segfault the process. Add a helper _process_incoming_data!(ws, data) that acquires ws.sendlock and, under it, runs ws_on_incoming_data! (so the auto-PONG/CLOSE-echo push! happens under the lock) followed by _flush_ws_output_locked!, making each decode atomic with its flush. Route all three production decode call sites through it: the reader loop in _ws_read_loop! (the race site) and the two handshake-buffered sites in the client open and server upgrade paths (for consistency/defense-in-depth; both run before the reader task starts). The blocking readbytes! in the reader loop stays outside the lock so concurrent senders cannot deadlock the reader; lock ordering is unchanged (close nests closelock->sendlock and releases both before waiting). A concurrency-invariant comment documents why ws_on_incoming_data! must hold ws.sendlock. Add a concurrency regression test that drives the reader path and the application send path from many tasks at once and asserts no failures and a drained buffer; it discriminates the locked vs unlocked path under julia -t N, N>1. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-620 (Anthropic references: ANT-2026-YCN945B6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit ea3f7cc410a9dee1f9a701f30cffa9b0656926d1) * Fix thread-safety and out-of-bounds reads in content-type sniffer The HTTP content-type sniffer had two memory/concurrency bugs. Number sniffing in isjson stored the strtod end-pointer out-parameter in a shared module-global Vector{Ptr{UInt8}}. Concurrent sniff calls on a multithreaded server raced on that single cell between the ccall and the subsequent read, producing a non-deterministic consumed length and parse result that an attacker could use to influence content-type classification. The bytes handed to jl_strtod_c were also not NUL-terminated, so strtod could read past the provided buffer. Remove the shared global; use a per-call local Ref for the end pointer; and copy the remaining numeric input into a freshly allocated NUL-terminated buffer (new helper _sniff_number_buffer) so strtod is bounded by our own NUL. The consumed-length arithmetic and classification behavior are unchanged. The MP4 ftyp matcher iterated for st in 9:4:(boxsize+1) and compared 3-byte brand windows via _byte_equal(data, st, _SNIFF_MP4) under @inbounds, reading data[st .. st+2]. The only guard was length(data) < boxsize, so when boxsize == length(data) the loop read up to data[length(data)+3]: a BoundsError under forced bounds checks and a silent heap over-read otherwise. Clamp the loop upper bound to min(boxsize+1, length(data) - length(_SNIFF_MP4) + 1) so every comparison window lies fully within the buffer. Add focused tests: concurrent number sniffs that must classify deterministically, NUL-termination of the numeric buffer, and boundary-length MP4 buffers. The MP4 case is additionally re-run in a subprocess under --check-bounds=yes, which overrides @inbounds and turns the unpatched over-read into an observable BoundsError, so the regression deterministically fails on unpatched code regardless of how the test runner is invoked. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-621 (Anthropic references: ANT-2026-5FMZ73VG, ANT-2026-07KFWYV3, ANT-2026-9PKP3RJA). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 0b658bc9a9769800d0cf3b223cec9cacef37e670) * Generate WebSocket masking key and handshake nonce from a CSPRNG The WebSocket client masking key (ws_send_frame!) and the Sec-WebSocket-Key handshake nonce (ws_random_handshake_key) were generated with rand(UInt8, n), which draws from the task-local Xoshiro256++ PRNG. Xoshiro is not cryptographically secure: its internal state can be recovered from a short run of observed outputs, and every outbound client frame exposes 4 mask bytes on the wire. An on-path observer could therefore recover the RNG state and predict all future masking keys, defeating the RFC 6455 5.3 anti-cache-poisoning purpose of masking (a predictable mask lets an attacker who also supplies payload craft wire bytes a non-conformant transparent proxy parses and caches as HTTP). Introduce a module-level CSPRNG (const WS_CSPRNG = Random.RandomDevice()) and a ws_secure_random_bytes helper, and route both the 4-byte masking key and the 16-byte handshake nonce through it. Random was already imported by the module. Behavior is otherwise unchanged and the wire format and public API are unaffected. Add a regression testset that pins the security property: seeding the task-local RNG identically before two secret generations must not reproduce the secrets (this fails on the unpatched seedable code), plus shape and non-constant checks for the 4-byte key and 16-byte nonce. Declare Random in test/Project.toml so the test loads under the package's explicit test-dependency convention. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-622 (Anthropic references: ANT-2026-90M07PW7, ANT-2026-ZQ6ARSMP, ANT-2026-K6WWQH2N). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit d5beeed4125bddcdf6e3f7450f532db4762b1567) * Strictly validate HTTP/2 request pseudo-headers and Host/:authority consistency The HTTP/2 server's request validator (_validate_h2_request_headers! in src/http2_server.jl) only passed :method, :path, and :authority through _normalize_strict_header_field_value, which rejects CR/LF/CTL but permits SP/HTAB and applies no host or token grammar. As a result a :method such as "GET /admin?x=" was accepted, :path could carry interior whitespace, and :authority was never host-validated; on HTTP/1 downgrade these were written verbatim into the request line, enabling request smuggling past path-based ACLs. Separately, a request could carry both a benign :authority and a mismatched Host header: _decode_h2_request sets request.host from :authority but leaves the Host header in request.headers, and the HTTP/1 serializer forwards existing Host headers verbatim, so a proxy could authorize on :authority while forwarding a hostile Host to an origin. Bring h2-server pseudo-header validation to the same strictness already used by the h1 server and h2 client: - :method must match the RFC 9110 token charset (_valid_header_field_name), rejecting SP/HTAB/CTL/non-token values. - :path must not contain interior SP/HTAB (new _string_contains_whitespace_byte helper in src/http_core.jl; CR/LF/CTL were already rejected). - :authority must pass _valid_host_header (the h1 server's host validator). - Per RFC 9113 8.3.1, when :authority is present every Host header value must equal it; otherwise the request is rejected as malformed. The check iterates all stored Host entries (not just the first) so multiple/non-adjacent Host headers cannot smuggle a mismatched authority through HTTP/1 downgrade. Well-formed and CONNECT request paths are unchanged. Adds regression tests covering each rejection, the multiple/non-adjacent Host case, and that well-formed, CONNECT, and matching-Host requests still validate. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-623 (Anthropic references: ANT-2026-565042FN, ANT-2026-CWYA87HX). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit f1d08b61b41fd1024d029036b7784367742a6cd6) * Decode HPACK header blocks for unknown HTTP/2 streams and enforce CONTINUATION sequencing on the client The HTTP/2 client's _process_incoming_frame! dropped HEADERS/CONTINUATION frames for stream ids absent from conn.streams via `state === nothing && return nothing`, without passing the header block through conn.decoder. HPACK's dynamic table is connection-scoped and mutated as a side effect of decoding each header block (RFC 7541 2.3.2/4), so skipping even one block permanently desyncs the client decoder from the server encoder, causing later indexed header references on other multiplexed streams to resolve to wrong name/value pairs or throw "HPACK index out of range". This is reachable by a malicious server or by a benign race where trailers arrive after _unregister_stream!. The client also lacked the CONTINUATION sequencing enforcement the server already performs. Add three read-loop-scoped fields to H2Connection: continuation_stream (id of the stream whose header block is still open, 0 if none), open_header_stream (the live stream a multi-frame block belongs to, so trailing CONTINUATIONs still reach it even if the application thread unregisters the stream mid-block), and orphan_header_block (scratch buffer accumulating header-block fragments for unknown/closed streams). Initialize them in the constructor. Add _consume_orphan_header_fragment!, which bounds the scratch buffer by max_header_block_bytes, accumulates fragments across HEADERS+CONTINUATION, and on END_HEADERS calls decode_header_block purely for the dynamic-table side effect before discarding, clearing the buffer and continuation_stream in a finally. In _process_incoming_frame!, add a top-of-function CONTINUATION sequencing guard (RFC 7540 6.10): when continuation_stream != 0 the next frame must be a CONTINUATION for that same stream, otherwise a ProtocolError; a CONTINUATION with no open block is also a ProtocolError. Route HEADERS/CONTINUATION for unknown streams through the orphan path and live multi-frame blocks through the captured open_header_stream. All new fields are touched only from the single-threaded read loop, so no extra locking is required. Add regression tests driving the internal frame handlers over a bare H2Connection: one verifies that a header block for an unregistered stream is still HPACK-decoded so a later stream's indexed references resolve correctly, and one verifies CONTINUATION sequencing is enforced (DATA in place of CONTINUATION, and an unsolicited CONTINUATION, are connection errors; a multi-frame orphan block is decoded across frames). Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program and tracked as JLSEC-2026-624 (Anthropic references: ANT-2026-SCEWC4G3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit a77a1a2bef3205657c4f0817214a80e32fec4b51) * Bound high-level WebSocket inbound message buffering Wire the public maxframesize setting into the underlying WebSocket codec so oversized frame headers are rejected before payload bytes are buffered. High-level WebSocket clients and servers now default to a finite 16 MiB frame/message limit instead of typemax(Int), while callers can still opt into larger limits explicitly. Oversized codec rejections are mapped to WebSocket close code 1009 for parity with the existing post-decode size check. Adds regression coverage proving a high-level WebSocket rejects an oversized frame at header decode time and that the default maxframesize is finite. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program. Anthropic references: ANT-2026-119PTS6Z, ANT-2026-69ZRFT60. * Reject control bytes in HTTP response reason phrases Validate the HTTP/1 response reason phrase before serializing the status line. Custom reasons containing CR, LF, NUL, DEL, or other control bytes now raise ProtocolError instead of being written verbatim into the wire response, while default and safe custom reason phrases remain unchanged. Adds regression coverage for both status-line serialization paths and write_response!. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program. Anthropic references: ANT-2026-47BXJSCB, ANT-2026-B9E07QMW, ANT-2026-DC12T2PB, ANT-2026-HRC4SY7X. * Reject public-suffix cookies and invalid cookie names Switch cookie-name validation to the RFC token grammar so delimiters, whitespace, and control bytes cannot be serialized into Cookie or Set-Cookie headers. This closes the any-vs-all validation bug while preserving the existing BoundsError regression for high ASCII cookie names that are valid tokens. Vendor a generated ASCII subset of the official Public Suffix List (version 2026-06-13_21-47-18_UTC, commit 9186eeeda85cef35b1551d00731464939c765cab) and use standard longest-rule matching, including wildcard and exception rules, to reject Set-Cookie Domain attributes that are public suffixes. Normal registrable-domain cookies such as Domain=example.com remain accepted. Tests cover delimiter-name rejection, PSL examples including co.uk/github.io/herokuapp.com/s3.amazonaws.com, wildcard/exception behavior, and normal sibling-subdomain cookies. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program. Anthropic references: ANT-2026-76NKG95A, ANT-2026-DWY11CF6, ANT-2026-RJZMA1RW, ANT-2026-TMN5189D, ANT-2026-TTD0NRAZ, ANT-2026-WF563CJR. * Ignore HTTP/2 WINDOW_UPDATE frames for unknown client streams The HTTP/2 client no longer creates stream_send_window entries when a peer sends a stream-level WINDOW_UPDATE for an idle, closed, or otherwise unknown stream. Connection-level updates and tracked live-stream updates are unchanged. Adds a bare-connection regression test that proves unknown and closed-stream updates do not grow the map while live-stream updates still increase the send window. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program. Anthropic references: ANT-2026-68EXRPGQ, ANT-2026-8JZYNDQD. * Clamp peer-advertised HPACK encoder table sizes Clamp SETTINGS_HEADER_TABLE_SIZE before applying it to the local HPACK encoder on both HTTP/2 server and client connections. Peers may advertise a larger receive table, but HTTP.jl now caps the encoder dynamic table at 64 KiB per connection instead of allowing unbounded peer-controlled growth. Adds focused tests for the client and server settings handlers and preserves existing zero-size table behavior. Reported to the JuliaLang security team through Anthropic's Coordinated Vulnerability Disclosure program. Anthropic reference: ANT-2026-JKEVPKR9. * Require router regex constraints to match full segments Route variable regex constraints previously used substring matching, so a route such as /users/{id:[0-9]+} accepted segments like 123abc or abc123 while exposing the full unvalidated segment to handlers. Require constrained variables to match the entire path segment and add regression tests for prefix/suffix garbage around constrained route parameters. Covers ANT-2026-A297EQFH. * Close servefile sources on conditional empty responses servefile opened the file before evaluating conditional and range headers, but only transferred ownership to the response when servecontent returned a streaming body. Conditional 304, failed-precondition 412, and unsatisfiable-range 416 responses returned EmptyBody and left the opened file for GC-driven finalization. Finalize every opened servefile source deterministically: streaming responses adopt the IO through _SeekableResponseBody, empty responses close it immediately, and exception paths close before rethrowing. Add focused ownership regression coverage and an If-None-Match servefile check. Covers ANT-2026-BXCQ4CZK. * Bound ordinary server request body buffering The ordinary serve! path materialized request bodies before dispatching handlers. H1 chunked/unknown bodies and H2 request bodies could therefore grow an IOBuffer without any application-visible opportunity to reject the upload first. Add a default-on max_body_bytes server cap for buffered Request handlers, map cap violations to 413 Content Too Large, and preserve a max_body_bytes=0 escape hatch for legacy unlimited buffering. H2 writes the error response before closing the request read side so oversized streams get a real 413 response instead of reset-only behavior. Add focused H1 content-length/chunked and H2 raw-frame regressions plus docs for the new limit. Covers ANT-2026-5FGBYW28 and ANT-2026-K4A2QR9Y. * Enable a default client decompression output cap HTTP clients already supported max_decompressed_size, but the default of 0 left automatic gzip/deflate materialization unbounded. A malicious compressed response could therefore inflate until the process ran out of memory unless callers knew to opt in. Default max_decompressed_size to 64 MiB, keep max_decompressed_size=0 as the explicit unlimited opt-out, and reject negative limits. Extend the decompression-bomb tests to cover both default rejection and the opt-out path. Covers ANT-2026-4EG5QVV7. * Bound SSE client line and event buffering The SSE client parser could accept an initial SSE-looking token, then append an arbitrarily long unterminated line into its partial buffer without invoking user callbacks or enforcing the server-side SSEStream max_len. Add finite default client parser limits for one response line and one accumulated event, expose max_sse_line_bytes and max_sse_event_bytes on high-level request calls, and preserve explicit zero as the opt-out. Add parser-level and high-level sse_callback regressions for the long-line attack shape. Covers ANT-2026-PC5S9F2J. * Escape terminal controls in response display The text/plain Response display printed server-controlled reason phrases and textual body previews directly to the terminal. ANSI and OSC control sequences in either field could affect a user's REPL terminal when a response was auto-displayed. Render C0/C1 controls as visible escape text in compact response summaries, response status lines, and textual body previews without changing the underlying response bytes. Add regressions for ESC and BEL in both reason and body output. Covers ANT-2026-X65C86KB. * perf(cookies): compact public suffix lookup Replace the generated Set(String[...]) public suffix table with a Julia port of the compact golang.org/x/net/publicsuffix table walker. The copied data comes from the latest golang/net origin/master checked during this change (9e7fdbfadb32b0cc7524100014c5cf9b6adc7729), including the Go package's checked-in PSL snapshot and BSD license. Validation: - JULIA_NUM_THREADS=4 julia --project=test test/http_cookie_tests.jl * fix(redirects): require exact host for credentials Tighten sensitive redirect credential retention to exact normalized host equality after scheme and port checks. This avoids copying caller-supplied Authorization/Cookie headers to child hosts, while leaving domain-scoped cookie policy to the cookie jar. Update helper coverage for normalized exact hosts and child-host redirects. * Bump version to v2.4.0 * Fix Server docstring binding * Relax raw server close probe timeout * Relax raw HTTP/2 server frame timeout on Windows * Narrow stream response type for trim compile * Inline response status line helpers for trim compile --------- Co-authored-by: Keno Fischer <Keno@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9e830d9 commit b5219f5

38 files changed

Lines changed: 2929 additions & 216 deletions

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "HTTP"
22
uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
3-
version = "2.3.1"
3+
version = "2.4.0"
44
authors = ["Jacob Quinn", "contributors: https://github.com/JuliaWeb/HTTP.jl/graphs/contributors"]
55

66
[deps]

docs/src/guides/protocols.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ end
9595
HTTP and WebSocket routes. Compression is most beneficial for larger, repetitive
9696
text/JSON payloads; tiny or already-compressed (binary/media) messages gain
9797
little. Decompressed message size is bounded by `maxframesize`, guarding against
98-
decompression bombs.
98+
decompression bombs. `maxframesize` defaults to 16 MiB for high-level
99+
WebSocket clients and servers; pass a larger value explicitly if your protocol
100+
requires larger messages.
99101

100102
## HTTP/2 Support
101103

docs/src/guides/server.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ server = HTTP.serve!(
112112
The older `readtimeout` keyword is accepted as a seconds-valued migration alias
113113
for `read_timeout`.
114114

115+
Ordinary `serve!` request handlers receive a buffered `HTTP.Request` body. That
116+
buffering is capped by `max_body_bytes`, which defaults to 64 MiB. Raise the
117+
limit for larger in-memory uploads, pass `max_body_bytes = 0` to restore legacy
118+
unbounded buffering, or use `listen!`/stream handlers when the application
119+
should manage large request bodies incrementally.
120+
115121
## Routing and Middleware
116122

117123
Use `HTTP.Router` when you want route matching without bringing in a

src/hpack.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,8 @@ end
584584
return 32 + ncodeunits(name) + ncodeunits(value)
585585
end
586586

587+
const _MAX_ENCODER_DYNAMIC_TABLE_SIZE = 64 * 1024
588+
587589
function DynamicTable(max_size::Integer=4096)
588590
max_size >= 0 || throw(ArgumentError("dynamic table max_size must be >= 0"))
589591
return DynamicTable(DynamicTableEntry[], Int(max_size), 0)

src/http1.jl

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,16 @@ function _parse_transfer_encoding!(hdrs::Headers, proto_major::UInt8, proto_mino
248248
values = headers(hdrs, "Transfer-Encoding")
249249
isempty(values) && return false
250250
if proto_major < UInt8(1) || (proto_major == UInt8(1) && proto_minor < UInt8(1))
251-
removeheader(hdrs, "Transfer-Encoding")
252-
return false
251+
# RFC 9112 §6.1: an HTTP/1.0 message must not use Transfer-Encoding; a
252+
# recipient that receives one has faulty framing. The old behavior
253+
# silently dropped the header and fell back to Content-Length while
254+
# leaving a keep-alive connection open, so a reverse proxy that framed
255+
# the body as chunked could leave the trailing chunked bytes on the
256+
# reused connection for HTTP.jl to parse as a smuggled pipelined request
257+
# (ANT-2026-YD5QTQDZ). Reject instead: this surfaces as a 400 and the
258+
# server closes the connection (Response close=true), so no ambiguous
259+
# bytes remain on the transport.
260+
throw(ProtocolError("Transfer-Encoding is not allowed in HTTP/1.0 messages"))
253261
end
254262
length(values) == 1 || throw(ProtocolError("too many Transfer-Encoding header values"))
255263
raw = _trim_http_ows(values[1])
@@ -281,6 +289,27 @@ function _validate_request_target!(method::String, target::String)::Nothing
281289
end
282290
end
283291

292+
# Client-side guard for the HTTP/1 request start line. The method and target
293+
# flow from caller-constructed `Request` objects (often built by interpolating
294+
# user-controlled URL components) straight onto the wire via `print`, so we must
295+
# reject CR/LF and other control bytes here before serialization. Without this an
296+
# attacker who influences the URL path/query/method could embed `\r\n` to inject
297+
# headers or smuggle a pipelined request on a pooled keep-alive connection. The
298+
# server read path already enforces this via `_validate_request_target!`; this is
299+
# the symmetric check for the client write path (the HTTP/2 client validates
300+
# `:path`/`:method` analogously). `host`, when present, is the authority used in
301+
# absolute-form/proxy-forward start lines and must be control-byte-free as well.
302+
function _validate_request_start_line!(method::String, target::String, host::Union{Nothing,AbstractString}=nothing)::Nothing
303+
# An HTTP method is an RFC 7230 token; `_valid_header_field_name` enforces
304+
# exactly that grammar (and so excludes spaces, CR, LF and other CTLs).
305+
_valid_header_field_name(method) || throw(ParseError("invalid HTTP method: $(repr(method))"))
306+
_validate_request_target!(method, target)
307+
if host !== nothing
308+
_string_contains_ctl_byte(host) && throw(ParseError("invalid HTTP request host: $(repr(host))"))
309+
end
310+
return nothing
311+
end
312+
284313
function _validate_request_host!(hdrs::Headers, method::String, proto_major::UInt8, proto_minor::UInt8)::Union{Nothing,String}
285314
host_values = headers(hdrs, "Host")
286315
if (proto_major > UInt8(1) || (proto_major == UInt8(1) && proto_minor >= UInt8(1))) &&
@@ -331,19 +360,37 @@ function _consume_crlf(io::IO)
331360
end
332361

333362
function _parse_chunk_size(line::AbstractString)::Int64
334-
trimmed = _trim_http_ows(line)
335-
isempty(trimmed) && throw(ParseError("empty chunk size line"))
336-
semi = findfirst(';', trimmed)
337-
token = semi === nothing ? trimmed : String(SubString(trimmed, firstindex(trimmed), prevind(trimmed, semi)))
338-
token = _trim_http_ows(token)
339-
isempty(token) && throw(ParseError("empty chunk size"))
340-
size = try
341-
parse(Int64, token; base=16)
342-
catch
343-
throw(ParseError("invalid chunk size: $(repr(line))"))
363+
# RFC 9112 §7.1: chunk = chunk-size [ chunk-ext ] CRLF, where
364+
# chunk-size = 1*HEXDIG. The chunk-size token must be one or more ASCII
365+
# hex digits with no sign, no "0x" prefix, and no surrounding whitespace.
366+
# We split off any chunk extension at the first ';' and then validate the
367+
# remaining token byte-by-byte instead of using Base.parse(Int64,...;base=16),
368+
# which silently tolerates '+'/'-', a "0x" prefix, and isspace padding
369+
# (including a trailing bare CR). Accepting those forms lets HTTP.jl disagree
370+
# with an RFC-strict front-end proxy about where the chunked body ends, which
371+
# enables HTTP request smuggling (ANT-2026-SRPX7DN1). This mirrors the strict
372+
# decimal parser used for Content-Length (_parse_int64_decimal_header_value).
373+
semi = findfirst(';', line)
374+
token = semi === nothing ? line : SubString(line, firstindex(line), prevind(line, semi))
375+
units = codeunits(token)
376+
isempty(units) && throw(ParseError("empty chunk size"))
377+
limit = UInt64(typemax(Int64))
378+
total = UInt64(0)
379+
@inbounds for b in units
380+
if 0x30 <= b <= 0x39 # '0'-'9'
381+
digit = UInt64(b - 0x30)
382+
elseif 0x41 <= b <= 0x46 # 'A'-'F'
383+
digit = UInt64(b - 0x41 + 0x0a)
384+
elseif 0x61 <= b <= 0x66 # 'a'-'f'
385+
digit = UInt64(b - 0x61 + 0x0a)
386+
else
387+
throw(ParseError("invalid chunk size: $(repr(line))"))
388+
end
389+
# Guard against overflow past Int64 range (also keeps the result >= 0).
390+
total > ((limit - digit) >> 4) && throw(ParseError("chunk size too large: $(repr(line))"))
391+
total = (total << 4) + digit
344392
end
345-
size < 0 && throw(ParseError("negative chunk size"))
346-
return size
393+
return Int64(total)
347394
end
348395

349396
function _read_next_chunk!(body::ChunkedBody)
@@ -409,6 +456,8 @@ end
409456

410457
function _write_start_line!(io::IO, request::Request, wire_target::Union{Nothing,AbstractString}=nothing)
411458
target = wire_target === nothing ? request.target : String(wire_target)
459+
# Reject CR/LF/CTL in the method or target before they reach the socket.
460+
_validate_request_start_line!(request.method, target)
412461
print(io, request.method, ' ', target, " HTTP/", Int(request.proto_major), '.', Int(request.proto_minor), "\r\n")
413462
return nothing
414463
end
@@ -479,14 +528,20 @@ function _status_text(status::Integer)::String
479528
return ""
480529
end
481530

482-
function _write_status_line!(io::IO, response::Response)
531+
@inline function _response_reason_phrase(response::Response)::String
483532
reason = isempty(response.reason) ? _status_text(response.status) : response.reason
533+
_string_contains_ctl_byte(reason) && throw(ProtocolError("invalid HTTP response reason phrase: $(repr(reason))"))
534+
return reason
535+
end
536+
537+
@inline function _write_status_line!(io::IO, response::Response)::Nothing
538+
reason = _response_reason_phrase(response)
484539
print(io, "HTTP/", Int(response.proto_major), '.', Int(response.proto_minor), ' ', response.status, ' ', reason, "\r\n")
485540
return nothing
486541
end
487542

488-
function _append_status_line!(buf::IOBuffer, response::Response)
489-
reason = isempty(response.reason) ? _status_text(response.status) : response.reason
543+
@inline function _append_status_line!(buf::IOBuffer, response::Response)::Nothing
544+
reason = _response_reason_phrase(response)
490545
print(buf, "HTTP/", Int(response.proto_major), '.', Int(response.proto_minor), ' ', response.status, ' ', reason, "\r\n")
491546
return nothing
492547
end
@@ -698,6 +753,8 @@ end
698753

699754
function _append_start_line!(buf::IOBuffer, request::Request, wire_target::Union{Nothing,AbstractString}=nothing)
700755
target = wire_target === nothing ? request.target : String(wire_target)
756+
# Reject CR/LF/CTL in the method or target before they reach the socket.
757+
_validate_request_start_line!(request.method, target)
701758
print(buf, request.method, ' ', target, " HTTP/", Int(request.proto_major), '.', Int(request.proto_minor), "\r\n")
702759
return nothing
703760
end
@@ -897,6 +954,15 @@ function read_request(io::IO; max_line_bytes::Integer=_HTTP1_DEFAULT_MAX_LINE_BY
897954
method, target, proto_major, proto_minor = _parse_request_line(line)
898955
headers = _read_headers(io, max_line_bytes, max_header_bytes)
899956
chunked = _parse_transfer_encoding!(headers, proto_major, proto_minor)
957+
# A request that carries BOTH Transfer-Encoding and Content-Length has
958+
# ambiguous framing (RFC 9112 §6.1/§6.3): a front-end proxy may frame by one
959+
# header while HTTP.jl frames by the other, leaving trailing bytes on a
960+
# reused keep-alive connection that get parsed as a smuggled request
961+
# (ANT-2026-YD5QTQDZ). Reject such requests outright (surfaced as 400 with
962+
# the connection closed) instead of silently preferring Transfer-Encoding.
963+
if chunked && hasheader(headers, "Content-Length")
964+
throw(ProtocolError("request must not include both Transfer-Encoding and Content-Length"))
965+
end
900966
content_length = _parse_content_length(headers)
901967
chunked && removeheader(headers, "Content-Length")
902968
content_length = chunked ? Int64(-1) : content_length

src/http2_client.jl

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,25 @@ mutable struct H2Connection
156156
# Per-stream receive buffer cap applied to every `H2StreamState` opened on
157157
# this connection. Configured at connect time; defaults to 256 KiB.
158158
max_buffered_bytes::Int
159+
# CONTINUATION sequencing state, mirroring the server read loop. When a
160+
# HEADERS/CONTINUATION frame arrives without END_HEADERS, this records the
161+
# stream id whose header block is still open; the next frame on the
162+
# connection MUST be a CONTINUATION for the same stream (RFC 7540 6.10).
163+
# Touched only from the single-threaded read loop, so it needs no lock.
164+
continuation_stream::UInt32
165+
# While a header block is open on a live stream, holds that stream's state so
166+
# the trailing CONTINUATION frames are still delivered to it even if the
167+
# application thread unregisters the stream mid-block (the read loop and
168+
# `_unregister_stream!` run concurrently). `nothing` when no live block is
169+
# open. Touched only from the single-threaded read loop.
170+
open_header_stream::Union{Nothing,H2StreamState}
171+
# Scratch buffer that accumulates header block fragments for a header block
172+
# belonging to an unknown/closed stream. HPACK's dynamic table is
173+
# connection-scoped (RFC 7541 2.3.2 / 4), so even discarded header blocks
174+
# must be decoded as a single block to keep the decoder in sync; we buffer
175+
# the fragments here until END_HEADERS, decode once, then discard.
176+
# Touched only from the single-threaded read loop.
177+
orphan_header_block::Vector{UInt8}
159178
# Concurrent acquirers that have committed to opening a stream on this
160179
# connection but haven't reached `_register_stream!` yet. Used by
161180
# `_h2_conn_available` so a second caller doesn't decide a peer-capped
@@ -343,7 +362,7 @@ function _apply_peer_settings!(conn::H2Connection, settings::Vector{Pair{UInt16,
343362
unlock(conn.state_lock)
344363
end
345364
if header_table_size !== nothing
346-
size = header_table_size::Int
365+
size = min(header_table_size::Int, _MAX_ENCODER_DYNAMIC_TABLE_SIZE)
347366
lock(conn.write_lock)
348367
try
349368
set_max_dynamic_table_size_limit!(conn.encoder, size)
@@ -752,9 +771,65 @@ function _send_window_updates!(conn::H2Connection, stream_id::UInt32, nbytes::In
752771
return nothing
753772
end
754773

774+
"""
775+
_consume_orphan_header_fragment!(conn, stream_id, fragment, end_headers)
776+
777+
Buffer and (on END_HEADERS) HPACK-decode a header block that belongs to an
778+
unknown or already-closed stream, discarding the decoded result.
779+
780+
HPACK's dynamic table is connection-scoped and mutated as a side effect of
781+
decoding each header block (RFC 7541 2.3.2, 4). A header block that is silently
782+
dropped instead of decoded leaves the client's dynamic table permanently out of
783+
step with the server's encoder, so every later indexed reference on *any*
784+
stream of the connection resolves against a stale table. We therefore must still
785+
feed these bytes through `conn.decoder`. Because HPACK instructions may straddle
786+
fragment boundaries, the block is accumulated across HEADERS/CONTINUATION frames
787+
and decoded once when END_HEADERS arrives, exactly as for live streams.
788+
"""
789+
function _consume_orphan_header_fragment!(
790+
conn::H2Connection,
791+
stream_id::UInt32,
792+
fragment::Vector{UInt8},
793+
end_headers::Bool,
794+
)
795+
remaining = conn.max_header_block_bytes - length(conn.orphan_header_block)
796+
if remaining < 0 || length(fragment) > remaining
797+
# The peer cannot be allowed to grow this scratch buffer without bound.
798+
# An oversized block on an unknown stream is a connection-level fault.
799+
empty!(conn.orphan_header_block)
800+
conn.continuation_stream = UInt32(0)
801+
throw(ProtocolError("HTTP/2 response header block exceeded maximum size"))
802+
end
803+
append!(conn.orphan_header_block, fragment)
804+
if end_headers
805+
# Decode for the dynamic-table side effect only, then discard the result.
806+
try
807+
decode_header_block(conn.decoder, conn.orphan_header_block)
808+
finally
809+
empty!(conn.orphan_header_block)
810+
conn.continuation_stream = UInt32(0)
811+
end
812+
else
813+
conn.continuation_stream = stream_id
814+
end
815+
return nothing
816+
end
817+
755818
function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
756819
# Frame-local validation already happened in `read_frame!`; this function is
757820
# responsible for connection- and stream-level state transitions.
821+
#
822+
# CONTINUATION sequencing (RFC 7540 6.10): a HEADERS/CONTINUATION frame that
823+
# does not set END_HEADERS must be followed only by a CONTINUATION frame for
824+
# the same stream. Any other frame in that window is a connection error. This
825+
# mirrors the enforcement already performed in the server read loop.
826+
if conn.continuation_stream != UInt32(0)
827+
if !(frame isa ContinuationFrame && (frame::ContinuationFrame).stream_id == conn.continuation_stream)
828+
throw(ProtocolError("expected CONTINUATION for stream $(conn.continuation_stream)"))
829+
end
830+
elseif frame isa ContinuationFrame
831+
throw(ProtocolError("unexpected CONTINUATION frame"))
832+
end
758833
if frame isa SettingsFrame
759834
settings = frame::SettingsFrame
760835
if !settings.ack
@@ -805,9 +880,8 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
805880
try
806881
if update.stream_id == UInt32(0)
807882
conn.conn_send_window += increment
808-
else
809-
current = get(() -> conn.initial_stream_send_window, conn.stream_send_window, update.stream_id)
810-
conn.stream_send_window[update.stream_id] = current + increment
883+
elseif haskey(conn.stream_send_window, update.stream_id)
884+
conn.stream_send_window[update.stream_id] += increment
811885
end
812886
notify(conn.window_condition; all=true)
813887
finally
@@ -817,16 +891,48 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
817891
elseif frame isa HeadersFrame
818892
headers = frame::HeadersFrame
819893
state = _stream_state(conn, headers.stream_id)
820-
state === nothing && return nothing
894+
if state === nothing
895+
# Unknown or already-closed stream (e.g. trailers racing
896+
# `_unregister_stream!`, or a malicious server). The header block is
897+
# not delivered to any application stream, but it MUST still pass
898+
# through the HPACK decoder to keep the connection's dynamic table
899+
# synchronized (RFC 7541 4); otherwise all later streams desync.
900+
_consume_orphan_header_fragment!(conn, headers.stream_id, headers.header_block_fragment, headers.end_headers)
901+
return nothing
902+
end
821903
_handle_stream_header_fragment!(conn, state::H2StreamState, headers.header_block_fragment, headers.end_headers, headers.end_stream)
904+
# Track an open header block so a missing END_HEADERS forces CONTINUATION
905+
# (RFC 7540 6.10) and the trailing frames still reach this stream even if
906+
# the application thread unregisters it before the block completes.
907+
if headers.end_headers
908+
conn.continuation_stream = UInt32(0)
909+
conn.open_header_stream = nothing
910+
else
911+
conn.continuation_stream = headers.stream_id
912+
conn.open_header_stream = state::H2StreamState
913+
end
822914
return nothing
823915
elseif frame isa PushPromiseFrame
824916
throw(ProtocolError("HTTP/2 server push is unsupported"))
825917
elseif frame isa ContinuationFrame
826918
cont = frame::ContinuationFrame
827-
state = _stream_state(conn, cont.stream_id)
828-
state === nothing && return nothing
829-
_handle_stream_header_fragment!(conn, state::H2StreamState, cont.header_block_fragment, cont.end_headers, false)
919+
open_state = conn.open_header_stream
920+
if open_state !== nothing
921+
# Continuation of a header block that began on a live stream. Route
922+
# it to the captured state even if the stream was unregistered
923+
# mid-block so the block is decoded as a single unit.
924+
_handle_stream_header_fragment!(conn, open_state::H2StreamState, cont.header_block_fragment, cont.end_headers, false)
925+
if cont.end_headers
926+
conn.continuation_stream = UInt32(0)
927+
conn.open_header_stream = nothing
928+
else
929+
conn.continuation_stream = cont.stream_id
930+
end
931+
return nothing
932+
end
933+
# Otherwise this continues a header block for an unknown/closed stream:
934+
# decode for the HPACK side effect and discard, just like HEADERS above.
935+
_consume_orphan_header_fragment!(conn, cont.stream_id, cont.header_block_fragment, cont.end_headers)
830936
return nothing
831937
elseif frame isa DataFrame
832938
data = frame::DataFrame
@@ -1014,6 +1120,9 @@ function _connect_h2_from_tcp!(
10141120
true,
10151121
_H2_DEFAULT_MAX_HEADER_BLOCK_BYTES,
10161122
_h2_buffered_bytes(http2_settings),
1123+
UInt32(0),
1124+
nothing,
1125+
UInt8[],
10171126
0,
10181127
false,
10191128
)

0 commit comments

Comments
 (0)