Skip to content

fleet-proxy(connect-udp): request-head bound is checked only after a whole line arrives — one newline-free line accumulates without limit #6511

Closed

Description

@dezren39

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 += n and the
    *total > MAX_HEAD_BYTES check happen only after read_until
    returns — i.e. after the whole line (however large) is already
    allocated in line.
  • :284 — read_request reads the request line and every header line
    through read_head_line, so both the request line and any single
    header line are exploitable.
  • :355 — serve_session sets 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 in read_request accumulating.

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 OK with Capsule-Protocol: ?1.
  • Many-lines control: valid request line + 100 header lines of
    ~1 KiB each → refused HTTP/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\n did the server refuse with 400
    ("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.rs read_line_bounded, or an equivalent chunked read —
    so a line is refused as soon as the cumulative head total would
    exceed MAX_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.rs pin: (a) one newline-free
    line streamed past MAX_HEAD_BYTES is 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-proxy tests keep passing
    (cargo test -p fleet-proxy on the laptop or CI).

Scope notes

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 6, 2026
  2. developing-today commented on Nov 6, 2026

    @developing-today
    Contributor
  3. added
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    app/fleetpkgs/fleet multicall binary + platform crates
    on Nov 6, 2026
  4. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  5. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  6. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. 9 remaining items

  8. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  9. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/needs-reviewQueue task finished and is awaiting review
    on Nov 6, 2026
  10. added a commit that references this issue on Nov 6, 2026
  11. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  12. removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/queuedHas a queued task in the Supabase agent queue
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app/fleetpkgs/fleet multicall binary + platform crateskind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions