17 KiB
BadTest Results
Tested against badtests.py on port 60666 using curl 8.5.0, urllib.request, http.client, requests, and aiohttp.
Legend: β = accepted/worked | β = rejected/error | β οΈ = accepted but weird behavior
π¨ Critical: Header Injection via CRLF
Endpoint: /ct-crlf
A Content-Type header with embedded \r\n injects a fake X-Injected: true header. Every single client accepts it as a real header.
curl -sv http://127.0.0.1:60666/ct-crlf
< Content-Type: text/html
< X-Injected: true
Python (all 4 libraries):
# urllib, http.client, requests, aiohttp all show:
Headers: {'Content-Type': 'text/html', 'X-Injected': 'true'}
Impact: Full header injection. An attacker controlling header values can inject arbitrary headers into responses. This enables cache poisoning, XSS via injected headers, session fixation, etc.
π¨ Critical: HTTP Request Smuggling via Duplicate Content-Length
Endpoint: /hdr-dup-cl
Server sends two Content-Length headers: 5 and 999. Different clients pick different values.
curl -sv http://127.0.0.1:60666/hdr-dup-cl
< Content-Length: 5
< Content-Length: 999
* transfer closed with 994 bytes remaining to read
hello
curl uses the second CL (999), reads 5 bytes, then reports 994 remaining.
# urllib.request β uses FIRST Content-Length (5), returns body fine
Headers: {'Content-Length': '5'}
Body: b'hello'
# http.client β exposes BOTH headers
Headers: [('Content-Length', '5'), ('Content-Length', '999')]
Body: b'hello'
# requests β REJECTS
ERROR: InvalidHeader: Content-Length contained multiple unmatching values (5, 999)
# aiohttp β REJECTS
ERROR: Duplicate Content-Length
Impact: Classic request smuggling vector. A proxy using the first CL and a backend using the second will desync, allowing request smuggling attacks.
π¨ Critical: Content-Length + Transfer-Encoding (Smuggling)
Endpoint: /te-double
Server sends both Content-Length: 5 and Transfer-Encoding: chunked.
curl -sv http://127.0.0.1:60666/te-double
< Content-Length: 5
< Transfer-Encoding: chunked
hello
curl reads body via CL, ignores the chunked TE.
# urllib/http.client/requests β all accept both headers, read body fine
Headers: {'Content-Length': '5', 'Transfer-Encoding': 'chunked'}
Body: b'hello'
# aiohttp β REJECTS (correct per RFC 7230)
ERROR: Transfer-Encoding can't be present with Content-Length
Impact: The exact CL+TE desync that enables HTTP request smuggling. Only aiohttp correctly rejects this.
β οΈ High: Python stdlib Accepts ANY Content-Length Value
Endpoints: /cl-word, /cl-empty, /cl-negative, /cl-float, /cl-hex, /cl-null, /cl-nan, /cl-inf, /cl-negative-one
Python's urllib.request, http.client, and requests accept literally ANY value for Content-Length β words, negative numbers, floats, hex, NaN, Infinity, null bytes. They just store the raw string and read the body from the connection close.
| Endpoint | curl | urllib/http.client/requests | aiohttp |
|---|---|---|---|
/cl-word (banana) |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
/cl-empty ("") |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
/cl-negative (-99999999999999999) |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
/cl-float (3.14159) |
β οΈ Reads 3 bytes "hel" | β Status 200, body b'hello' | β 400 |
/cl-hex (0xDEAD) |
β οΈ Accepts header, empty body | β Status 200, body b'hello' | β 400 |
/cl-null (5\x00) |
β Nul byte in header | β Status 200, body b'hello' | β 400 |
/cl-nan (NaN) |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
/cl-inf (Infinity) |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
/cl-negative-one (-1) |
β Invalid CL value | β Status 200, body b'hello' | β 400 |
curl is interesting with /cl-float:
curl -sv http://127.0.0.1:60666/cl-float
< Content-Length: 3.14159
hel
curl truncates the float to integer 3 and reads exactly 3 bytes.
Impact: Python stdlib libraries never validate Content-Length. If your code trusts Content-Length to be a valid integer, you'll crash or behave unexpectedly. aiohttp is the only Python lib that validates.
β οΈ High: Content-Length Mismatch Silently Truncates
Endpoint: /cl-mismatch β CL says 1, body is 30 bytes ("this is way more than one byte")
curl -sv http://127.0.0.1:60666/cl-mismatch
< Content-Length: 1
* transfer closed with ... bytes remaining
t
# urllib/http.client/requests β all return only 1 byte
Body: b't'
# aiohttp β REJECTS
ERROR: Data after `Connection: close`
Impact: Data truncation. If a server misconfigures CL, most clients silently drop the rest of the body. Only aiohttp flags this.
β οΈ High: Null Bytes Accepted in Headers by Python stdlib
Endpoints: /ct-null, /cl-null, /hdr-null
curl -sv http://127.0.0.1:60666/ct-null
* Nul byte in header
* Closing connection
curl correctly rejects null bytes in headers.
# urllib/http.client/requests β ACCEPT null bytes
Headers: {'Content-Type': 'text/\x00html'}
Body: b'hello'
# aiohttp on /ct-null β also ACCEPTS
Headers: {'Content-Type': 'text/\x00html'}
Impact: Null byte injection in headers. Can bypass security filters that use C-style string comparison (null terminates the string early).
β οΈ High: 1000 Headers β curl and aiohttp Accept, stdlib Rejects
Endpoint: /hdr-1000
curl -sv http://127.0.0.1:60666/hdr-1000
< X-H0: 0
< X-H1: 1
... (all 1000 accepted)
hello
curl accepts all 1000 headers with no limit.
# urllib/http.client/requests β REJECT
ERROR: HTTPException: got more than 100 headers
# aiohttp β ACCEPTS all 1000
Status: 200
Body: b'hello'
Impact: Python stdlib has a 100-header limit (good defense against slow header attacks). curl and aiohttp don't enforce any limit, making them vulnerable to memory exhaustion via header flooding.
β οΈ High: Gzip Bomb Behavior
Endpoint: /net-gzip-bomb β 9.7KB compressed β 10MB decompressed
curl -sv -o /dev/null http://127.0.0.1:60666/net-gzip-bomb
< Content-Encoding: gzip
< Content-Length: 9750
{ [9750 bytes data]
curl downloads 9750 raw bytes (doesn't auto-decompress without --compressed).
# urllib/http.client/requests β get raw gzip bytes (no auto-decompress)
Body: b'\x1f\x8b\x08\x00...' (9750 raw bytes)
# aiohttp β AUTO-DECOMPRESSES! Returns null bytes
Body: b'\x00\x00\x00\x00\x00...' (8192 bytes of zeros, capped by our read limit)
Impact: aiohttp auto-decompresses gzip, so a 9.7KB download becomes 10MB in memory. Without read limits, this would be a denial-of-service via memory exhaustion.
β οΈ High: False Content-Encoding Accepted by Most
Endpoint: /enc-gzip-lie β says Content-Encoding: gzip but body is plaintext
curl -sv http://127.0.0.1:60666/enc-gzip-lie
< Content-Encoding: gzip
this is definitely not gzipped
curl delivers the plaintext body despite the gzip header.
# urllib/http.client/requests β accept, return plaintext
Body: b'this is definitely not gzipped'
# aiohttp β REJECTS
ERROR: Can not decode content-encoding: gzip
Impact: Most clients ignore the Content-Encoding mismatch. If your app trusts the header to decompress, it'll crash. Only aiohttp validates.
β οΈ Medium: Invalid Status Codes
Endpoints: /status-neg, /status-zero, /status-999, /status-huge, /status-word
| Endpoint | curl | urllib | http.client | requests | aiohttp |
|---|---|---|---|---|---|
/status-neg (-1) |
β Unsupported subversion | β BadStatusLine | β BadStatusLine | β ConnectionError | β 400 |
/status-zero (0) |
β Unsupported subversion | β BadStatusLine | β BadStatusLine | β ConnectionError | β 400 |
/status-999 (999) |
β Shows "999" | β HTTPError 999 | β Status 999 | β Status 999 | β Status 999 |
/status-huge (99999) |
β Unsupported subversion | β BadStatusLine | β BadStatusLine | β ConnectionError | β 400 |
/status-word (BANANA) |
β Unsupported subversion | β BadStatusLine | β BadStatusLine | β ConnectionError | β 400 |
Notable: Status 999 is accepted by curl, http.client, requests, and aiohttp. Only urllib raises an error (treats any non-standard code β₯400 as an HTTPError). A 3-digit status code is technically valid per HTTP spec even if unregistered.
β οΈ Medium: Oversized Headers
Endpoints: /hdr-mega-name (64KB header name), /hdr-mega-val (1MB header value)
# All Python libs on /hdr-mega-name:
# urllib/http.client/requests: LineTooLong: got more than 65536 bytes
# aiohttp: Got more than 8190 bytes (65542)
curl accepts the 64KB header name but silently closes on the 1MB value.
Impact: aiohttp has the strictest limit (8190 bytes per header line). Python stdlib allows up to 64KB. curl has no apparent limit for header names.
β οΈ Medium: Transfer-Encoding: banana Accepted by All
Endpoint: /te-invalid
curl -sv http://127.0.0.1:60666/te-invalid
< Transfer-Encoding: banana
hello
Every client accepts Transfer-Encoding: banana without complaint. They all fall back to reading until connection close.
Impact: No client validates the Transfer-Encoding value. A garbage TE value is silently ignored.
β οΈ Medium: Garbage Location URL Causes Different Failures
Endpoint: /redir-garbage β Location contains \x00\xff://π:99999/../../etc/passwd
curl -sv http://127.0.0.1:60666/redir-garbage
* Nul byte in header
* Closing connection
# urllib β follows redirect, gets 404 (tries to resolve the garbage URL)
ERROR: HTTPError: HTTP Error 404: Not Found
# http.client β returns raw 301 with the garbage Location
Headers: [('Location', '\x00ΓΏ://Γ°\x9f\x92\x80:99999/../../etc/passwd')]
# requests β CRASHES on the byte
ERROR: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff
# aiohttp β accepts with surrogate escapes
Headers: {'Location': '\x00\udcff://π:99999/../../etc/passwd'}
Impact: Potential SSRF. urllib actually tries to follow the redirect. requests crashes entirely. http.client exposes path traversal payload.
β οΈ Medium: Redirect Chain Handling
Endpoint: /redir-chain β infinite 302 chain with unique URLs
curl -sv -L --max-redirs 5 http://127.0.0.1:60666/redir-chain
< Location: /redir-chain?n=1
... (follows 5 hops)
* Maximum (5) redirects followed
# urllib β detects infinite loop
ERROR: HTTP Error 302: The HTTP server returned a redirect error that would lead to an infinite loop.
# http.client β returns first 302 (doesn't follow)
Status: 302, Location: /redir-chain?n=1
# requests β returns first 302 (allow_redirects=False)
Status: 302
# aiohttp β returns first 302 (allow_redirects=False)
Status: 302
β οΈ Medium: Slowloris Timing
Endpoints: /slow-headers (1 byte per 0.5s), /slow-body (1 byte per second)
curl -sv --max-time 2 http://127.0.0.1:60666/slow-body
* Operation timed out after 2001 milliseconds with 2 out of 26 bytes received
ab
# urllib/http.client/requests on /slow-body β WAIT THE FULL 26 SECONDS
Body: b'abcdefghijklmnopqrstuvwxyz'
# aiohttp β gets only 1 byte before timeout
Body: b'a'
# aiohttp on /slow-headers β times out completely
ERROR: TimeoutError
Impact: Python stdlib clients with no timeout will block for the full duration of a slowloris attack. urllib/http.client/requests all waited 26 seconds for the slow body. Only aiohttp's timeout saved it.
β οΈ Medium: Headers Without Colons / Empty Names
Endpoint: /hdr-no-colon β sends BrokenHeaderNoColon as a header line
curl -sv http://127.0.0.1:60666/hdr-no-colon
* Header without colon
* Closing connection
# urllib/http.client/requests β accept, but headers dict is EMPTY
Headers: {}
Body: b'hello'
# aiohttp β REJECTS
ERROR: Invalid header token
Endpoint: /hdr-empty-name β sends : empty_name
# urllib/http.client/requests β accept, but the empty-name header is silently dropped
# aiohttp β REJECTS: "Invalid header token"
βΉοΈ Low: Protocol Violations
| Endpoint | curl | Python libs |
|---|---|---|
/proto-garbage (random bytes) |
β HTTP/0.9 not allowed | β BadStatusLine (all) |
/proto-empty (0 bytes) |
β Empty reply | β RemoteDisconnected (all) |
/proto-half (HTTP/9.9) |
β Unsupported version | β UnknownProtocol (all) |
/proto-no-headers (no headers) |
β Accepts, reads body | β All accept, body b'hello' |
/proto-just-newlines (only CRLF) |
β HTTP/0.9 not allowed | β BadStatusLine (all) |
Notable: /proto-no-headers β a response with just a status line and body (no headers at all) is accepted by every client. This means you can serve valid responses without any headers.
βΉοΈ Low: Encoding Mismatches
Endpoint: /enc-utf16 β body is UTF-16LE, header says charset=utf-8
All clients return the raw bytes without detecting the mismatch. No client validates that the body matches the declared charset.
Endpoint: /html-utf16 β full HTML page encoded as UTF-16LE with no charset declaration
curl -sv http://127.0.0.1:60666/html-utf16
< h t m l > < h e a d > < t i t l e > U T F - 1 6 L E ...
All clients return the raw UTF-16LE bytes. curl shows the spaced-out text.
βΉοΈ Low: HTML Content Tests
These endpoints return valid HTTP with unusual HTML content. All clients accept them since the HTTP layer is valid:
| Endpoint | What it does | All clients |
|---|---|---|
/html-long-title |
1000-char <title> |
β Accepted |
/html-multiline-title |
Newlines in <title> |
β Accepted |
/html-irc-title |
\r\nQUIT in <title> |
β Accepted |
/html-unicode |
Random Unicode 0x1000-0x3000 | β Accepted |
/html-meta-refresh |
<meta refresh> loop |
β Accepted (browser-only effect) |
/ansi-chaos |
ANSI escape codes | β Accepted (terminal-only effect) |
Summary: Client Strictness Ranking
Most strict β Least strict:
-
aiohttp β Validates Content-Length format, rejects null bytes in headers, enforces 8190-byte header limit, rejects duplicate CL, rejects CL+TE combo, validates gzip encoding, enforces timeouts. Best security posture.
-
curl β Rejects invalid CL values, null bytes, headers without colons. But accepts 1000 headers, emoji Content-Type, status 999, and the CL+TE combo. Interesting float-to-int truncation on CL.
-
requests β Rejects duplicate Content-Length. But accepts everything else that urllib accepts. Crashes on
\xffbyte in Location header. -
urllib.request / http.client β Accept almost everything. No CL validation, no null byte rejection, no header count limit enforcement (100 limit exists but only in stdlib). Blindly trusts whatever the server sends. Worst security posture.
Summary Table
| Test | curl | urllib | http.client | requests | aiohttp |
|---|---|---|---|---|---|
| CL: banana | β | β | β | β | β |
| CL: -1 | β | β | β | β | β |
| CL: 3.14159 | β οΈ 3 bytes | β | β | β | β |
| CL: 0xDEAD | β οΈ empty | β | β | β | β |
| CL: mismatch | β οΈ truncate | β οΈ truncate | β οΈ truncate | β οΈ truncate | β |
| CL: null byte | β | β | β | β | β |
| CT: CRLF inject | β injected | β injected | β injected | β injected | β injected |
| CT: emoji | β | β | β | β | β |
| CT: 10KB | β | β | β | β | β >8190 |
| Status: -1 | β | β | β | β | β |
| Status: 999 | β | β HTTPError | β | β | β |
| 64KB header name | β | β >65536 | β >65536 | β >65536 | β >8190 |
| 1000 headers | β | β >100 | β >100 | β >100 | β |
| Dup Content-Length | β οΈ uses 2nd | β οΈ uses 1st | β οΈ both | β | β |
| CL + TE (smuggle) | β οΈ uses CL | β both | β both | β both | β |
| TE: banana | β | β | β | β | β |
| Bad chunk size | β | β | β | β | β |
| No headers | β | β | β | β | β |
| Null in header | β | β | β | β | β |
| Header no colon | β | β empty | β empty | β empty | β |
| Gzip lie | β raw | β raw | β raw | β raw | β decode fail |
| Gzip bomb | β raw | β raw | β raw | β raw | β οΈ decompresses |
| Slowloris body | β οΈ 2s timeout | β οΈ 26s wait | β οΈ 26s wait | β οΈ 26s wait | β οΈ 1 byte |
| Garbage Location | β null byte | β οΈ follows | β raw | β crash | β surrogates |