add hypothesis-based fuzzing test suite
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Unit Tests (push) Has been cancelled
CI / Security Tests (push) Has been cancelled
CI / Memory Leak Check (push) Has been cancelled
CI / SBOM Generation (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Unit Tests (push) Has been cancelled
CI / Security Tests (push) Has been cancelled
CI / Memory Leak Check (push) Has been cancelled
CI / SBOM Generation (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
18 property-based tests covering: - Content handling (binary, text, unicode) - Paste ID validation and path traversal - Header fuzzing (auth, proxy, XFF) - JSON endpoint fuzzing - Size limit enforcement - Injection detection (SQLi, SSTI, XSS) - Error handling paths
This commit is contained in:
485
documentation/fuzzing-tools.md
Normal file
485
documentation/fuzzing-tools.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Fuzzing Tools Guide
|
||||||
|
|
||||||
|
Quick reference for offensive security testing tools used in FlaskPaste fuzzing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Tool │ Purpose
|
||||||
|
├─────────────┼──────────────────────────────────────────────────────────────┤
|
||||||
|
│ httpx │ Async HTTP client for high-concurrency testing
|
||||||
|
│ aiohttp │ Async HTTP for protocol-level attacks
|
||||||
|
│ hypothesis │ Property-based fuzzing with smart input generation
|
||||||
|
│ requests │ Synchronous HTTP for sequential tests
|
||||||
|
│ curl │ Quick manual endpoint testing
|
||||||
|
│ radamsa │ Mutation-based fuzzing (binary corruption)
|
||||||
|
│ atheris │ Coverage-guided Python fuzzing (LibFuzzer)
|
||||||
|
│ sqlmap │ Automated SQL injection detection
|
||||||
|
│ ffuf │ Fast web fuzzer for directory/parameter discovery
|
||||||
|
└─────────────┴──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create isolated fuzzing environment
|
||||||
|
python3 -m venv /tmp/fuzz-venv
|
||||||
|
source /tmp/fuzz-venv/bin/activate
|
||||||
|
|
||||||
|
# Core tools
|
||||||
|
pip install httpx aiohttp hypothesis requests pytest pytest-asyncio
|
||||||
|
|
||||||
|
# Optional advanced tools
|
||||||
|
pip install atheris # Coverage-guided fuzzing
|
||||||
|
# pip install python-afl # AFL integration (requires AFL++)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## httpx - Async HTTP Client
|
||||||
|
|
||||||
|
Modern async HTTP client for high-concurrency testing.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Sync client
|
||||||
|
r = httpx.get("http://target/")
|
||||||
|
print(r.status_code, r.text[:100])
|
||||||
|
|
||||||
|
# With custom headers
|
||||||
|
r = httpx.post("http://target/",
|
||||||
|
data="payload",
|
||||||
|
headers={"X-Custom": "value"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Concurrent Requests
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async def fuzz_concurrent(url, count=100):
|
||||||
|
"""Send concurrent requests to test race conditions."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tasks = [client.get(url) for _ in range(count)]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
success = sum(1 for r in results if hasattr(r, 'status_code') and r.status_code == 200)
|
||||||
|
print(f"Success: {success}/{count}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Run
|
||||||
|
asyncio.run(fuzz_concurrent("http://target/paste-id", 50))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeout and Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
client = httpx.Client(timeout=5.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = client.get("http://target/slow-endpoint")
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
print("Timeout - potential DoS")
|
||||||
|
except httpx.ConnectError:
|
||||||
|
print("Connection failed")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## hypothesis - Property-Based Testing
|
||||||
|
|
||||||
|
Generate smart test inputs automatically based on specifications.
|
||||||
|
|
||||||
|
### Basic Fuzzing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import given, strategies as st, settings
|
||||||
|
|
||||||
|
@settings(max_examples=1000)
|
||||||
|
@given(st.binary(min_size=0, max_size=10000))
|
||||||
|
def test_paste_content(content):
|
||||||
|
"""Fuzz paste content with arbitrary bytes."""
|
||||||
|
import requests
|
||||||
|
r = requests.post("http://target/", data=content,
|
||||||
|
headers={"X-PoW-Token": "x", "X-PoW-Solution": "0"})
|
||||||
|
assert r.status_code in (201, 400, 413, 429)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Fuzzing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import given, strategies as st
|
||||||
|
|
||||||
|
@given(st.text(min_size=0, max_size=1000, alphabet=st.characters()))
|
||||||
|
def test_unicode_handling(text):
|
||||||
|
"""Test Unicode handling in paste content."""
|
||||||
|
import requests
|
||||||
|
r = requests.post("http://target/", data=text.encode('utf-8'),
|
||||||
|
headers={"X-PoW-Token": "x", "X-PoW-Solution": "0"})
|
||||||
|
assert r.status_code != 500 # No server errors
|
||||||
|
|
||||||
|
@given(st.text(alphabet="/<>\"'%;()&+"))
|
||||||
|
def test_special_chars_in_id(paste_id):
|
||||||
|
"""Test special characters in paste ID."""
|
||||||
|
import requests
|
||||||
|
r = requests.get(f"http://target/{paste_id}")
|
||||||
|
assert r.status_code in (200, 400, 404)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Fuzzing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import given, strategies as st
|
||||||
|
|
||||||
|
@given(st.dictionaries(
|
||||||
|
st.text(min_size=1, max_size=50),
|
||||||
|
st.one_of(st.text(), st.integers(), st.floats(), st.none(), st.booleans()),
|
||||||
|
min_size=0, max_size=20
|
||||||
|
))
|
||||||
|
def test_json_endpoints(data):
|
||||||
|
"""Fuzz JSON endpoints with arbitrary structures."""
|
||||||
|
import requests
|
||||||
|
r = requests.post("http://target/pki/ca",
|
||||||
|
json=data,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
assert r.status_code != 500
|
||||||
|
|
||||||
|
# Nested structures
|
||||||
|
@given(st.recursive(
|
||||||
|
st.text() | st.integers(),
|
||||||
|
lambda children: st.lists(children) | st.dictionaries(st.text(), children),
|
||||||
|
max_leaves=50
|
||||||
|
))
|
||||||
|
def test_nested_json(data):
|
||||||
|
"""Test deeply nested JSON structures."""
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
r = requests.post("http://target/pki/issue", json={"data": data})
|
||||||
|
assert r.status_code != 500
|
||||||
|
except Exception:
|
||||||
|
pass # Connection errors ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header Fuzzing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import given, strategies as st
|
||||||
|
|
||||||
|
@given(st.dictionaries(st.text(max_size=100), st.text(max_size=500), max_size=20))
|
||||||
|
def test_arbitrary_headers(headers):
|
||||||
|
"""Test with arbitrary HTTP headers."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Filter dangerous headers
|
||||||
|
safe = {k: v for k, v in headers.items()
|
||||||
|
if k.lower() not in ('host', 'content-length', 'transfer-encoding')}
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get("http://target/", headers=safe, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Hypothesis Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with pytest
|
||||||
|
pytest test_fuzz.py -v --hypothesis-show-statistics
|
||||||
|
|
||||||
|
# Increase examples
|
||||||
|
pytest test_fuzz.py --hypothesis-seed=0 -x
|
||||||
|
|
||||||
|
# Save failing examples
|
||||||
|
pytest test_fuzz.py --hypothesis-database=.hypothesis
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## aiohttp - Low-Level Async HTTP
|
||||||
|
|
||||||
|
For protocol-level attacks and custom request crafting.
|
||||||
|
|
||||||
|
### HTTP Smuggling Test
|
||||||
|
|
||||||
|
```python
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def test_http_smuggling():
|
||||||
|
"""Test for HTTP request smuggling."""
|
||||||
|
# CL.TE smuggling attempt
|
||||||
|
payload = (
|
||||||
|
b"POST / HTTP/1.1\r\n"
|
||||||
|
b"Host: target\r\n"
|
||||||
|
b"Content-Length: 13\r\n"
|
||||||
|
b"Transfer-Encoding: chunked\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
b"0\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
b"SMUGGLED"
|
||||||
|
)
|
||||||
|
|
||||||
|
reader, writer = await asyncio.open_connection('127.0.0.1', 5000)
|
||||||
|
writer.write(payload)
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
response = await reader.read(1024)
|
||||||
|
print(response.decode(errors='replace'))
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
asyncio.run(test_http_smuggling())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slowloris Attack Test
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def slowloris_test(host, port, connections=50):
|
||||||
|
"""Test resistance to slowloris attacks."""
|
||||||
|
async def slow_connection():
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_connection(host, port)
|
||||||
|
writer.write(b"GET / HTTP/1.1\r\nHost: target\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Send headers slowly
|
||||||
|
for i in range(100):
|
||||||
|
writer.write(f"X-Header-{i}: {'A'*100}\r\n".encode())
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
return str(e)
|
||||||
|
return "kept alive"
|
||||||
|
|
||||||
|
tasks = [slow_connection() for _ in range(connections)]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
print(f"Connections maintained: {sum(1 for r in results if r == 'kept alive')}")
|
||||||
|
|
||||||
|
asyncio.run(slowloris_test('127.0.0.1', 5000))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## curl - Quick Manual Testing
|
||||||
|
|
||||||
|
### Cheatsheet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic requests
|
||||||
|
curl -v http://target/ # Verbose GET
|
||||||
|
curl -X POST http://target/ -d "data" # POST with data
|
||||||
|
curl -X PUT http://target/id -d "update" # PUT request
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
curl -H "X-Custom: value" http://target/
|
||||||
|
curl -H "Host: evil.com" http://target/ # Host header injection
|
||||||
|
curl -H "X-Forwarded-For: 127.0.0.1" http://target/
|
||||||
|
|
||||||
|
# Response info
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" http://target/
|
||||||
|
curl -s -o /dev/null -w "%{time_total}\n" http://target/ # Timing
|
||||||
|
|
||||||
|
# Binary data
|
||||||
|
curl -X POST http://target/ --data-binary @file.bin
|
||||||
|
dd if=/dev/urandom bs=1024 count=100 | curl -X POST http://target/ --data-binary @-
|
||||||
|
|
||||||
|
# Follow redirects
|
||||||
|
curl -L http://target/
|
||||||
|
|
||||||
|
# With authentication
|
||||||
|
curl --cert client.crt --key client.key https://target/
|
||||||
|
|
||||||
|
# Parallel requests (race condition)
|
||||||
|
seq 1 100 | xargs -P50 -I{} curl -s http://target/paste-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Injection Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SQL injection
|
||||||
|
curl "http://target/1'%20OR%20'1'='1"
|
||||||
|
curl "http://target/1%3B%20DROP%20TABLE%20pastes"
|
||||||
|
|
||||||
|
# Path traversal
|
||||||
|
curl "http://target/../../../etc/passwd"
|
||||||
|
curl "http://target/..%2f..%2f..%2fetc/passwd"
|
||||||
|
|
||||||
|
# Command injection (timing)
|
||||||
|
time curl -X POST http://target/ -d "; sleep 5"
|
||||||
|
|
||||||
|
# SSTI
|
||||||
|
curl -X POST http://target/ -d "{{7*7}}"
|
||||||
|
curl -X POST http://target/ -d '${7*7}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## radamsa - Mutation Fuzzer
|
||||||
|
|
||||||
|
Generate corrupted inputs from valid samples.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitlab.com/akihe/radamsa.git
|
||||||
|
cd radamsa && make && sudo make install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate mutations from seed
|
||||||
|
echo "normal input" > seed.txt
|
||||||
|
radamsa seed.txt # Single mutation
|
||||||
|
radamsa -n 100 seed.txt > mutations.txt # 100 mutations
|
||||||
|
|
||||||
|
# Fuzz endpoint
|
||||||
|
for i in $(seq 1 1000); do
|
||||||
|
radamsa seed.txt | curl -s -X POST http://target/ \
|
||||||
|
--data-binary @- -o /dev/null -w "%{http_code}\n"
|
||||||
|
done | sort | uniq -c
|
||||||
|
|
||||||
|
# JSON mutations
|
||||||
|
echo '{"key": "value"}' > seed.json
|
||||||
|
radamsa seed.json | curl -s -X POST http://target/api \
|
||||||
|
-H "Content-Type: application/json" --data-binary @-
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## atheris - Coverage-Guided Fuzzing
|
||||||
|
|
||||||
|
LibFuzzer-based Python fuzzing with code coverage feedback.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install atheris
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fuzzing Harness
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import atheris
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with atheris.instrument_imports():
|
||||||
|
# Import after instrumentation
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def TestOneInput(data):
|
||||||
|
"""Fuzz test function called by atheris."""
|
||||||
|
fdp = atheris.FuzzedDataProvider(data)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
# Generate fuzzed request
|
||||||
|
method = fdp.PickValueInList(['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
|
path = '/' + fdp.ConsumeUnicodeNoSurrogates(100)
|
||||||
|
body = fdp.ConsumeBytes(fdp.remaining_bytes())
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == 'GET':
|
||||||
|
client.get(path)
|
||||||
|
elif method == 'POST':
|
||||||
|
client.post(path, data=body)
|
||||||
|
elif method == 'PUT':
|
||||||
|
client.put(path, data=body)
|
||||||
|
elif method == 'DELETE':
|
||||||
|
client.delete(path)
|
||||||
|
except Exception:
|
||||||
|
pass # Catch app exceptions
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
atheris.Setup(sys.argv, TestOneInput)
|
||||||
|
atheris.Fuzz()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic run
|
||||||
|
python fuzz_harness.py
|
||||||
|
|
||||||
|
# With options
|
||||||
|
python fuzz_harness.py -max_len=10000 -runs=100000 -jobs=4
|
||||||
|
|
||||||
|
# With corpus
|
||||||
|
mkdir corpus
|
||||||
|
python fuzz_harness.py corpus/ -max_len=10000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Complete Fuzzing Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Complete fuzzing workflow
|
||||||
|
|
||||||
|
export TARGET="http://127.0.0.1:5000"
|
||||||
|
export FUZZ_DIR="/tmp/flaskpaste-fuzz"
|
||||||
|
|
||||||
|
# 1. Setup
|
||||||
|
mkdir -p "$FUZZ_DIR"/{results,crashes,corpus}
|
||||||
|
source /tmp/fuzz-venv/bin/activate
|
||||||
|
|
||||||
|
# 2. Reconnaissance
|
||||||
|
echo "=== Recon ===" | tee "$FUZZ_DIR/results/recon.txt"
|
||||||
|
for path in / /health /challenge /pastes /pki /audit; do
|
||||||
|
curl -s -o /dev/null -w "%s %s\n" "$TARGET$path" "$path" | tee -a "$FUZZ_DIR/results/recon.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Injection tests
|
||||||
|
echo "=== Injection ===" | tee "$FUZZ_DIR/results/injection.txt"
|
||||||
|
python3 -c "
|
||||||
|
import requests
|
||||||
|
for payload in [\"' OR 1=1--\", \"{{7*7}}\", \"; sleep 5\"]:
|
||||||
|
r = requests.post('$TARGET/', data=payload,
|
||||||
|
headers={'X-PoW-Token':'x','X-PoW-Solution':'0'})
|
||||||
|
print(f'{r.status_code} {payload[:20]}')
|
||||||
|
" | tee -a "$FUZZ_DIR/results/injection.txt"
|
||||||
|
|
||||||
|
# 4. Hypothesis fuzzing
|
||||||
|
pytest tests/fuzz/ -v --hypothesis-show-statistics 2>&1 | tee "$FUZZ_DIR/results/hypothesis.txt"
|
||||||
|
|
||||||
|
# 5. Generate report
|
||||||
|
echo "=== Results ==="
|
||||||
|
cat "$FUZZ_DIR/results/"*.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ COMMAND CHEATSHEET
|
||||||
|
├──────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ curl -s -o /dev/null -w "%{http_code}" URL Status code only
|
||||||
|
│ curl -w "%{time_total}" URL Response timing
|
||||||
|
│ seq 1 100 | xargs -P50 curl -s URL Parallel requests
|
||||||
|
│
|
||||||
|
│ pytest test.py --hypothesis-show-statistics Run with stats
|
||||||
|
│ radamsa seed.txt | curl --data-binary @- Mutation fuzzing
|
||||||
|
│
|
||||||
|
│ python fuzz.py -runs=10000 -jobs=4 Atheris parallel
|
||||||
|
│ python fuzz.py -max_len=10000 Max input size
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
345
tests/test_fuzz.py
Normal file
345
tests/test_fuzz.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Hypothesis-based fuzzing tests for FlaskPaste API."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import string
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from hypothesis import HealthCheck, given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
# Common health check suppressions for pytest fixtures
|
||||||
|
FIXTURE_HEALTH_CHECKS = [HealthCheck.function_scoped_fixture, HealthCheck.too_slow]
|
||||||
|
|
||||||
|
# Strategies for generating test data
|
||||||
|
PRINTABLE = string.printable
|
||||||
|
HEX_CHARS = string.hexdigits.lower()
|
||||||
|
|
||||||
|
# Custom strategies
|
||||||
|
paste_content = st.binary(min_size=0, max_size=50000)
|
||||||
|
text_content = st.text(min_size=0, max_size=10000, alphabet=st.characters())
|
||||||
|
unicode_content = st.text(
|
||||||
|
min_size=1,
|
||||||
|
max_size=5000,
|
||||||
|
alphabet=st.characters(
|
||||||
|
whitelist_categories=("L", "N", "P", "S", "Z"),
|
||||||
|
blacklist_characters="\x00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
paste_id = st.text(min_size=1, max_size=50, alphabet=HEX_CHARS + "/<>\"'%;()&+-_.")
|
||||||
|
header_value = st.text(
|
||||||
|
min_size=0, max_size=500, alphabet=PRINTABLE.replace("\r", "").replace("\n", "")
|
||||||
|
)
|
||||||
|
json_primitive = st.one_of(
|
||||||
|
st.text(), st.integers(), st.floats(allow_nan=False), st.none(), st.booleans()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestContentFuzzing:
|
||||||
|
"""Fuzz paste content handling."""
|
||||||
|
|
||||||
|
@settings(max_examples=200, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(content=paste_content)
|
||||||
|
def test_binary_content_no_crash(self, client, content):
|
||||||
|
"""Binary content should never crash the server."""
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=content,
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
assert response.status_code in (201, 400, 413, 429, 503)
|
||||||
|
|
||||||
|
@settings(max_examples=200, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(content=text_content)
|
||||||
|
def test_text_content_no_crash(self, client, content):
|
||||||
|
"""Text content should never crash the server."""
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=content.encode("utf-8", errors="replace"),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
assert response.status_code in (201, 400, 413, 429, 503)
|
||||||
|
|
||||||
|
@settings(max_examples=100, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(content=unicode_content)
|
||||||
|
def test_unicode_roundtrip(self, client, content):
|
||||||
|
"""Unicode content should survive storage and retrieval."""
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=content.encode("utf-8"),
|
||||||
|
content_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
data = json.loads(response.data)
|
||||||
|
paste_id = data["id"]
|
||||||
|
raw = client.get(f"/{paste_id}/raw")
|
||||||
|
assert raw.status_code == 200
|
||||||
|
# Content should match (may have charset normalization)
|
||||||
|
assert raw.data.decode("utf-8") == content
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasteIdFuzzing:
|
||||||
|
"""Fuzz paste ID handling."""
|
||||||
|
|
||||||
|
@settings(max_examples=300, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(paste_id=paste_id)
|
||||||
|
def test_malformed_paste_id(self, client, paste_id):
|
||||||
|
"""Malformed paste IDs should return 400 or 404, never crash."""
|
||||||
|
try:
|
||||||
|
response = client.get(f"/{paste_id}")
|
||||||
|
except UnicodeError:
|
||||||
|
# Some malformed paths cause IDNA encoding issues in URL normalization
|
||||||
|
# This is expected behavior for invalid paths
|
||||||
|
return
|
||||||
|
# Should never return 500
|
||||||
|
assert response.status_code != 500
|
||||||
|
# Valid responses: 200 (found), 400 (invalid), 404 (not found), 308 (redirect)
|
||||||
|
assert response.status_code in (200, 400, 404, 308)
|
||||||
|
|
||||||
|
@settings(max_examples=200, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(paste_id=st.text(min_size=1, max_size=100, alphabet="/<>\"'%;()&+\\"))
|
||||||
|
def test_special_chars_in_id(self, client, paste_id):
|
||||||
|
"""Special characters in paste ID should be handled safely."""
|
||||||
|
response = client.get(f"/{paste_id}")
|
||||||
|
assert response.status_code in (200, 400, 404, 405, 308)
|
||||||
|
# Verify response is valid JSON or HTML
|
||||||
|
if response.content_type and "json" in response.content_type:
|
||||||
|
try:
|
||||||
|
json.loads(response.data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pytest.fail("Invalid JSON response")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeaderFuzzing:
|
||||||
|
"""Fuzz HTTP header handling."""
|
||||||
|
|
||||||
|
@settings(max_examples=150, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(sha1=header_value)
|
||||||
|
def test_auth_header_fuzz(self, client, sha1):
|
||||||
|
"""Arbitrary X-SSL-Client-SHA1 values should not crash."""
|
||||||
|
response = client.get("/", headers={"X-SSL-Client-SHA1": sha1})
|
||||||
|
assert response.status_code in (200, 400, 401, 403)
|
||||||
|
|
||||||
|
@settings(max_examples=150, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(secret=header_value)
|
||||||
|
def test_proxy_secret_fuzz(self, client, secret):
|
||||||
|
"""Arbitrary X-Proxy-Secret values should not crash."""
|
||||||
|
response = client.get("/", headers={"X-Proxy-Secret": secret})
|
||||||
|
assert response.status_code in (200, 400, 401, 403)
|
||||||
|
|
||||||
|
@settings(max_examples=100, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(xff=st.lists(st.ip_addresses(), min_size=1, max_size=10))
|
||||||
|
def test_xff_chain_fuzz(self, client, xff):
|
||||||
|
"""X-Forwarded-For chains should be handled safely."""
|
||||||
|
xff_header = ", ".join(str(ip) for ip in xff)
|
||||||
|
response = client.get("/", headers={"X-Forwarded-For": xff_header})
|
||||||
|
assert response.status_code in (200, 400, 429)
|
||||||
|
|
||||||
|
@settings(max_examples=100, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(request_id=header_value)
|
||||||
|
def test_request_id_fuzz(self, client, request_id):
|
||||||
|
"""Arbitrary X-Request-ID values should be echoed safely."""
|
||||||
|
response = client.get("/", headers={"X-Request-ID": request_id})
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Should echo back or generate new one
|
||||||
|
assert response.headers.get("X-Request-ID") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMimeTypeFuzzing:
|
||||||
|
"""Fuzz MIME type handling."""
|
||||||
|
|
||||||
|
@settings(max_examples=150, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(
|
||||||
|
mime=st.text(
|
||||||
|
min_size=1, max_size=200, alphabet=PRINTABLE.replace("\r", "").replace("\n", "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def test_arbitrary_content_type(self, client, mime):
|
||||||
|
"""Arbitrary Content-Type headers should not crash."""
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=b"test content",
|
||||||
|
content_type=mime,
|
||||||
|
)
|
||||||
|
assert response.status_code in (201, 400, 413, 429, 503)
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
# Some strings can't be encoded as headers - that's fine
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonFuzzing:
|
||||||
|
"""Fuzz JSON endpoint handling."""
|
||||||
|
|
||||||
|
@settings(max_examples=100, suppress_health_check=FIXTURE_HEALTH_CHECKS, deadline=None)
|
||||||
|
@given(
|
||||||
|
data=st.dictionaries(
|
||||||
|
st.text(min_size=1, max_size=50),
|
||||||
|
json_primitive,
|
||||||
|
min_size=0,
|
||||||
|
max_size=20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def test_pki_ca_json_fuzz(self, client, data):
|
||||||
|
"""PKI CA endpoint should handle arbitrary JSON."""
|
||||||
|
response = client.post(
|
||||||
|
"/pki/ca",
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
# Should return structured response, not crash
|
||||||
|
# 201 = CA created, 200 = already exists, 400 = bad request, 404 = PKI disabled
|
||||||
|
assert response.status_code in (200, 201, 400, 401, 403, 404, 409)
|
||||||
|
assert response.status_code != 500
|
||||||
|
|
||||||
|
@settings(max_examples=100, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(
|
||||||
|
data=st.recursive(
|
||||||
|
json_primitive,
|
||||||
|
lambda children: st.lists(children, max_size=5)
|
||||||
|
| st.dictionaries(st.text(max_size=10), children, max_size=5),
|
||||||
|
max_leaves=20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def test_nested_json_fuzz(self, client, data):
|
||||||
|
"""Deeply nested JSON should not cause stack overflow."""
|
||||||
|
try:
|
||||||
|
payload = json.dumps({"data": data})
|
||||||
|
except (ValueError, RecursionError):
|
||||||
|
return # Can't serialize - skip
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/pki/issue",
|
||||||
|
data=payload,
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code != 500 or "error" in response.data.decode()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSizeLimitFuzzing:
|
||||||
|
"""Fuzz size limit enforcement."""
|
||||||
|
|
||||||
|
@settings(
|
||||||
|
max_examples=50,
|
||||||
|
suppress_health_check=[*FIXTURE_HEALTH_CHECKS, HealthCheck.data_too_large],
|
||||||
|
)
|
||||||
|
@given(size=st.integers(min_value=0, max_value=2_000_000))
|
||||||
|
def test_size_boundary(self, client, size):
|
||||||
|
"""Size limits should be enforced consistently."""
|
||||||
|
# Generate content of specific size (capped to avoid memory issues)
|
||||||
|
actual_size = min(size, 500_000)
|
||||||
|
content = b"x" * actual_size
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=content,
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
# Should reject if too large, accept if within limits
|
||||||
|
assert response.status_code in (201, 400, 413, 429, 503)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrencyFuzzing:
|
||||||
|
"""Fuzz concurrent access patterns."""
|
||||||
|
|
||||||
|
@settings(max_examples=20, suppress_health_check=FIXTURE_HEALTH_CHECKS, deadline=None)
|
||||||
|
@given(content=st.binary(min_size=10, max_size=1000))
|
||||||
|
def test_rapid_create_delete(self, client, content, auth_header):
|
||||||
|
"""Rapid create/delete should not corrupt state."""
|
||||||
|
# Create
|
||||||
|
create = client.post(
|
||||||
|
"/",
|
||||||
|
data=content,
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
headers=auth_header,
|
||||||
|
)
|
||||||
|
if create.status_code != 201:
|
||||||
|
return # Rate limited or other - skip
|
||||||
|
|
||||||
|
data = json.loads(create.data)
|
||||||
|
paste_id = data["id"]
|
||||||
|
|
||||||
|
# Immediate delete
|
||||||
|
delete = client.delete(f"/{paste_id}", headers=auth_header)
|
||||||
|
assert delete.status_code in (200, 404)
|
||||||
|
|
||||||
|
# Verify gone
|
||||||
|
get = client.get(f"/{paste_id}")
|
||||||
|
assert get.status_code in (404, 410)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInjectionFuzzing:
|
||||||
|
"""Fuzz for injection vulnerabilities."""
|
||||||
|
|
||||||
|
INJECTION_PAYLOADS: ClassVar[list[str]] = [
|
||||||
|
"' OR '1'='1",
|
||||||
|
"'; DROP TABLE pastes; --",
|
||||||
|
"{{7*7}}",
|
||||||
|
"${7*7}",
|
||||||
|
"<%=7*7%>",
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"{{constructor.constructor('return this')()}}",
|
||||||
|
"../../../etc/passwd",
|
||||||
|
"..\\..\\..\\windows\\system32\\config\\sam",
|
||||||
|
"\x00",
|
||||||
|
"\r\nX-Injected: header",
|
||||||
|
"$(whoami)",
|
||||||
|
"`whoami`",
|
||||||
|
"; sleep 5",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_sql_injection_in_content(self, client):
|
||||||
|
"""SQL injection payloads in content should be stored literally."""
|
||||||
|
for payload in self.INJECTION_PAYLOADS:
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=payload.encode("utf-8", errors="replace"),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
data = json.loads(response.data)
|
||||||
|
paste_id = data["id"]
|
||||||
|
raw = client.get(f"/{paste_id}/raw")
|
||||||
|
# Content should be stored literally, not executed
|
||||||
|
assert raw.data.decode("utf-8", errors="replace") == payload
|
||||||
|
|
||||||
|
def test_ssti_in_content(self, client):
|
||||||
|
"""SSTI payloads should not be evaluated."""
|
||||||
|
for payload in ["{{7*7}}", "${7*7}", "<%=7*7%>"]:
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=payload.encode(),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
data = json.loads(response.data)
|
||||||
|
paste_id = data["id"]
|
||||||
|
raw = client.get(f"/{paste_id}/raw")
|
||||||
|
# Should NOT contain "49" (7*7 evaluated)
|
||||||
|
assert raw.data == payload.encode()
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandlingFuzzing:
|
||||||
|
"""Fuzz error handling paths."""
|
||||||
|
|
||||||
|
@settings(max_examples=50, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(method=st.sampled_from(["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]))
|
||||||
|
def test_method_handling(self, client, method):
|
||||||
|
"""All HTTP methods should be handled without crash."""
|
||||||
|
response = client.open("/", method=method)
|
||||||
|
# Should never return 500
|
||||||
|
assert response.status_code != 500
|
||||||
|
assert response.status_code in (200, 201, 204, 400, 404, 405, 413, 429)
|
||||||
|
|
||||||
|
@settings(max_examples=50, suppress_health_check=FIXTURE_HEALTH_CHECKS)
|
||||||
|
@given(
|
||||||
|
path=st.text(
|
||||||
|
min_size=1, max_size=500, alphabet=string.ascii_letters + string.digits + "/-_."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
def test_path_handling(self, client, path):
|
||||||
|
"""Arbitrary paths should not crash."""
|
||||||
|
response = client.get(f"/{path}")
|
||||||
|
# Should never return 500
|
||||||
|
assert response.status_code != 500
|
||||||
|
assert response.status_code in (200, 400, 404, 405, 308)
|
||||||
Reference in New Issue
Block a user