Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3618,11 +3618,18 @@
buffer.list.as_slice()
};

// Persist the unparsed tail for the next `on_data` and re-arm the
// receive timeout. When `needs_move`, `to_read` is a suffix of
// `incoming_data` and is copied into the (currently empty) accumulation
// buffer; otherwise `to_read` is a suffix of `buffer`, so the consumed
// prefix is drained and `buffer` is moved back into state.
// Persist the unparsed tail for the next `on_data`. When `needs_move`,
// `to_read` is a suffix of `incoming_data` and is copied into the
// (currently empty) accumulation buffer; otherwise `to_read` is a
// suffix of `buffer`, so the consumed prefix is drained and `buffer`
// is moved back into state.
//
// Deliberately does NOT re-arm the socket timer: the timer armed at
// request-write time stays monotonic for the whole response-header
// phase, so it acts as an absolute headers deadline (undici's
// `headersTimeout`). Re-arming here let a server that drips one
// header line per <idle-timeout> pin the request forever. The timer
// is re-armed below once headers are complete, for the body phase.

Check failure on line 3632 in src/http/lib.rs

View check run for this annotation

Claude / Claude Code Review

Header-drip fix does not cover proxy-tunneled requests

The proxy-tunnel path still re-arms the idle timer on every dripped header byte: `on_data` at src/http/lib.rs:3833 calls `self.set_timeout(&socket)` unconditionally before dispatching to `receive()` → `handle_on_data_headers`, so the removal of `set_timeout` from `short_read!()` is moot when the origin is behind an HTTPS CONNECT proxy. The non-tunnel arm (3847-3849) already skips the re-arm for `Pending|Headers`; gating the re-arm at 3833 on `self.state.response_stage != HTTPStage::ProxyHeaders`
Comment on lines +3627 to +3632

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The proxy-tunnel path still re-arms the idle timer on every dripped header byte: on_data at src/http/lib.rs:3833 calls self.set_timeout(&socket) unconditionally before dispatching to receive()handle_on_data_headers, so the removal of set_timeout from short_read!() is moot when the origin is behind an HTTPS CONNECT proxy. The non-tunnel arm (3847-3849) already skips the re-arm for Pending|Headers; gating the re-arm at 3833 on self.state.response_stage != HTTPStage::ProxyHeaders (mirroring that structure) closes the sibling site — otherwise the same drip attack still pins the request forever via a proxy, and the new comment claiming the timer "stays monotonic for the whole response-header phase" is false on that path.

Extended reasoning...

What the bug is

The PR converts the response-header phase from an idle timer into an absolute deadline by removing self.set_timeout(&socket) from the short_read!() macro in handle_on_data_headers, so partial header reads no longer re-arm the socket timer. The new comment (src/http/lib.rs:3627-3632) documents the invariant: "the timer armed at request-write time stays monotonic for the whole response-header phase".

However, handle_on_data_headers has two entry points, and the fix only covers one. For a request routed through an HTTPS CONNECT proxy, encrypted TLS records from the origin arrive at HTTPClient::on_data (lib.rs:3819), which at lines 3831-3835 does:

if self.proxy_tunnel.is_some() {
    // if we have a tunnel we dont care about the other stages, we will just tunnel the data
    self.set_timeout(&socket);   // <-- unconditionally re-arms on EVERY chunk
    self.proxy_tunnel_mut().unwrap().receive(incoming_data);
    return;
}

receive() decrypts and, when response_stage == ProxyHeaders, dispatches to handle_on_data_headers (src/http/ProxyTunnel.rs:340-352). So the outer on_data has already re-armed the idle timer before short_read!() ever runs — the removal of set_timeout from the macro has no effect on this path.

Why the non-tunnel path is different

Contrast the direct (non-tunnel) branch of the same function at lines 3847-3893: the match self.state.response_stage sends Pending | Headers straight to handle_on_data_headers without re-arming (line 3849); only the Body (3853) and BodyChunk (3871) arms call set_timeout. The tunnel branch at 3833 re-arms unconditionally regardless of response stage, so the two entry points are now asymmetric.

Step-by-step proof

Consider an origin behind an HTTPS CONNECT proxy, with BUN_CONFIG_HTTP_IDLE_TIMEOUT=5, that drips one header line per second (the exact scenario in the new test, but through a proxy):

  1. Request is written; idle timer armed for 5s.
  2. Origin sends HTTP/1.1 200 OK\r\n inside a TLS record → arrives at on_data (lib.rs:3819).
  3. proxy_tunnel.is_some() → line 3833 calls self.set_timeout(&socket)timer re-armed to 5s.
  4. receive() decrypts → response_stage == ProxyHeadershandle_on_data_headers (ProxyTunnel.rs:345/349).
  5. picohttpparser returns Status::Partialshort_read!() persists the tail and returns (no longer re-arms — but step 3 already did).
  6. 1s later, origin sends X-Drip-0: v\r\n → back to step 2. Timer re-armed again.
  7. Since 1s < 5s, the sweep never fires. Loop forever.

The direct-connection variant of this exact sequence is what the PR fixes and tests; the tunneled variant is untouched.

Impact

An origin (or misbehaving upstream) reachable via an HTTPS proxy can drip one header line per <idle-timeout> and pin the request, its socket, and a request-cap slot indefinitely — precisely the resource-pinning hole described in the PR body, on the sibling variant. The added comment now misdocuments the invariant for tunneled requests. Per REVIEW.md — "Fix the whole class in the same PR — grep for every sibling site sharing the pattern… SSL/non-SSL variants… If a site is intentionally excluded, say so in the PR" — this is required scope, and the PR description does not mention excluding the proxy path.

Suggested fix

Gate the re-arm at line 3833 on the response stage, mirroring the non-tunnel match:

if self.proxy_tunnel.is_some() {
    if self.state.response_stage != HTTPStage::ProxyHeaders {
        self.set_timeout(&socket);
    }
    self.proxy_tunnel_mut().unwrap().receive(incoming_data);
    return;
}

(ProxyHandshake could be excluded too for symmetry, though the handshake completes before header parsing so it's less material.) The new set_timeout after handle_response_metadata (line 3737) already covers the header→body boundary for both paths, so body-phase re-arm is preserved.

macro_rules! short_read {
() => {{
bun_core::scoped_log!(fetch, "handleShortRead");
Expand All @@ -3638,7 +3645,6 @@
.drain_front(buffer.list.len().saturating_sub(keep));
self.state.response_message_buffer = buffer;
}
self.set_timeout(&socket);
return;
}};
}
Expand Down Expand Up @@ -3727,6 +3733,12 @@
}
};

// Headers are complete: re-arm the idle timer for the body phase.
// `short_read!()` above deliberately leaves the timer untouched so the
// header phase has an absolute deadline; this is the boundary where
// the body-idle semantics begin.
self.set_timeout(&socket);

if (self.state.content_encoding_i as usize) < response.headers.list.len()
&& !self.state.flags.did_set_content_encoding
{
Expand Down
92 changes: 92 additions & 0 deletions test/js/web/fetch/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3068,3 +3068,95 @@ it("an explicit numeric `timeout` extends the socket idle deadline past the defa
expect(out.withDefault).toStartWith("ERR:");
expect(exitCode).toBe(0);
}, 60_000);

// The response-header phase is an absolute deadline, not an idle timer: a
// server that drips one header line per second must still time out. Before the
// fix, every partial-header read re-armed the idle timer, so a drip faster
// than the idle window pinned the request (and its socket/request-cap slot)
// forever; undici rejects the same drip with HeadersTimeoutError.
it("a dripping response header does not reset the idle timer", async () => {
// The child runs with BUN_CONFIG_HTTP_IDLE_TIMEOUT=5 (2 ticks of uSockets'
// 4s sweep; 1-4s map to a single tick and would fire on the next sweep
// regardless of re-arming, which would mask the bug) and talks to a raw TCP
// server.
// /drip: writes the status line then one header line every second, never
// finishing the header block. Each drip is well under the 5s idle
// window, so an idle timer would never fire. The header-phase
// deadline must fire anyway (within ~8s).
// /body: writes complete headers immediately, then one body byte every
// second for 12s. Body bytes must still re-arm the idle timer, so
// this resolves with the full payload even though it runs longer
// than the header deadline.
const script = /* js */ `
const net = require("node:net");
const DRIP_MS = 1000;
const BODY_CHUNKS = 12;
const WATCHDOG_MS = 20_000;
const srv = net.createServer(s => {
s.on("error", () => {});
s.once("data", d => {
if (d.includes("GET /drip")) {
s.write("HTTP/1.1 200 OK\\r\\n");
let n = 0;
const iv = setInterval(
() => (s.destroyed ? clearInterval(iv) : s.write("X-Drip-" + n++ + ": v\\r\\n")),
DRIP_MS,
);
} else {
s.write("HTTP/1.1 200 OK\\r\\nConnection: close\\r\\n\\r\\n");
let n = 0;
const iv = setInterval(() => {
if (s.destroyed) return clearInterval(iv);
s.write("x");
if (++n === BODY_CHUNKS) { clearInterval(iv); s.end(); }
}, DRIP_MS);
}
});
});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const base = "http://127.0.0.1:" + srv.address().port;
const t0 = Date.now();
const pending = tag => new Promise(r => setTimeout(r, WATCHDOG_MS, { [tag]: "pending" }));
const [drip, body] = await Promise.all([
Promise.race([
fetch(base + "/drip").then(
() => ({ drip: "resolved" }),
e => ({ drip: String(e?.name ?? e?.code ?? e), ms: Date.now() - t0 }),
),
pending("drip"),
]),
Promise.race([
fetch(base + "/body").then(r => r.text()).then(
t => ({ body: "ok", len: t.length }),
e => ({ body: "ERR:" + (e?.name ?? e?.code ?? e) }),
),
pending("body"),
]),
]);
console.log(JSON.stringify({ ...drip, ...body }));
process.exit(0);
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, BUN_CONFIG_HTTP_IDLE_TIMEOUT: "5" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const out = JSON.parse(stdout.trim().split("\n").pop()!) as {
drip: string;
ms?: number;
body: string;
len?: number;
};
// /body dripped for ~12s at 1s/byte with a 5s idle window and still
// resolved: body bytes still re-arm the idle timer.
expect({ body: out.body, len: out.len }).toEqual({ body: "ok", len: 12 });
// /drip must have rejected with a timeout inside the watchdog window.
// Before the fix this stayed "pending": every dripped header line re-armed
// the 5s idle timer, and 1s < 5s keeps it ahead of the sweep forever.
expect(out.drip).toMatch(/Timeout/i);
expect(out.ms).toBeLessThan(20_000);
expect(exitCode).toBe(0);
}, 60_000);
Loading