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:
parent
a32550d968
commit
9661c08696
3 changed files with 88 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue