From 00f0797f15ff45625b44c6cfddba9ada6af52278 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 23 Aug 2026 08:23:55 +0200 Subject: [PATCH] feat: TE rejection + listen backlog 1024 + the 1k soak gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse.wo: ANY Transfer-Encoding header is 400-and-close (RFC 9112 §6.1) — silently treating chunked as body-less was the smuggling door the dup-CL fix left open - net.listen/listen_unix backlog 64 -> 1024: the soak's connect bursts overflowed the kernel accept queue and BLACK-HOLED clients (three-way handshake done, server never sees the conn — 35-70 stuck per run, fully reproduced then gone at 1024; kernel clamps via somaxconn) - web-app gate grows to 46 checks: TE-reject; the 1k soak — 500 real conns all served + 500 idle conns all evicted, server fds home (45 -> 45), RSS 24MB, healthy after - battery green (site restart + fibers-TSan legs flaked under parallel battery load, both clean serially — the standing flake pair) Co-Authored-By: Claude Fable 5 --- .../writeonce-framework/internal/parse.wo | 7 ++ runtime/src/sysio.c | 10 ++- scripts/web-app-accept.sh | 74 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/docs/examples/writeonce-framework/internal/parse.wo b/docs/examples/writeonce-framework/internal/parse.wo index 68be0c3..6fdbf8a 100644 --- a/docs/examples/writeonce-framework/internal/parse.wo +++ b/docs/examples/writeonce-framework/internal/parse.wo @@ -150,6 +150,13 @@ pub fn parse_request(c: net.Conn, carry: Text, first_ms: Int, read_ms: Int) -> P } } + -- Transfer-Encoding is NOT implemented — and silently treating a + -- chunked request as body-less is request smuggling's other favorite + -- door (RFC 9112 §6.1: a server that cannot handle TE on a request + -- MUST respond 400 and close). Reject ANY TE header outright. + let te = headers["transfer-encoding"]; + if te != nil { return malformed(""); } + let want = 0; let cl = headers["content-length"]; if cl != nil { diff --git a/runtime/src/sysio.c b/runtime/src/sysio.c index 1a60501..5379963 100644 --- a/runtime/src/sysio.c +++ b/runtime/src/sysio.c @@ -354,7 +354,11 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { return 0; } /* ---- net --------------------------------------------------------- */ - case WO_B_NET_LISTEN: { /* IPv4, SO_REUSEADDR, backlog 64 */ + case WO_B_NET_LISTEN: { /* IPv4, SO_REUSEADDR. Backlog 1024 (iteration + * 35's soak): 64 black-holed connect bursts — + * the kernel drops the overflow's handshake and + * the CLIENT hangs believing it connected. The + * kernel clamps to somaxconn either way. */ if (cstr_of(R[B], path, sizeof path, msg)) return WO_T_BOUNDS; int64_t port = (int64_t)R[B + 1]; if (port < 0 || port > 65535) { @@ -375,7 +379,7 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { addr.sin_addr.s_addr = !strcmp(path, "0.0.0.0") ? (in_addr_t)INADDR_ANY : inet_addr(path); fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); /* arc T4 */ - if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 64) != 0) { + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 1024) != 0) { *msg = strerror(errno); close(fd); return WO_T_IO; @@ -666,7 +670,7 @@ int wo_builtin_sys(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { memset(&ua, 0, sizeof ua); ua.sun_family = AF_UNIX; strncpy(ua.sun_path, path, sizeof(ua.sun_path) - 1); - if (bind(fd, (struct sockaddr *)&ua, sizeof ua) != 0 || listen(fd, 64) != 0) { + if (bind(fd, (struct sockaddr *)&ua, sizeof ua) != 0 || listen(fd, 1024) != 0) { *msg = strerror(errno); close(fd); return WO_T_IO; diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index 28ca4e1..517f9e3 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -23,6 +23,7 @@ if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then exit 1 fi +ulimit -n 8192 2>/dev/null || true # the 1k soak needs headroom W="$(mktemp -d "${TMPDIR:-/tmp}/web-app-accept.XXXXXX")" SRV="" cleanup() { @@ -364,6 +365,79 @@ PYEOF )" [[ "$r" == "400" ]] && ok "duplicate Content-Length rejected (400)" || bad "dup-cl" "got $r" +# ---- 12d. Transfer-Encoding is rejected outright (RFC 9112 §6.1) ---- +r="$(timeout 5 python3 - "$PORT" <<'PYEOF' +import socket, sys +s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=5) +s.sendall(b"POST /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\ntransfer-encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n") +d = b"" +try: + while True: + c = s.recv(2000) + if not c: break + d += c +except Exception: pass +print(d.decode(errors="replace").splitlines()[0].split(" ")[1] if d else "closed") +PYEOF +)" +[[ "$r" == "400" ]] && ok "Transfer-Encoding rejected (400, anti-smuggling)" || bad "te-reject" "got $r" + +# ---- 12e. the 1k soak: 500 idle + 500 real, fds and RSS come home ---- +fds_before="$(ls /proc/$SRV/fd 2>/dev/null | wc -l)" +r="$(timeout 60 python3 - "$PORT" <<'PYEOF' +import asyncio, sys, time +port = int(sys.argv[1]) +REQ = b"GET /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n" +sem = asyncio.Semaphore(100) # connect in waves: the listener backlog is 64 +async def idle_conn(): + async with sem: + r, w = await asyncio.open_connection("127.0.0.1", port) + try: + # the server evicts at idle_ms after ITS accept, which under the + # 1k wave can lag the client's connect by seconds — wait long; a + # reset counts as evicted too (closed is closed) + d = await asyncio.wait_for(r.read(64), timeout=30) + return 1 if d == b"" else 0 + except asyncio.TimeoutError: + return 0 + except (ConnectionResetError, BrokenPipeError): + return 1 + finally: + w.close() +async def real_conn(): + async with sem: + r, w = await asyncio.open_connection("127.0.0.1", port) + try: + w.write(REQ); await w.drain() + d = await asyncio.wait_for(r.read(-1), timeout=15) + return 1 if b" 200 " in d.split(b"\r\n")[0] + b" " else 0 + finally: + w.close() +async def main(): + t0 = time.time() + tasks = [idle_conn() for _ in range(500)] + [real_conn() for _ in range(500)] + res = await asyncio.gather(*tasks, return_exceptions=True) + evicted = sum(1 for x in res[:500] if x == 1) + served = sum(1 for x in res[500:] if x == 1) + print(f"{evicted}|{served}|{int(time.time()-t0)}") +asyncio.run(main()) +PYEOF +)" +ev="${r%%|*}"; rest="${r#*|}"; sv="${rest%%|*}"; el="${rest#*|}" +[[ "$ev" -ge 495 && "$sv" -ge 495 ]] \ + && ok "1k soak: $sv/500 served + $ev/500 idle evicted in ${el}s" \ + || bad "soak" "$r" +sleep 1 +fds_after="$(ls /proc/$SRV/fd 2>/dev/null | wc -l)" +rss_kb="$(awk '/VmRSS/{print $2}' /proc/$SRV/status 2>/dev/null)" +[[ "$fds_after" -le $((fds_before + 8)) ]] \ + && ok "soak fds came home ($fds_before -> $fds_after)" \ + || bad "soak-fds" "$fds_before -> $fds_after" +[[ -n "$rss_kb" && "$rss_kb" -lt 409600 ]] \ + && ok "soak RSS bounded (${rss_kb}KB < 400MB)" \ + || bad "soak-rss" "${rss_kb}KB" +expect "server healthy after the soak" "$(hit GET /products)" 200 '"name":"mug"' + # ---- 13. pipelined keep-alive: two requests, one connection ---- n="$(timeout 5 python3 - "$PORT" <<'PYEOF' import socket, sys