Repository navigation
Description
fleet-proxy(connect-udp): request-head bound is checked only after a whole line arrives — one newline-free line accumulates without limit
Summary
The CONNECT-UDP endpoint's request-head reader claims a 64 KiB bound
(MAX_HEAD_BYTES, "the bound exists so a peer cannot make the parser
accumulate without limit"), and the bound is enforced across many
complete lines — but a single line with no \n defeats it completely.
read_head_line reads the entire line into memory first and only then
checks the cumulative total, so a peer streaming one newline-free line
makes the parser accumulate without limit for as long as it keeps
sending. The sibling HTTP reader in the same crate does this correctly.
Evidence (origin/main 540d3bea3f)
pkgs/fleet/crates/fleet-proxy/src/connect_udp.rs:
:76—pub const MAX_HEAD_BYTES: usize = 64 * 1024;with the doc
comment: "the bound exists so a peer cannot make the parser
accumulate without limit (peer-declared sizes are never trusted,
fleet-proxy(http): response reader trusts peer-declared sizes (unbounded preallocation) and silently drops the body on a malformed Content-Length, desyncing the pooled connection #5579)".:261-271—read_head_line:let n = reader.read_until(b'\n', &mut line)?;
with no limit applied to the read itself;*total += nand the
*total > MAX_HEAD_BYTEScheck happen only afterread_until
returns — i.e. after the whole line (however large) is already
allocated inline.:284—read_requestreads the request line and every header line
throughread_head_line, so both the request line and any single
header line are exploitable.:355—serve_sessionsets a 250 ms read timeout (RELAY_POLL),
but that only fires on an idle gap; a peer that keeps sending (the
normal case for a flood) never trips it, and the session thread
stays inread_requestaccumulating.
Contrast — the correct pattern in the same crate,
pkgs/fleet/crates/fleet-proxy/src/http.rs:143-155:
read_line_bounded wraps the reader in
.take((MAX_LINE_BYTES + 1) as u64).read_until(...), so allocation is
bounded during the read and an over-long line errors as soon as it
crosses the cap.
Reproduction (verified this run)
Probe ~/git/fleet/probe-udp-head on the laptop (real fleet-proxy
crate via path dependency; connect_udp.rs identical at laptop HEAD
and origin/main 540d3bea3f), driving the public
connect_udp::serve on a loopback listener:
- Valid control: a well-formed CONNECT-UDP request to a loopback
UDP echo target →HTTP/1.1 200 OKwithCapsule-Protocol: ?1. - Many-lines control: valid request line + 100 header lines of
~1 KiB each → refusedHTTP/1.1 400("request head exceeds the byte
bound") as soon as the cumulative total crossed 64 KiB. The bound
works across lines. - Single-line case: request line prefix followed by 33,554,432
bytes (32 MiB) with no newline, streamed continuously in 64 KiB
chunks: the server accepted every byte, sent no response at any
point during the stream, and the process RSS grew
2,820 KiB → 35,716 KiB (Δ 32,896 KiB ≈ the full 32 MiB line) —
512× the declared bound, held in the parser. Only after the probe
finally sent\r\ndid the server refuse with400
("request head exceeds the byte bound").
PROBE PASS (all controls + the single-line case as described).
Why it matters
MAX_HEAD_BYTES exists specifically so an untrusted peer cannot make
the parser accumulate without limit. As written, the limit is a
post-hoc check, not a bound: any peer that can reach the CONNECT-UDP
listener (opt-in via [proxy] connect_udp = true, default loopback —
but the listener address is config-controlled) can grow one session
thread's memory without limit with a single unterminated line, and
the 250 ms poll timeout does not save it because the flood keeps the
socket busy. Every session thread that is fed such a line is also
pinned in read_request for the duration. This is the #5579
peer-trust class, in the one reader that checks after the fact
instead of during the read.
Distinct from #6152 (no cap on the number of per-source CONNECT-UDP
sessions in udp-forward) and from the http.rs repetition-count issue
filed earlier today (that reader caps each line during the read but
not how many lines/heads may repeat; this reader caps the total
across lines but not a single line during the read).
Acceptance criteria
-
read_head_line(or its replacement) bounds accumulation
during the read — e.g.take(remaining + 1)in the style of
http.rsread_line_bounded, or an equivalent chunked read —
so a line is refused as soon as the cumulative head total would
exceedMAX_HEAD_BYTES, without waiting for\n/EOF. - A single line longer than the remaining head budget is refused
with the same 400 shape as the many-lines case. - Loopback tests in
connect_udp.rspin: (a) one newline-free
line streamed pastMAX_HEAD_BYTESis refused promptly (not
only after a newline is sent), (b) the many-lines overflow
still refuses, (c) a valid request still returns 200. - Existing
fleet-proxytests keep passing
(cargo test -p fleet-proxyon the laptop or CI).
Scope notes
- Fix is local to
connect_udp.rs's head reader; the
UdpProxyClientresponse-head reads reuseread_head_lineand get
the same fix for free. - Out of scope: session-count caps (fleet-proxy(udp-forward): no cap on per-source CONNECT-UDP sessions — 150 one-datagram sources open 150 sessions (threads on the shim, threads+sockets on the endpoint), retained 120s and unreaped under load #6152), capsule/datagram parsing
after the head,udp_forward.rs.
Activity
developing-today commentedon Nov 6, 2026
CODE-1970
dezren39 commentedon Nov 6, 2026
🤖 Agent note · filed
dezren39 commentedon Nov 6, 2026
🤖 Agent note · picked up
9 remaining items
dezren39 commentedon Nov 6, 2026
🤖 Agent note · parked
Merge pull request #6522 from developing-today/queue/11162-6511-conne…
dezren39 commentedon Nov 6, 2026
🤖 Agent note · done