From debdc8478e558f946d95272c7c7244350da998ea Mon Sep 17 00:00:00 2001 From: Username Date: Thu, 25 Dec 2025 19:20:16 +0100 Subject: [PATCH] add hypothesis-based fuzzing test suite 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 --- documentation/fuzzing-tools.md | 485 +++++++++++++++++++++++++++++++++ tests/test_fuzz.py | 345 +++++++++++++++++++++++ 2 files changed, 830 insertions(+) create mode 100644 documentation/fuzzing-tools.md create mode 100644 tests/test_fuzz.py diff --git a/documentation/fuzzing-tools.md b/documentation/fuzzing-tools.md new file mode 100644 index 0000000..9b1f401 --- /dev/null +++ b/documentation/fuzzing-tools.md @@ -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 +└──────────────────────────────────────────────────────────────────────────────┘ +``` diff --git a/tests/test_fuzz.py b/tests/test_fuzz.py new file mode 100644 index 0000000..3828261 --- /dev/null +++ b/tests/test_fuzz.py @@ -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%>", + "", + "{{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)