fix(spinel): review findings — request deadline, whole bodies, issued tokens, loopback-only listen

- One 10s deadline per request instead of a 5s timeout per read.
- Bodies read whole up to 256 KiB; short or chunked bodies get 400 and
  oversized ones 413 before any handler runs; SQLite column buffer sized
  to match.
- A visitors table records issued tokens; unknown cookies are replaced.
  Existing lists are backfilled by migration.
- --listen accepts loopback only, and there is no every-interface fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 57988626e6e0d5a11f600a4a9d50364960ceaefc parent 4256b95
5 files changed, +352 −27
  • apps/spinel/AGENTS.md +3 −1
  • apps/spinel/README.md +12 −4
  • apps/spinel/app.rb +25 −4
  • apps/spinel/patches/2-tep-bakeoff.patch +267 −18
  • apps/spinel/test/run.sh +45 −0
diff --git a/apps/spinel/AGENTS.md b/apps/spinel/AGENTS.md
index 95d5864..6f5ed76 100644
--- a/apps/spinel/AGENTS.md
+++ b/apps/spinel/AGENTS.md
@@ -14,7 +14,9 @@ libc, no client framework, no JS build step.
first visit sets `tid=<22 base64url characters>` (128 bits from the
kernel's `getrandom`),
`HttpOnly; SameSite=Lax; Path=/`, plus `Secure` over https. Every query
- is scoped by that value; an id from someone else's list is a 404.
+ is scoped by that value; an id from someone else's list is a 404. The
+ `visitors` table holds every token the server has issued; any other
+ cookie value is replaced with a fresh one.
- **No sign-in.** There are no accounts, passwords or e-mail addresses.
- **Trust class C.** Todo text is stored as plain text in SQLite. That's
a deliberate choice for a demo app: nothing in it is meant to be
diff --git a/apps/spinel/README.md b/apps/spinel/README.md
index b78b9b0..4a43c70 100644
--- a/apps/spinel/README.md
+++ b/apps/spinel/README.md
@@ -18,7 +18,9 @@ concatenation, small top-level functions, no metaprogramming.
**SQLite** comes from the official amalgamation, compiled by the Makefile and
linked statically. tep's own SQLite shim talks to it. Lists live in one
-`todos` table keyed by the visitor's cookie; migrations are numbered by
+`todos` table keyed by the visitor's cookie, and a `visitors` table records
+every token the server has issued, so a cookie it didn't issue gets a fresh
+one. Migrations are numbered by
`PRAGMA user_version` and only ever add.
**HTML** is built in Ruby functions in `app.rb`, escaped by hand. tep has
@@ -36,10 +38,16 @@ counterparts and disappear when the script adds the `js` class.
tep is copied into `.cache/` and patched rather than used as-is.
`patches/1-tep-carlos.patch` is lpay's: the `--socket` contract and a
`Content-Length` that counts bytes. `patches/2-tep-bakeoff.patch` adds
-`--listen 127.0.0.1:<port>` (tep's `-p` binds every interface), a 5-second
-read timeout so an idle browser connection can't hold the single worker, the
+`--listen 127.0.0.1:<port>`, which refuses anything but a loopback address,
+and removes tep's fallback to listening on every interface: with neither
+`--socket` nor `--listen` the binary exits. It gives each request a
+10-second deadline for the whole exchange, so a slow or idle client can't
+hold the single worker. It reads bodies whole up to 256 KiB (tep stopped at
+64 KiB and carried on with a truncated title), answers 413 above that, and
+answers 400 without running any handler when a body is short of its
+`Content-Length` or chunked. Smaller changes: the
`303 See Other` reason phrase, and a return-type fix in `Tep::Assets._add`
-that otherwise breaks the C compile. It also replaces tep's form decoding.
+that otherwise breaks the C compile. Last, it replaces tep's form decoding.
spinelkit's `Url.parse_query` keeps a decoded key in an unrooted temporary
while it decodes the value, so a garbage collection in between loses the
key. On arm64 that dropped any todo title over a thousand or so
diff --git a/apps/spinel/app.rb b/apps/spinel/app.rb
index 87c0607..cfe3735 100644
--- a/apps/spinel/app.rb
+++ b/apps/spinel/app.rb
@@ -62,7 +62,11 @@ DB = Tep::SQLite.new
MIGRATIONS = [
"CREATE TABLE todos (id INTEGER PRIMARY KEY, list TEXT NOT NULL, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()))",
- "CREATE INDEX todos_list ON todos (list, id)"
+ "CREATE INDEX todos_list ON todos (list, id)",
+ # Tokens this server has issued; a cookie not in here is not a list.
+ "CREATE TABLE visitors (token TEXT PRIMARY KEY, created_at INTEGER NOT NULL DEFAULT (unixepoch())) WITHOUT ROWID",
+ # Lists that existed before the table did were all issued by us.
+ "INSERT OR IGNORE INTO visitors (token) SELECT DISTINCT list FROM todos"
]
def open_db(path)
@@ -196,14 +200,31 @@ def valid_token(t)
true
end
-# The caller's list token. A missing or unrecognised cookie gets a fresh
-# token (and so a fresh, empty list), set on this response.
+def issued_token(t)
+ DB.prepare("SELECT 1 FROM visitors WHERE token = ?")
+ DB.bind_str(1, t)
+ found = DB.step == 1
+ DB.finalize
+ found
+end
+
+def record_token(t)
+ DB.prepare("INSERT INTO visitors (token) VALUES (?)")
+ DB.bind_str(1, t)
+ DB.step
+ DB.finalize
+end
+
+# The caller's list token. A missing cookie, or one this server never
+# issued (garbled, made up, or from another deployment), gets a fresh
+# random token, and so a fresh, empty list, set on this response.
def visitor(req, res)
t = req.cookies[COOKIE_NAME].to_s
- if valid_token(t)
+ if valid_token(t) && issued_token(t)
return t
end
t = Crypto.sp_crypto_random_b64url(16).to_s
+ record_token(t)
opts = Tep.str_hash
opts["Path"] = "/"
opts["Max-Age"] = "31536000"
diff --git a/apps/spinel/patches/2-tep-bakeoff.patch b/apps/spinel/patches/2-tep-bakeoff.patch
index 1dd846f..61a08a4 100644
--- a/apps/spinel/patches/2-tep-bakeoff.patch
+++ b/apps/spinel/patches/2-tep-bakeoff.patch
@@ -1,39 +1,78 @@
--- a/bin/tep
+++ b/bin/tep
-@@ -899,6 +899,7 @@
+@@ -899,6 +899,8 @@
__port = #{config[:port] || 4567}
__workers = #{config[:workers] || 1}
__sockpath = ""
+ __listen_host = ""
++ __listen_bad = false
__quiet = false
__scheduled = #{scheduled ? 'true' : 'false'}
__i = 0
-@@ -915,6 +916,16 @@
+@@ -915,6 +917,21 @@
# argv deployment platforms like CARLOS spawn instances with).
__sockpath = ARGV[__i + 1]
__i += 2
+ elsif __a == "--listen" && __i + 1 < ARGV.length
-+ # host:port -- bind TCP on exactly that address (127.0.0.1 for
-+ # local runs) instead of -p's INADDR_ANY.
++ # host:port -- bind TCP on exactly that address. Loopback only
++ # (127.0.0.0/8, ::1, localhost): anything else is refused below.
+ __hp = ARGV[__i + 1]
+ __colon = __hp.rindex(":")
-+ if __colon != nil
++ if __colon == nil
++ __listen_bad = true
++ else
+ __listen_host = __hp[0, __colon]
+ __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i
++ if !Tep.loopback_host?(__listen_host) || __port < 1 || __port > 65535
++ __listen_bad = true
++ end
+ end
+ __i += 2
elsif __a == "--db" && __i + 1 < ARGV.length
# Platform-supplied DB path; tolerated so a platform can pass it
# unconditionally. Apps that want it read it from ARGV themselves.
-@@ -936,6 +947,8 @@
+@@ -934,10 +951,19 @@
+ __i += 1
+ end
end
++ # Never fall back to tep's every-interface listener: the process
++ # serves a unix socket (--socket) or a loopback address (--listen).
++ if __listen_bad
++ puts "tep: --listen needs a loopback address and a port, e.g. 127.0.0.1:8080"
++ exit(2)
++ end
if __sockpath.length > 0
Tep.run_unix!(__sockpath, __quiet)
+ elsif __listen_host.length > 0
+ Tep.run_host!(__listen_host, __port, __quiet)
else
- Tep.run!(__port, __workers, __quiet, __scheduled)
+- Tep.run!(__port, __workers, __quiet, __scheduled)
++ puts "tep: give --socket <path> or --listen 127.0.0.1:<port>"
++ exit(2)
end
+ RB
+ out << ""
+--- a/native/sqlite/tep_sqlite.c
++++ b/native/sqlite/tep_sqlite.c
+@@ -44,7 +44,7 @@
+ #include <sqlite3.h>
+
+ #define TEP_SQLITE_MAX_HANDLES 16
+-#define TEP_SQLITE_COL_BUFSIZE 65536
++#define TEP_SQLITE_COL_BUFSIZE (256 * 1024 + 1) /* a whole request body (SPHTTP_BODY_MAX) fits */
+ #define TEP_SQLITE_COL_BUF_SLOTS 16
+ #define TEP_SQLITE_CACHE_SLOTS 64 /* prepare-statement cache */
+ #define TEP_SQLITE_CACHE_SQL_MAX 512 /* longest cached SQL */
+--- a/sig/tep/request.rbs
++++ b/sig/tep/request.rbs
+@@ -14,6 +14,7 @@
+ # an ivar slot with Response.
+ attr_accessor req_headers: Hash[String, String]
+ attr_accessor raw_body: String
++ attr_accessor body_status: Integer
+ attr_accessor cookies: Hash[String, String]
+ attr_accessor session: Tep::Session
+ attr_accessor remote_host: String
--- a/tep/assets.rb
+++ b/tep/assets.rb
@@ -25,8 +25,11 @@
@@ -50,7 +89,7 @@
def self.has?(path)
--- a/tep/net.rb
+++ b/tep/net.rb
-@@ -27,6 +27,10 @@
+@@ -27,6 +27,14 @@
# Unix-domain listener: bind+listen on a filesystem path (see
# sphttp.c). -1 on failure; accept/read/write reuse the same fd paths.
ffi_func :sphttp_listen_unix, [:str], :int
@@ -58,6 +97,10 @@
+ ffi_func :sphttp_listen_host, [:str, :int], :int
+ # Form-rules URL decoding in C (see sphttp.c and Tep.parse_form).
+ ffi_func :sphttp_url_unescape, [:str], :str
++ # Whole-request deadline + complete-body reads (see sphttp.c).
++ ffi_func :sphttp_set_deadline, [:int, :int], :int
++ ffi_func :sphttp_body_max, [], :int
++ ffi_func :sphttp_body_incomplete, [], :int
ffi_func :sphttp_accept, [:int], :int
# Non-blocking accept variant used by Tep::Server::Scheduled.
# Listen fd must be in non-blocking mode (sphttp_set_nonblock).
@@ -84,7 +127,59 @@
end
--- a/tep/request.rb
+++ b/tep/request.rb
-@@ -190,7 +190,7 @@
+@@ -4,6 +4,8 @@
+ attr_accessor :verb, :path, :raw_path, :http_version
+ attr_accessor :params, :query, :req_headers, :raw_body, :cookies, :session
+ attr_accessor :remote_host
++ # 0 when the body was read whole; else the status to refuse it with.
++ attr_accessor :body_status
+ attr_accessor :ivars
+ # Set by the auth-filter (Tep::AuthFilter, run before the user's
+ # before-filter -- see Tep::App#auth_filter). Always populated:
+@@ -29,6 +31,7 @@
+ @session = Session.new # signed cookie store
+ @raw_body = "" # same reasoning as req_headers
+ @remote_host = ""
++ @body_status = 0
+ @passed = false # `pass` flag: skip to the next matching route
+ @ivars = Tep.str_hash # per-request bag for `@name = ...`
+ # set by handlers and `before` filters,
+@@ -136,12 +139,33 @@
+ # Other content types leave @raw_body intact for handlers that
+ # want to consume it directly.
+ def consume_body(client_fd)
++ if @req_headers.key?("transfer-encoding")
++ @body_status = 400
++ return 0
++ end
+ cl = content_length
+- already = @raw_body.length
++ if cl < 0
++ @body_status = 400
++ return 0
++ end
++ if cl > Sock.sphttp_body_max
++ @body_status = 413
++ return 0
++ end
++ already = @raw_body.bytesize
+ if cl > already
+ rest = Sock.sphttp_drain_body(client_fd, cl - already)
++ if Sock.sphttp_body_incomplete != 0
++ @body_status = 400
++ return 0
++ end
+ @raw_body = @raw_body + rest
+ end
++ # Short (or carrying a NUL, which the C string boundary cuts off).
++ if @raw_body.bytesize < cl
++ @body_status = 400
++ return 0
++ end
+ parse_form_body
+ 0
+ end
+@@ -190,7 +214,7 @@
# @raw_body to Content-Length.
def parse_form_body
if form?
@@ -107,7 +202,7 @@
i += 1
--- a/tep/server.rb
+++ b/tep/server.rb
-@@ -8,6 +8,7 @@
+@@ -8,11 +8,13 @@
if status == 204; return "No Content"; end
if status == 301; return "Moved Permanently"; end
if status == 302; return "Found"; end
@@ -115,7 +210,13 @@
if status == 304; return "Not Modified"; end
if status == 400; return "Bad Request"; end
if status == 401; return "Unauthorized"; end
-@@ -121,6 +122,25 @@
+ if status == 403; return "Forbidden"; end
+ if status == 404; return "Not Found"; end
++ if status == 413; return "Content Too Large"; end
+ if status == 500; return "Internal Server Error"; end
+ "OK"
+ end
+@@ -121,6 +123,25 @@
end
end
@@ -141,19 +242,32 @@
def worker_loop(sfd)
loop do
client = Sock.sphttp_accept(sfd)
-@@ -152,6 +172,12 @@
+@@ -152,6 +173,12 @@
end
Sock.sphttp_set_recv_timeout(client, 0)
end
+ # Close-per-request mode serves one connection at a time, so a
-+ # client that connects and then sends nothing (a browser's
-+ # speculative preconnect) must not hold the worker: bound reads.
++ # client that connects and then sends nothing, or trickles bytes,
++ # must not hold the worker: the whole request gets 10 seconds.
+ if Tep::APP.close_per_request
-+ Sock.sphttp_set_recv_timeout(client, 5000)
++ Sock.sphttp_set_deadline(client, 10000)
+ end
handle_connection(client)
end
# Explicit Integer return: the `loop` falls through as nil (boxed),
+@@ -178,6 +205,12 @@
+ end
+
+ req.consume_body(client)
++ # A body that is too large, short of its Content-Length, or chunked
++ # (unsupported here) never reaches a handler, so nothing mutates.
++ if req.body_status != 0
++ send_simple(client, req.body_status, "the body was refused")
++ break
++ end
+
+ res = Response.new
+ @app.dispatch(req, res)
--- a/tep/sphttp.c
+++ b/tep/sphttp.c
@@ -85,6 +85,7 @@
@@ -164,7 +278,7 @@
extern int sp_net_accept(int sfd);
extern int sp_net_accept_nb(int sfd);
extern int sp_net_connect(const char *host, int port);
-@@ -147,7 +148,61 @@
+@@ -147,19 +148,111 @@
return fd;
}
@@ -226,9 +340,113 @@
int sphttp_accept(int sfd) { return sp_net_accept(sfd); }
int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }
+ /* Read until end-of-headers ("\r\n\r\n") or the buffer fills. Subsequent
+ * recv()s for the body are the caller's job (we expose a length helper).
+ * Returns the parsed length (>0), 0 on clean EOF, -1 on error. */
++/* Whole-request deadline (close-per-request mode). Before every blocking
++ * read or write on the connection, SO_RCVTIMEO/SO_SNDTIMEO are set to the
++ * time left, so a client trickling one byte at a time can hold the single
++ * worker for at most the deadline, not a timeout per read. */
++static struct timespec sphttp_deadline;
++static int sphttp_deadline_on = 0;
++
++int sphttp_set_deadline(int fd, int ms) {
++ (void)fd;
++ if (ms <= 0) { sphttp_deadline_on = 0; return 0; }
++ clock_gettime(CLOCK_MONOTONIC, &sphttp_deadline);
++ sphttp_deadline.tv_sec += ms / 1000;
++ sphttp_deadline.tv_nsec += (long)(ms % 1000) * 1000000L;
++ if (sphttp_deadline.tv_nsec >= 1000000000L) {
++ sphttp_deadline.tv_sec += 1;
++ sphttp_deadline.tv_nsec -= 1000000000L;
++ }
++ sphttp_deadline_on = 1;
++ return 0;
++}
++
++/* Arms the socket timeouts with the time left; -1 once it has run out. */
++static int sphttp_arm_deadline(int fd) {
++ if (!sphttp_deadline_on) return 0;
++ struct timespec now;
++ clock_gettime(CLOCK_MONOTONIC, &now);
++ long long left_ms = (long long)(sphttp_deadline.tv_sec - now.tv_sec) * 1000LL +
++ (sphttp_deadline.tv_nsec - now.tv_nsec) / 1000000L;
++ if (left_ms <= 0) { errno = ETIMEDOUT; return -1; }
++ struct timeval tv;
++ tv.tv_sec = (time_t)(left_ms / 1000);
++ tv.tv_usec = (suseconds_t)((left_ms % 1000) * 1000);
++ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
++ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
++ return 0;
++}
++
+ int sphttp_read_request(int fd) {
+ SSL *ssl = sphttp_ssl_for(fd); /* inbound TLS: decrypt via SSL_read */
+ sphttp_req_len = 0;
+ sphttp_req_buf[0] = '\0';
+ while (sphttp_req_len < SPHTTP_BUFSIZE - 1) {
+ int want = SPHTTP_BUFSIZE - 1 - sphttp_req_len;
++ if (!ssl && sphttp_arm_deadline(fd) < 0) return -1;
+ ssize_t n = ssl
+ ? (ssize_t)SSL_read(ssl, sphttp_req_buf + sphttp_req_len, want)
+ : recv(fd, sphttp_req_buf + sphttp_req_len, (size_t)want, 0);
+@@ -190,23 +283,42 @@
+ * computes remaining = content_length - already_in_buf; this gulps
+ * those into a Ruby-visible string buffer. We round-trip via a
+ * static buffer to avoid hand-rolling write_str FFI. */
+-static char sphttp_body_buf[SPHTTP_BUFSIZE];
++/* Bodies are read whole, up to SPHTTP_BODY_MAX, into a growing buffer
++ * (this used to stop at 64 KiB, silently truncating longer bodies).
++ * sphttp_body_incomplete() reports whether the last drain came up short:
++ * the peer closed or the deadline passed before total_len bytes arrived. */
++#define SPHTTP_BODY_MAX (256 * 1024)
++static char *sphttp_body_buf = NULL;
++static size_t sphttp_body_cap = 0;
++static int sphttp_body_short = 0;
++
++int sphttp_body_max(void) { return SPHTTP_BODY_MAX; }
++int sphttp_body_incomplete(void) { return sphttp_body_short; }
+
+ const char *sphttp_drain_body(int fd, int total_len) {
+ SSL *ssl = sphttp_ssl_for(fd);
+ int n = total_len;
++ sphttp_body_short = 0;
+ if (n < 0) n = 0;
+- if (n >= SPHTTP_BUFSIZE) n = SPHTTP_BUFSIZE - 1;
++ if (n > SPHTTP_BODY_MAX) { sphttp_body_short = 1; return ""; }
++ if ((size_t)n + 1 > sphttp_body_cap) {
++ char *nb = (char *)realloc(sphttp_body_buf, (size_t)n + 1);
++ if (!nb) { sphttp_body_short = 1; return ""; }
++ sphttp_body_buf = nb;
++ sphttp_body_cap = (size_t)n + 1;
++ }
+ int got = 0;
+ while (got < n) {
++ if (!ssl && sphttp_arm_deadline(fd) < 0) break;
+ ssize_t r = ssl ? (ssize_t)SSL_read(ssl, sphttp_body_buf + got, n - got)
+ : recv(fd, sphttp_body_buf + got, n - got, 0);
+ if (r <= 0) {
+- if (!ssl && errno == EINTR) continue;
++ if (!ssl && r < 0 && errno == EINTR) continue;
+ break;
+ }
+ got += (int)r;
+ }
++ if (got < n) sphttp_body_short = 1;
+ sphttp_body_buf[got] = '\0';
+ return sphttp_body_buf;
+ }
+@@ -221,6 +333,7 @@
+ if (w <= 0) return -1;
+ off += (size_t)w;
+ } else {
++ if (sphttp_arm_deadline(fd) < 0) return -1;
+ ssize_t n = send(fd, s + off, len - off, 0);
+ if (n <= 0) {
+ if (errno == EINTR) continue;
--- a/tep.rb
+++ b/tep.rb
-@@ -166,6 +166,37 @@
+@@ -166,6 +166,68 @@
# narrowing model). Removing it would require a mechanical
# `< 0` -> `.nil?` refactor across http.rb / parser.rb / url.rb /
# jwt.rb / app.rb. Worth doing eventually; not urgent.
@@ -262,11 +480,42 @@
+ end
+ h
+ end
++
++ # True for the addresses `--listen` may bind: localhost, ::1 and
++ # dotted-quad 127.0.0.0/8.
++ def self.loopback_host?(h)
++ if h == "localhost" || h == "::1"
++ return true
++ end
++ parts = h.split(".")
++ if parts.length != 4 || parts[0] != "127"
++ return false
++ end
++ i = 0
++ while i < 4
++ p = parts[i]
++ if p.length == 0 || p.length > 3
++ return false
++ end
++ j = 0
++ while j < p.length
++ if p[j] < "0" || p[j] > "9"
++ return false
++ end
++ j += 1
++ end
++ if p.to_i > 255
++ return false
++ end
++ i += 1
++ end
++ true
++ end
+
def self.str_find(s, needle, start)
nlen = needle.length
slen = s.length
-@@ -300,6 +331,11 @@
+@@ -300,6 +362,11 @@
Server.new(APP).run_unix(path, quiet)
end
diff --git a/apps/spinel/test/run.sh b/apps/spinel/test/run.sh
index cd2b931..607422d 100755
--- a/apps/spinel/test/run.sh
+++ b/apps/spinel/test/run.sh
@@ -153,6 +153,51 @@ for f in base.css index.css; do
done
contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")"
+# --- cookies must be ones this server issued
+made_up="AAAAAAAAAAAAAAAAAAAAAA"
+hdr=$(curl -s -D - -o /dev/null -H "Cookie: tid=$made_up" -d filter=all --data-urlencode title=shared "$base/todos")
+contains "made-up token replaced" "Set-Cookie: tid=" "$hdr"
+lacks "made-up token not kept" "tid=$made_up" "$hdr"
+lacks "made-up token holds no list" "<label>shared</label>" "$(curl -s -H "Cookie: tid=$made_up" "$base/")"
+
+# --- long bodies arrive whole; short, chunked and oversized ones change nothing
+rockets=$(python3 -c 'print("\U0001F680" * 10000, end="")')
+check "10,000-emoji title" 303 "$(status -b "$jar" --data-urlencode "title=$rockets" -d filter=all "$base/todos")"
+contains "10,000 emoji kept whole" "<label>$rockets</label>" "$(curl -s -b "$jar" "$base/")"
+before=$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')
+raw() { # raw <python body>: talk to the server over a raw socket, print the status line
+ python3 -I -c "
+import socket, sys, time
+s = socket.create_connection(('127.0.0.1', $port))
+h = 'POST /todos HTTP/1.1\\r\\nHost: x\\r\\nCookie: tid=$token\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n'
+$1
+s.settimeout(30)
+print(s.recv(100).split(b'\\r\\n')[0].decode())
+"
+}
+contains "short body refused" " 400 " "$(raw "s.sendall((h + 'Content-Length: 5000\\r\\n\\r\\n').encode() + b'title=' + b'y' * 1000); s.shutdown(socket.SHUT_WR)")"
+contains "chunked body refused" " 400 " "$(raw "s.sendall((h + 'Transfer-Encoding: chunked\\r\\n\\r\\n5\\r\\ntitle\\r\\n0\\r\\n\\r\\n').encode())")"
+contains "oversized body refused" " 413 " "$(raw "s.sendall((h + 'Content-Length: 300000\\r\\n\\r\\n').encode())")"
+check "refused bodies changed nothing" "$before" "$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')"
+
+# --- a client trickling its body is cut off at the 10-second request deadline
+start=$(date +%s)
+raw "s.sendall((h + 'Content-Length: 100\\r\\n\\r\\n').encode())
+for _ in range(30):
+ try: s.sendall(b'x'); time.sleep(1)
+ except OSError: break" >/dev/null 2>&1
+took=$(( $(date +%s) - start ))
+check "trickling client cut off within 12s" 1 "$([ "$took" -le 12 ] && echo 1 || echo 0)"
+check "server still answers after a trickler" 200 "$(status "$base/healthz")"
+
+# --- only loopback listeners, and no silent every-interface fallback
+timeout 5 "$bin" --listen "0.0.0.0:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
+check "--listen 0.0.0.0 refused" 2 "$?"
+timeout 5 "$bin" --listen "10.1.2.3:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
+check "--listen 10.1.2.3 refused" 2 "$?"
+timeout 5 "$bin" --db "$work/x.db" -p "$((port + 1))" >/dev/null 2>&1
+check "no listener refused" 2 "$?"
+
# --- clean exit on SIGTERM, and the data survives a restart
kill -TERM "$pid"
code=0