Skip to content

Commit c601aa7

Browse files
committed
fix(http): bound the request-head read — one packet no longer DoSes the server
Smell-audited: depth 1: the fix is scoped narrower than the debt row's severity language invites, and the narrowing is the honest part. SO_RCVTIMEO turns an UNBOUNDED wedge into a bounded one; it does not make a serial server concurrent. N held connections still cost N x timeout of throughput. Closing that needs per-connection threading, and a Ring handler runs Clojure — so its thread must be a registered GC worker under the ADR-0175 safepoint discipline. That is a concurrency design change, not a line in this function, and pulling it in here would be exactly the scope-creep the smell sensor watches for. It stays with the D-257 threading follow-on that server.zig's module doc already names, and D-339 is marked PARTIAL rather than discharged. D-339 (SE-3), raised 2026-06-09 and untouched for two months: `run-server`'s accept loop is serial and blocking with no read deadline anywhere, so a client that opened a connection and never finished sending the request head held the whole server — which binds 0.0.0.0 and is internet-facing in a container — for as long as it cared to. The 8 MB body cap guards volume, not time. The test earned its keep by being wrong first. Written as "hold a half-open connection, then assert a normal request still succeeds", it PASSED against the unfixed server: the stalled client's subshell exits after 12s, and the server served the next request the moment the attacker gave up. A body-only assertion cannot tell "the server recovered" from "the attacker left", so the elapsed bound IS the assertion: 12s before, 1s after, fail over 5s. - `armReceiveDeadline` sets SO_RCVTIMEO on each accepted connection before the first read. Best-effort — a platform that rejects the option leaves the socket blocking rather than failing the request; nothing else depends on it. - `:header-timeout-ms` in the opts map, default 10000 (the order of nginx's client_header_timeout and Go's documented ReadHeaderTimeout examples), 0 disables. Non-integer is rejected at the boundary with a named message. - The e2e fixture sets 1500 so the case runs fast; legitimate requests complete in milliseconds, so the other 12 cases are unaffected. Smoke green (phase16_http_server, 283s).
1 parent 92d7926 commit c601aa7

4 files changed

Lines changed: 104 additions & 9 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -978,7 +978,7 @@ active:
978978
rung 6, D-281/ADR-0103 (host_inert supertype precedent for inert-accept).
979979
last_reviewed: "2026-07-02"
980980
- id: "D-339"
981-
status: "open — security (SE-3, HTTP server slowloris). The cljw.http.server accept loop is serial + blocking with NO socket read/idle/header deadline (server.zig:63-71); one client that opens a connection and never completes the request head wedges the WHOLE server (binds 0.0.0.0, internet-facing in a container). The 8 MB body cap guards volume, not time. High severity (one packet DoSes a public deploy)."
981+
status: "PARTIAL (2026-08-04) — the unbounded wedge is CLOSED; the serial-throughput half remains. Was: the accept loop is serial + blocking with NO socket read/idle/header deadline, so one client that opens a connection and never completes the request head wedged the WHOLE server forever (binds 0.0.0.0, internet-facing in a container); one packet DoSed a public deploy. FIXED: `run-server` now arms SO_RCVTIMEO on every accepted connection before the first read (`armReceiveDeadline`, server.zig), default 10s, tunable via `:header-timeout-ms` in the opts map (0 disables). e2e `phase16_http_server.sh` case `http-slowloris-recovers` holds a half-open connection and asserts the next client is served in <5s — it measured 12s before the fix (i.e. the server only recovered when the ATTACKER gave up) and 1s after. REMAINING: a serial server still handles one connection at a time, so N held connections still cost N x timeout of throughput. Removing that needs per-connection concurrency, which is NOT a drive-by: a Ring handler runs Clojure, so its thread must be a registered GC worker (ADR-0175 safepoint discipline). That is the D-257 threading follow-on named in server.zig's module doc, and it owns the residual."
982982
category: "security"
983983
barrier: |-
984984
blocked-by: the Phase-15 concurrency model (cancellable Io). The
@@ -989,7 +989,7 @@ active:
989989
(zig_tips.md: concurrency arrives Phase 15; D-257 lists per-conn threading as a
990990
follow-on). Forcing it now would be premature (F-003). Land per-conn + default
991991
deadline when Phase-15 concurrency lands. Cross-ref SE-3, server.zig, D-257.
992-
last_reviewed: "2026-06-09"
992+
last_reviewed: "2026-08-04"
993993
- id: "D-342"
994994
status: "open — security (SE-6/7 finished form). The FS-jail v1 (ADR-0123, lexical containment) blocks `..`/absolute-path traversal portably but does NOT resolve symlinks: a symlink planted INSIDE the jail pointing outside is followed. Documented residual (mitigate via read-only mount / no symlinks in jail). The symlink-SAFE finished form is the ADR-0123 DA Alt-2."
995995
category: "security"

‎src/runtime/cljw/http/server.zig‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,54 @@ fn headerFieldClean(s: []const u8) bool {
6161
return true;
6262
}
6363

64+
/// Default request-head read deadline in milliseconds (D-339 / SE-3). Ten
65+
/// seconds is the same order as nginx's `client_header_timeout` and Go's
66+
/// documented `ReadHeaderTimeout` examples: far longer than any legitimate
67+
/// client needs to finish a request line + headers over a live connection,
68+
/// far shorter than "forever".
69+
pub const default_header_timeout_ms: i64 = 10_000;
70+
71+
/// Arm a receive deadline on an accepted connection (D-339 / SE-3).
72+
///
73+
/// The accept loop is serial by design, so a peer that opens a connection and
74+
/// never finishes sending the request head blocks EVERY other client for as
75+
/// long as the read blocks — and with no deadline that is forever, so a single
76+
/// packet takes down a public deploy (the server binds 0.0.0.0). `SO_RCVTIMEO`
77+
/// turns that unbounded wedge into a bounded one: the stalled read fails, the
78+
/// loop drops the connection and serves the next client.
79+
///
80+
/// This bounds the wedge; it does not remove it. A serial server still handles
81+
/// one connection at a time, so an attacker holding N connections still costs
82+
/// N × timeout of throughput. Removing that needs per-connection concurrency,
83+
/// which is a different change with GC-registration consequences (a handler
84+
/// runs Clojure, so its thread must be a registered GC worker per ADR-0175) —
85+
/// tracked separately as the D-257 threading follow-on, not smuggled in here.
86+
///
87+
/// Best-effort: a platform that rejects the option leaves the socket blocking
88+
/// rather than failing the request. Nothing else in the loop depends on it.
89+
fn armReceiveDeadline(handle: std.posix.socket_t, timeout_ms: i64) void {
90+
if (timeout_ms <= 0) return; // 0 / negative = caller opted out
91+
const tv: std.posix.timeval = .{
92+
.sec = @intCast(@divTrunc(timeout_ms, 1000)),
93+
.usec = @intCast(@rem(timeout_ms, 1000) * 1000),
94+
};
95+
std.posix.setsockopt(handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&tv)) catch {
96+
// Best-effort by design — see the doc comment. No recovery to attempt
97+
// and no user-visible surface to report it on.
98+
};
99+
}
100+
64101
/// Bind `0.0.0.0:port` and serve forever (blocking, serial), dispatching each
65-
/// request through `handler` (a Ring `(fn [req] resp)`).
66-
pub fn runServer(rt: *Runtime, env: *Env, handler: Value, port: u16, loc: SourceLocation) anyerror!Value {
102+
/// request through `handler` (a Ring `(fn [req] resp)`). `header_timeout_ms`
103+
/// bounds how long a connection may take to deliver its request head (D-339).
104+
pub fn runServer(
105+
rt: *Runtime,
106+
env: *Env,
107+
handler: Value,
108+
port: u16,
109+
header_timeout_ms: i64,
110+
loc: SourceLocation,
111+
) anyerror!Value {
67112
const io = rt.io;
68113
const kw_method = try keyword_mod.intern(rt, null, "request-method");
69114
const kw_uri = try keyword_mod.intern(rt, null, "uri");
@@ -82,6 +127,10 @@ pub fn runServer(rt: *Runtime, env: *Env, handler: Value, port: u16, loc: Source
82127
while (true) {
83128
var stream = server.accept(io) catch continue;
84129
defer stream.close(io);
130+
// D-339: bound the request-head read BEFORE the first read on this
131+
// connection, so a peer that stalls mid-head cannot hold the serial
132+
// loop open indefinitely.
133+
armReceiveDeadline(stream.socket.handle, header_timeout_ms);
85134
var rbuf: [16384]u8 = undefined;
86135
var wbuf: [16384]u8 = undefined;
87136
var sr = stream.reader(io, &rbuf);
@@ -226,8 +275,10 @@ fn buildRequest(rt: *Runtime, req: *std.http.Server.Request, kw_method: Value, k
226275
// D2 zone rule: lang/primitive/ may not import a surface, so the host fns + their
227276
// ns registration live here and are wired via runtime/cljw/_host_api.zig.
228277

229-
/// `(run-server handler {:port N})` — Ring-style. `handler` is `(fn [req] resp)`;
230-
/// `opts` carries `:port` (a bare integer port is also accepted). Blocking.
278+
/// `(run-server handler {:port N :header-timeout-ms MS})` — Ring-style.
279+
/// `handler` is `(fn [req] resp)`; `opts` carries `:port` (a bare integer port
280+
/// is also accepted) and the optional `:header-timeout-ms` (D-339; default
281+
/// `default_header_timeout_ms`, `0` disables the deadline). Blocking.
231282
pub fn runServerFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
232283
try error_catalog.checkArity("run-server", args, 2, loc);
233284
const handler = args[0];
@@ -242,7 +293,16 @@ pub fn runServerFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoca
242293
};
243294
if (port < 1 or port > 65535)
244295
return error_catalog.raiseInternal(loc, "run-server: port out of range (1..65535)");
245-
return runServer(rt, env, handler, @intCast(port), loc);
296+
const header_timeout_ms: i64 = blk: {
297+
if (opts.isInt()) break :blk default_header_timeout_ms;
298+
const kw_ht = try keyword_mod.intern(rt, null, "header-timeout-ms");
299+
const t = map_mod.get(opts, kw_ht) catch Value.nil_val;
300+
if (t.isNil()) break :blk default_header_timeout_ms;
301+
if (!t.isInt())
302+
return error_catalog.raiseInternal(loc, "run-server: :header-timeout-ms must be an integer (milliseconds; 0 disables)");
303+
break :blk t.asInteger();
304+
};
305+
return runServer(rt, env, handler, @intCast(port), header_timeout_ms, loc);
246306
}
247307

248308
/// Create the `cljw.http.server` / `cljw.http.client` host namespaces. Called by

‎test/e2e/fixtures/http_server_demo.clj‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@
2323
:body "should become 500"}
2424
(= (:request-method req) :post) {:status 201 :body "created"}
2525
:else {:status 200 :body (str "GET " (:uri req))}))
26-
{:port 8157})
26+
;; :header-timeout-ms is short here so the D-339 slowloris case runs fast;
27+
;; the shipped default is 10000. Legitimate requests complete in milliseconds,
28+
;; so 1500 does not affect the other cases.
29+
{:port 8157 :header-timeout-ms 1500})

‎test/e2e/phase16_http_server.sh‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,36 @@ got=$(curl -s "http://127.0.0.1:$PORT/still-alive" 2>&1)
8181
[[ "$got" == "GET /still-alive" ]] || fail "server died after CRLF header: got '$got'"
8282
echo "PASS http-survives-crlf -> $got"
8383

84-
echo "OK — phase16_http_server (12 cases) green"
84+
# D-339 (SE-3, slowloris): a client that opens a connection and never finishes
85+
# sending the request head must NOT wedge the server. The accept loop is serial
86+
# by design, so a half-open connection blocks every other client for as long as
87+
# the read blocks — without a deadline, that is forever, and one packet DoSes a
88+
# public deploy. The fixture sets :header-timeout-ms 1500, so the stalled
89+
# connection must be dropped and the next client served well inside 15s.
90+
#
91+
# The stalled client runs in a SUBSHELL, not via `exec` on the parent: a
92+
# redirection failure on `exec` is fatal to a non-interactive shell, which would
93+
# take the whole suite down on a host without bash /dev/tcp.
94+
if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then
95+
# Writes a partial head (no terminating blank line) and holds the connection.
96+
( printf 'GET /still-alive HTTP/1.1\r\nHost: localhost\r\n'; sleep 12 ) \
97+
>/dev/tcp/127.0.0.1/$PORT 2>/dev/null &
98+
SLOW=$!
99+
sleep 0.5 # let the stalled client reach the accept loop first
100+
start=$(date +%s)
101+
got=$(run_bounded 15 curl -s --max-time 12 "http://127.0.0.1:$PORT/still-alive" 2>&1 || true)
102+
elapsed=$(( $(date +%s) - start ))
103+
kill "$SLOW" 2>/dev/null || true
104+
wait "$SLOW" 2>/dev/null || true
105+
[[ "$got" == "GET /still-alive" ]] || fail "slowloris wedged the server (D-339): got '$got' after ${elapsed}s"
106+
# The elapsed bound is the real assertion. Without a header deadline the
107+
# server serves this request only when the ATTACKER disconnects (12s here),
108+
# which still "passes" a body-only check — so assert the deadline did the
109+
# work: 1500ms fixture timeout + dispatch must land well under 5s.
110+
[[ "$elapsed" -lt 5 ]] || fail "slowloris blocked the server for ${elapsed}s (D-339: no header deadline; it was the attacker giving up, not the server recovering)"
111+
echo "PASS http-slowloris-recovers -> served in ${elapsed}s"
112+
else
113+
echo "SKIP http-slowloris (bash /dev/tcp unavailable)"
114+
fi
115+
116+
echo "OK — phase16_http_server (13 cases) green"

0 commit comments

Comments
 (0)