fix(http): reject every Transfer-Encoding, not just the ones we can't name
`identity` was exempt as a coding that "frames nothing", so a request
carrying it read back as body-less however many payload bytes followed.
It frames nothing either: RFC 9112 6.3 says a request whose final
transfer coding is not `chunked` has a body length no recipient can
determine, RFC 7230 removed `identity` from the transfer codings
outright, and `http.server` decodes none of them (`rfile` is the raw
socket). Every present value has to reject-and-close.
Reproduced on 8c326003b, pipelined down one socket:
GET /api/extensions/probe/sidecar/ping `Transfer-Encoding: identity`
{"stale": true}
-> HTTP/1.1 403 Forbidden (no Connection: close)
-> HTTP/1.1 400 Bad request syntax
('{"stale": true}GET /api/health/agent HTTP/1.1')
POST /api/upload (and /upload/extract, /workspace/upload, /transcribe)
-> HTTP/1.1 400 Bad Request ("No file field in request", no close)
-> HTTP/1.1 400 Bad request syntax ('--x')
Deletes the `_UNFRAMED_TRANSFER_CODINGS` allowlist and the fold-safety
test that existed only to guard its membership check; `_BLANK_TRANSFER_CODING`
stays, since a present-but-empty header still needs a named, non-falsy
report. The identity rows of the framing tables and of
`test_bodyless_framing_keeps_the_pooled_socket_alive` move to the
closes-the-socket side, with real-socket coverage on the sidecar path and
all four upload endpoints. The keep-alive half stays pinned by the cases
that genuinely have no body: no framing header at all, `Content-Length: 0`,
an OWS-padded zero, two agreeing zeroes.
Swept the rest of the coding surface for the same class: every case
spelling, OWS and non-OWS padding, `identity, chunked`, `chunked, identity`,
`identity,identity`, a lone comma, an unknown coding, duplicated headers,
and a coding beside both a real and a zero Content-Length.
This commit is contained in:
1 parent
01f2446459
commit
b022a79eef
4 files changed
+281
-142
No files matched your search
@@ -199,11 +199,22 @@ def test_bodyless_write_method_rejection_keeps_connection(method, headers, monke
|
||||
({"content-length": "7"}, True),
|
||||
({"Transfer-Encoding": "chunked"}, True),
|
||||
({"transfer-encoding": "Chunked"}, True),
|
||||
({"Transfer-Encoding": "identity"}, False),
|
||||
({"Transfer-Encoding": "IDENTITY "}, False), # a token is case-insensitive
|
||||
# No coding is exempt: http.server decodes none of them (RFC 9112 6.3).
|
||||
({"Transfer-Encoding": "identity"}, True),
|
||||
({"Transfer-Encoding": "IDENTITY "}, True),
|
||||
({"Transfer-Encoding": "Identity"}, True),
|
||||
({"Transfer-Encoding": "\tidentity\t"}, True),
|
||||
({"Transfer-Encoding": "identity, chunked"}, True),
|
||||
({"Transfer-Encoding": "chunked, identity"}, True),
|
||||
({"Transfer-Encoding": "identity,identity"}, True),
|
||||
({"Transfer-Encoding": ","}, True),
|
||||
({"Transfer-Encoding": "banana"}, True),
|
||||
({"Transfer-Encoding": ""}, True),
|
||||
({"Transfer-Encoding": " "}, True),
|
||||
({"Transfer-Encoding": "\xa0identity"}, True), # not the token `identity`
|
||||
# A coding beside a length: the coding decides, whatever the length says.
|
||||
({"Transfer-Encoding": "identity", "Content-Length": "0"}, True),
|
||||
({"Transfer-Encoding": "identity", "Content-Length": "15"}, True),
|
||||
],
|
||||
)
|
||||
def test_request_declares_body_reads_the_framing_headers(headers, expected):
|
||||
@@ -268,15 +279,31 @@ def test_duplicate_agreeing_zero_content_length_declares_no_body():
|
||||
assert request_declares_body(cast(Any, handler)) is False
|
||||
|
||||
|
||||
def test_repeated_transfer_encoding_is_detected_beyond_the_first_value():
|
||||
"""`identity` first must not mask a chunked coding behind it."""
|
||||
from api.helpers import unsupported_transfer_encoding
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"Transfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n",
|
||||
"Transfer-Encoding: chunked\r\nTransfer-Encoding: identity\r\n",
|
||||
"Transfer-Encoding: identity\r\nTransfer-Encoding: identity\r\n",
|
||||
"Transfer-Encoding: identity\r\ntransfer-encoding: identity\r\n",
|
||||
],
|
||||
ids=["identity-then-chunked", "chunked-then-identity", "twice", "twice-mixed-case"],
|
||||
)
|
||||
def test_repeated_transfer_encoding_refuses_whatever_the_order(raw):
|
||||
"""A duplicated header cannot hide an undecodable coding behind an "unframed" one.
|
||||
|
||||
handler = _message_with(
|
||||
"Host: x\r\nTransfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n"
|
||||
)
|
||||
This used to have to look PAST a leading `identity` to find the chunked coding
|
||||
(`Message.get()` returns only the first value, so it read as unframed and the
|
||||
chunk bytes stayed queued). With no coding exempt the leading value already
|
||||
refuses, so no ordering can mask anything -- which is what is pinned here,
|
||||
rather than the specific coding a first-value read happened to miss.
|
||||
"""
|
||||
from api.helpers import request_declares_body, unsupported_transfer_encoding
|
||||
|
||||
assert unsupported_transfer_encoding(cast(Any, handler)) == "chunked"
|
||||
handler = _message_with("Host: x\r\n" + raw)
|
||||
|
||||
assert unsupported_transfer_encoding(cast(Any, handler)) is not None
|
||||
assert request_declares_body(cast(Any, handler)) is True
|
||||
|
||||
|
||||
def test_unreadable_content_length_flags_conflict_and_garbage_only():
|
||||
@@ -334,11 +361,12 @@ def test_sidecar_get_with_duplicate_content_length_closes_connection(monkeypatch
|
||||
# `Content-Length:\t` to the same '', so every blank spelling poisoned the socket
|
||||
# identically — as did a blank value duplicated, or paired with a real length.
|
||||
#
|
||||
# A blank `Transfer-Encoding:` was the same hole one header over: '' sat in
|
||||
# `_UNFRAMED_TRANSFER_CODINGS` beside `identity`, so a header naming no coding at
|
||||
# all read as "no framing to worry about" and its payload poisoned the socket the
|
||||
# same way (verified live: 403 then the same bad-syntax 400). Absence is
|
||||
# something only the wire can say, and an absent header yields no values at all.
|
||||
# A blank `Transfer-Encoding:` was the same hole one header over: '' reached the
|
||||
# allowlist of codings that "framed nothing" (since deleted, along with the
|
||||
# `identity` entry that was its last member), so a header naming no coding at all
|
||||
# read as "no framing to worry about" and its payload poisoned the socket the same
|
||||
# way (verified live: 403 then the same bad-syntax 400). Absence is something only
|
||||
# the wire can say, and an absent header yields no values at all.
|
||||
|
||||
|
||||
_BLANK_FRAMING_HEADERS = [
|
||||
@@ -390,10 +418,12 @@ def test_blank_transfer_encoding_is_reported_as_an_undecodable_coding():
|
||||
|
||||
assert coding is not None
|
||||
assert coding, "an empty return value would be a falsy non-None trap for callers"
|
||||
# Only an ABSENT header frames nothing; a present one always names a coding
|
||||
# this server cannot decode, `identity` included.
|
||||
assert unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\n"))) is None
|
||||
assert unsupported_transfer_encoding(
|
||||
cast(Any, _message_with("Host: x\r\nTransfer-Encoding: identity\r\n"))
|
||||
) is None
|
||||
) == "identity"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS)
|
||||
@@ -427,14 +457,14 @@ def test_sidecar_get_with_blank_framing_value_closes_connection(raw, monkeypatch
|
||||
"Content-Length: 0\r\n",
|
||||
"Content-Length: 0 \r\n",
|
||||
"Content-Length: 0\r\nContent-Length: 0\r\n",
|
||||
"Transfer-Encoding: identity\r\n",
|
||||
],
|
||||
)
|
||||
def test_blank_rule_does_not_over_close_a_bodyless_request(raw):
|
||||
"""The other half of the contract: framing that says "no body" keeps keep-alive.
|
||||
|
||||
Two AGREEING zeroes, a padded zero and `identity` are honest statements that
|
||||
nothing is queued, and none of them may be swept up by the blank rule.
|
||||
No framing header at all, an OWS-padded zero and two AGREEING zeroes are
|
||||
honest statements that nothing is queued, and none of them may be swept up by
|
||||
the blank rule.
|
||||
"""
|
||||
from api.helpers import request_declares_body
|
||||
|
||||
@@ -550,21 +580,69 @@ def test_absurdly_long_digit_run_is_unreadable_and_never_raises():
|
||||
assert unreadable_content_length(cast(Any, handler)) is True
|
||||
|
||||
|
||||
# ── The same audit one header over: spelling an undecodable coding ─────────────
|
||||
# ── The same audit one header over: NO transfer coding is decodable here ───────
|
||||
#
|
||||
# `identity` was exempt as a coding that "frames nothing", so `Transfer-Encoding:
|
||||
# identity` plus payload bytes read as body-less: the sidecar rejection answered
|
||||
# 403 with keep-alive and the payload became the next request line, and all four
|
||||
# upload handlers fell through to a length-0 read. RFC 9112 6.3 is explicit --
|
||||
# a request whose final transfer coding is not `chunked` has a length the
|
||||
# recipient cannot determine, so it must be refused and the connection closed --
|
||||
# and http.server decodes no coding at all, `chunked` included. Every spelling in
|
||||
# the neighbourhood is swept here because three prior rounds each fixed one value
|
||||
# of a framing rule and left an adjacent one poisoning the socket.
|
||||
#
|
||||
# The over-close half of this contract is pinned by the framing table above and by
|
||||
# `test_blank_rule_does_not_over_close_a_bodyless_request` /
|
||||
# `test_digit_grammar_does_not_over_close_an_honest_zero`: a request with NO
|
||||
# framing header, or one whose declared lengths all agree on zero, keeps its
|
||||
# keep-alive. An absent `Transfer-Encoding` is the only one that frames nothing.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["\xa0identity", "identity\xa0", "\xa0identity\xa0", "\x85identity"],
|
||||
ids=["nbsp-ahead", "nbsp-behind", "nbsp-both", "nel-ahead"],
|
||||
[
|
||||
"identity",
|
||||
"IDENTITY",
|
||||
"Identity",
|
||||
"identity ",
|
||||
"\tidentity\t",
|
||||
"\xa0identity",
|
||||
"identity\xa0",
|
||||
"\xa0identity\xa0",
|
||||
"\x85identity",
|
||||
"identity, chunked",
|
||||
"chunked, identity",
|
||||
"identity,identity",
|
||||
",",
|
||||
"banana",
|
||||
],
|
||||
ids=[
|
||||
"identity",
|
||||
"upper",
|
||||
"title",
|
||||
"sp-padded",
|
||||
"htab-padded",
|
||||
"nbsp-ahead",
|
||||
"nbsp-behind",
|
||||
"nbsp-both",
|
||||
"nel-ahead",
|
||||
"identity-then-chunked",
|
||||
"chunked-then-identity",
|
||||
"identity-list",
|
||||
"lone-comma",
|
||||
"unknown-coding",
|
||||
],
|
||||
)
|
||||
def test_non_ascii_padded_identity_is_not_the_identity_coding(value):
|
||||
"""`str.strip()` erased U+00A0/U+0085, so `\\xa0identity` read back as `identity`.
|
||||
def test_every_spelling_of_a_transfer_coding_declares_a_body(value):
|
||||
"""Case, OWS, non-OWS padding, comma lists, a bare comma, an unknown coding.
|
||||
|
||||
No token grammar allows the padding, so the value names no coding this server
|
||||
can decode -- and it reached the wire: latin-1 request lines carry U+00A0
|
||||
fine. Live on the otherwise-fixed head, sidecar GET + payload: 403 with no
|
||||
`Connection: close`, then `400 Bad request syntax ('{"stale": true}GET ...')`.
|
||||
The ASCII `identity` spellings are the newly-closed hole (they normalized to
|
||||
the exempt token); the U+00A0/U+0085-padded ones were the previous round's
|
||||
(`str.strip()` erased the padding, so `\\xa0identity` read back as `identity`)
|
||||
and they reach the wire because a request line decodes as latin-1. The comma
|
||||
forms never matched the exempt token and already refused -- they are pinned so
|
||||
a future normalizer that splits the list cannot reopen the hole.
|
||||
"""
|
||||
from api.helpers import request_declares_body, unsupported_transfer_encoding
|
||||
|
||||
@@ -574,44 +652,23 @@ def test_non_ascii_padded_identity_is_not_the_identity_coding(value):
|
||||
assert request_declares_body(cast(Any, handler)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value", ["identity", "IDENTITY", "Identity", "identity ", "\tidentity\t"]
|
||||
)
|
||||
def test_ascii_case_and_ows_spellings_of_identity_stay_decodable(value):
|
||||
"""The over-close half of the coding audit: a real token still frames nothing."""
|
||||
from api.helpers import unsupported_transfer_encoding
|
||||
@pytest.mark.parametrize("length", ["0", "15"], ids=["zero-length", "real-length"])
|
||||
def test_transfer_encoding_beside_a_content_length_still_declares_a_body(length):
|
||||
"""A coding plus a length is unframeable however honest the length looks.
|
||||
|
||||
handler = _message_with(f"Host: x\r\nTransfer-Encoding: {value}\r\n")
|
||||
|
||||
assert unsupported_transfer_encoding(cast(Any, handler)) is None
|
||||
|
||||
|
||||
def test_unframed_transfer_codings_cannot_be_spelled_non_ascii():
|
||||
"""Pin WHY membership-by-`str.lower()` is safe, so a future coding can't break it.
|
||||
|
||||
`str.lower()` maps some non-ASCII characters onto ASCII ones -- U+212A KELVIN
|
||||
SIGN lowercases to `k`, so `'chunKed'.lower() == 'chunked'` -- which means a
|
||||
coding in the unframed set containing such a letter would be reachable by a
|
||||
spelling no HTTP token allows, and would read as "frames nothing" for a
|
||||
request whose bytes are still queued. `identity` has no such letter. This
|
||||
scans every codepoint rather than trusting the claim.
|
||||
`Transfer-Encoding: identity` with `Content-Length: 0` was the worst of the
|
||||
pair: both halves read as "no body", so the rejection kept the socket and the
|
||||
payload behind them was parsed as the next request line. RFC 9112 6.3 refuses
|
||||
the combination outright (it is the request-smuggling shape), and the coding
|
||||
has to win -- a length cannot describe a body the server cannot decode.
|
||||
"""
|
||||
from api.helpers import _UNFRAMED_TRANSFER_CODINGS
|
||||
from api.helpers import request_declares_body
|
||||
|
||||
letters = set("".join(_UNFRAMED_TRANSFER_CODINGS))
|
||||
assert all(
|
||||
coding.isascii() and coding.islower() for coding in _UNFRAMED_TRANSFER_CODINGS
|
||||
), _UNFRAMED_TRANSFER_CODINGS
|
||||
foldable = [
|
||||
(hex(cp), chr(cp).lower())
|
||||
for cp in range(0x110000)
|
||||
if not chr(cp).isascii() and chr(cp).lower() and set(chr(cp).lower()) <= letters
|
||||
]
|
||||
assert not foldable, (
|
||||
f"a non-ASCII character now lowercases into {sorted(letters)}, so an "
|
||||
f"unframed coding could be spelled outside the token grammar: {foldable}"
|
||||
handler = _message_with(
|
||||
f"Host: x\r\nTransfer-Encoding: identity\r\nContent-Length: {length}\r\n"
|
||||
)
|
||||
assert "chunKed".lower() == "chunked", "the fold this test guards against"
|
||||
|
||||
assert request_declares_body(cast(Any, handler)) is True
|
||||
|
||||
|
||||
def test_csp_report_rate_limited_closes_connection(monkeypatch):
|
||||
@@ -808,37 +865,43 @@ def test_malformed_zero_content_length_multipart_rejects_before_read(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||||
def test_non_ascii_padded_identity_multipart_rejects_before_read(handler_name, monkeypatch):
|
||||
"""`Transfer-Encoding: \\xa0identity` is not the `identity` token either.
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"Transfer-Encoding: identity",
|
||||
"Transfer-Encoding: IDENTITY ",
|
||||
"Transfer-Encoding: \xa0identity",
|
||||
"Transfer-Encoding:",
|
||||
"Transfer-Encoding: identity\r\nContent-Length: 0",
|
||||
"Transfer-Encoding: identity\r\nContent-Length: 88",
|
||||
"Transfer-Encoding: banana",
|
||||
],
|
||||
ids=[
|
||||
"identity",
|
||||
"identity-cased-and-padded",
|
||||
"nbsp-padded-identity",
|
||||
"blank",
|
||||
"identity-plus-zero-length",
|
||||
"identity-plus-real-length",
|
||||
"unknown-coding",
|
||||
],
|
||||
)
|
||||
def test_any_transfer_encoding_multipart_rejects_before_read(handler_name, raw, monkeypatch):
|
||||
"""No coding is decodable by an upload handler, so none may reach `rfile`.
|
||||
|
||||
`str.strip()` erased the U+00A0, so it read as unframed, fell through to the
|
||||
length-0 read and answered 400 "No file field in request" with the payload
|
||||
still queued (live: 400 then `400 Bad request syntax ('--x')`).
|
||||
`identity` (any case, any OWS padding) was exempt as a coding that "frames
|
||||
nothing", so each handler fell through to the length-0 read, answered 400 "No
|
||||
file field in request" with keep-alive and left the whole multipart payload on
|
||||
the socket (live: 400 then `400 Bad request syntax ('--x')`). The `\\xa0`-padded
|
||||
spelling and the blank value are the previous round's holes, and the two
|
||||
Content-Length pairings are the RFC 9112 6.3 smuggling shape -- the coding has
|
||||
to win over any length beside it. `_NeverRead` proves the preflight answers
|
||||
before a byte is consumed.
|
||||
"""
|
||||
from email.parser import Parser
|
||||
|
||||
headers = Parser().parsestr(
|
||||
"Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding: \xa0identity\r\n\r\n"
|
||||
)
|
||||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||||
|
||||
assert answered["status"] == 411, answered
|
||||
assert "Transfer-Encoding" in answered["payload"]["error"], answered
|
||||
assert handler.close_connection is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||||
def test_blank_transfer_encoding_multipart_rejects_before_read(handler_name, monkeypatch):
|
||||
"""`Transfer-Encoding:` names no coding, so it cannot frame a body either.
|
||||
|
||||
It used to read as "unframed" beside `identity` and fell through to the
|
||||
length-0 read: 400 "No file field in request", keep-alive, and the payload
|
||||
left on the socket (live: 400 then `400 Bad request syntax ('--x')`).
|
||||
"""
|
||||
from email.parser import Parser
|
||||
|
||||
headers = Parser().parsestr(
|
||||
"Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding:\r\n\r\n"
|
||||
"Content-Type: multipart/form-data; boundary=x\r\n" + raw + "\r\n\r\n"
|
||||
)
|
||||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||||
|
||||
|
||||
@@ -210,8 +210,9 @@ def test_duplicate_content_length_upload_closes_and_cannot_poison_the_socket(pat
|
||||
# sidecar GET -> 403, then 400 Bad request syntax ('{"stale": true}GET /...')
|
||||
# /api/upload -> 400, then 400 Bad request syntax ('--x')
|
||||
# `Message` collapses `Content-Length:` and `Content-Length: ` to the same '',
|
||||
# and a blank `Transfer-Encoding:` sat in the unframed set beside `identity`, so
|
||||
# each spelling below poisoned the socket the same way.
|
||||
# and a blank `Transfer-Encoding:` reached the allowlist of codings that "framed
|
||||
# nothing" (since deleted — see the identity section below), so each spelling
|
||||
# below poisoned the socket the same way.
|
||||
|
||||
_BLANK_FRAMING_HEADER_LINES = (
|
||||
b"Content-Length:\r\n",
|
||||
@@ -319,6 +320,89 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path,
|
||||
_assert_single_closed_response(answered, status, _MULTIPART_PAYLOAD)
|
||||
|
||||
|
||||
# ── `Transfer-Encoding: identity` frames nothing a reader can trust ───────────
|
||||
#
|
||||
# `identity` sat in an allowlist of codings that "frame nothing", so a request
|
||||
# carrying it read back as body-less however many payload bytes followed. RFC
|
||||
# 9112 §6.3 leaves no room for that: a request whose final transfer coding is not
|
||||
# `chunked` cannot be framed by a recipient, so it must be refused and the
|
||||
# connection closed — and `http.server` decodes no transfer coding at all, so
|
||||
# `identity` is no more readable here than `chunked` is. Reproduced live on the
|
||||
# otherwise-fixed head, pipelined down one socket:
|
||||
#
|
||||
# GET /api/extensions/probe/sidecar/ping `Transfer-Encoding: identity`
|
||||
# {"stale": true}
|
||||
# -> HTTP/1.1 403 Forbidden (no Connection: close)
|
||||
# -> HTTP/1.1 400 Bad request syntax
|
||||
# ('{"stale": true}GET /api/health/agent HTTP/1.1')
|
||||
# POST /api/upload `Transfer-Encoding: identity` --x...
|
||||
# -> HTTP/1.1 400 Bad Request (no Connection: close, "No file field")
|
||||
# -> HTTP/1.1 400 Bad request syntax ('--x')
|
||||
#
|
||||
# Only the spellings that normalize to the bare token were poisoned — every
|
||||
# neighbouring value (`identity, chunked`, `identity,identity`, a lone comma, an
|
||||
# unknown coding, a non-OWS-padded token, a blank value) already missed the
|
||||
# allowlist and already closed. Those rows are in the sweep below anyway: this is
|
||||
# the fourth round in which an adjacent spelling of one framing rule survived a
|
||||
# fix, so the whole neighbourhood gets pinned rather than just the reported case.
|
||||
|
||||
_IDENTITY_FRAMING_LINES = [
|
||||
b"Transfer-Encoding: identity\r\n",
|
||||
b"Transfer-Encoding: IDENTITY\r\n",
|
||||
b"Transfer-Encoding: Identity\r\n",
|
||||
b"Transfer-Encoding: identity \r\n",
|
||||
b"Transfer-Encoding: \tidentity\t\r\n",
|
||||
b"Transfer-Encoding: identity\r\nTransfer-Encoding: identity\r\n",
|
||||
b"Transfer-Encoding: identity\r\nContent-Length: 0\r\n",
|
||||
b"Transfer-Encoding: identity\r\nContent-Length: " + str(len(_BODY)).encode() + b"\r\n",
|
||||
b"Transfer-Encoding: identity, chunked\r\n",
|
||||
b"Transfer-Encoding: chunked, identity\r\n",
|
||||
b"Transfer-Encoding: identity,identity\r\n",
|
||||
b"Transfer-Encoding: ,\r\n",
|
||||
b"Transfer-Encoding: banana\r\n",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("framing", _IDENTITY_FRAMING_LINES)
|
||||
def test_identity_framing_sidecar_get_closes_and_cannot_poison_the_socket(framing):
|
||||
answered = _pipelined_after(
|
||||
b"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n"
|
||||
b"Host: 127.0.0.1\r\n"
|
||||
b"Content-Type: application/json\r\n" + framing + b"\r\n" + _BODY
|
||||
)
|
||||
|
||||
_assert_single_closed_response(answered, b"403", _BODY)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS)
|
||||
@pytest.mark.parametrize(
|
||||
"framing",
|
||||
[
|
||||
b"Transfer-Encoding: identity\r\n",
|
||||
b"Transfer-Encoding: IDENTITY \r\n",
|
||||
b"Transfer-Encoding: identity\r\nContent-Length: 0\r\n",
|
||||
],
|
||||
ids=["identity", "identity-cased-and-padded", "identity-plus-zero-length"],
|
||||
)
|
||||
def test_identity_framing_upload_closes_and_cannot_poison_the_socket(path, framing):
|
||||
"""All four multipart handlers, mirroring the chunked cases above.
|
||||
|
||||
`identity` carries no Content-Length either, so the length-0 read found no
|
||||
parts and each handler answered 400 "No file field in request" with the whole
|
||||
multipart payload still queued on an open socket.
|
||||
"""
|
||||
answered = _pipelined_after(
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
"Host: 127.0.0.1\r\n"
|
||||
"Content-Type: multipart/form-data; boundary=x\r\n".encode()
|
||||
+ framing
|
||||
+ b"\r\n"
|
||||
+ _MULTIPART_PAYLOAD
|
||||
)
|
||||
|
||||
_assert_single_closed_response(answered, b"411", _MULTIPART_PAYLOAD)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"framing",
|
||||
[
|
||||
@@ -327,8 +411,6 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path,
|
||||
b"Content-Length: 00\r\n",
|
||||
b"Content-Length: 0 \r\n",
|
||||
b"Content-Length: 0\r\nContent-Length: 0\r\n",
|
||||
b"Transfer-Encoding: identity\r\n",
|
||||
b"Transfer-Encoding: IDENTITY \r\n",
|
||||
],
|
||||
ids=[
|
||||
"no-length",
|
||||
@@ -336,8 +418,6 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path,
|
||||
"double-zero-length",
|
||||
"ows-padded-zero",
|
||||
"two-agreeing-zeroes",
|
||||
"identity",
|
||||
"identity-cased-and-padded",
|
||||
],
|
||||
)
|
||||
def test_bodyless_framing_keeps_the_pooled_socket_alive(framing):
|
||||
|
||||
Reference in new issue
Block a user