Refuse an oversized body before inviting it #413

Merged
branderson merged 1 commits from proxy-expect-100 into development 2026-10-16 14:54:53 -07:00
3 changed files with 76 additions and 0 deletions
+10
View File
@@ -32,6 +32,16 @@ heading with the date it merges, and tags `main` with the same date.
and operator-specific values are replaced throughout. Commits keep their
authors and dates, but a forge SHA no longer resolves on GitHub.
### Fixed
- **The credential proxy refuses an oversized request body before inviting
it.** It answered `100 Continue` and only then read the declared length, so
whether a session saw the `413` or only the `100` was timing. The cap itself
always held - nothing oversized was read into host memory - but the
conversation was a race, and it surfaced as an intermittently red `proxy`
job.
([#400](https://gitbox.branderson.io/dejima/dejima/issues/400))
## 2026.10.14
Nothing a session does has changed. This release is about where dejima can be
+27
View File
@@ -424,6 +424,33 @@ class Handler(BaseHTTPRequestHandler):
% (what, n, self.MAX_BODY_BYTES))
return n
def handle_expect_100(self):
"""Refuse an oversized body before inviting it.
The default answers `100 Continue` and returns True, and it runs
before do_* - so the proxy invited the upload, read the length in
_read_body, refused it and closed mid-flight. Whether the client saw
the 413 or only the 100 was timing, which is a race a client cannot
be expected to win and a test cannot be expected to pass reliably.
The declared length is all there is to go on before a body arrives.
A chunked request declares nothing and is capped as it is read, which
is the one case where the conversation still ends mid-upload - there
is no earlier moment to end it at.
"""
try:
self._length(self.headers.get("Content-Length", 0), "Content-Length")
except BodyTooLarge as e:
# Closed for the same reason the other 413 closes: a client that
# sends anyway - curl gives up waiting after a second - leaves its
# bytes queued, and the next parse would read them as a request
# line and answer with garbage 400s instead of one clean refusal.
self.close_connection = True
self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
return False
return super().handle_expect_100()
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
+39
View File
@@ -485,6 +485,45 @@ make_cert() {
return 0
}
@test "an oversized body is refused before the 100 Continue that invites it" {
# The test above is about what the client ends up with, and it can pass
# either way when the timing is kind: the proxy used to answer
# `100 Continue` first and refuse from the body reader afterwards, so
# whether a client read the 413 or only the 100 was a race. This one asks
# the question the race cannot reach - what the FIRST answer is - and never
# sends a body at all.
start_proxy
run python3 - "$SOCK" "$PLACEHOLDER" <<'PROBE'
import socket, sys
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(sys.argv[1])
# Declared far over the cap, and not one byte of it sent: a client offering a
# body is entitled to be told no before it starts.
sock.sendall(
b"POST /v1/messages HTTP/1.1\r\n"
b"Host: x\r\n"
b"Authorization: Bearer " + sys.argv[2].encode() + b"\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: 99999999999\r\n"
b"Expect: 100-continue\r\n\r\n")
data = b""
while b"\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
print(data.split(b"\r\n")[0].decode("ascii", "replace"))
PROBE
[ "$status" -eq 0 ] || { echo "the probe failed: $output"; return 1; }
[[ "$output" == *"413"* ]] \
|| { echo "the first answer was not a refusal: $output"; return 1; }
grep -q 'refused a request body' "$TD/stderr" \
|| { echo "nothing logged: $(cat "$TD/stderr")"; return 1; }
}
@test "a normal-sized request body still passes through" {
# The cap must be far above anything real. A conversation with attachments
# is the biggest thing a Messages request carries.