feat: TE rejection + listen backlog 1024 + the 1k soak gate

- 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 <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-23 08:23:55 +02:00
parent bafd532afa
commit 00f0797f15
3 changed files with 88 additions and 3 deletions

View file

@ -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 {

View file

@ -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;

View file

@ -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