forked from claw/flaskpaste
flaskpaste: initial commit with security hardening
Features: - REST API for text/binary pastes with MIME detection - Client certificate auth via X-SSL-Client-SHA1 header - SQLite with WAL mode for concurrent access - Automatic paste expiry with LRU cleanup Security: - HSTS, CSP, X-Frame-Options, X-Content-Type-Options - Cache-Control: no-store for sensitive responses - X-Request-ID tracing for log correlation - X-Proxy-Secret validation for defense-in-depth - Parameterized queries, input validation - Size limits (3 MiB anon, 50 MiB auth) Includes /health endpoint, container support, and 70 tests.
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# FlaskPaste test suite
|
||||
77
tests/conftest.py
Normal file
77
tests/conftest.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Pytest fixtures for FlaskPaste tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
app = create_app("testing")
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Create CLI runner."""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_text():
|
||||
"""Sample text content for testing."""
|
||||
return "Hello, FlaskPaste!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_json():
|
||||
"""Sample JSON payload for testing."""
|
||||
return {"content": "Hello from JSON!"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_header():
|
||||
"""Valid authentication header."""
|
||||
return {"X-SSL-Client-SHA1": "a" * 40}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_auth_header():
|
||||
"""Different valid authentication header."""
|
||||
return {"X-SSL-Client-SHA1": "b" * 40}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def png_bytes():
|
||||
"""Minimal valid PNG bytes for testing."""
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
|
||||
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00"
|
||||
b"\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00"
|
||||
b"\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_bytes():
|
||||
"""Minimal JPEG magic bytes for testing."""
|
||||
return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zip_bytes():
|
||||
"""ZIP magic bytes for testing."""
|
||||
return b"PK\x03\x04" + b"\x00" * 26
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_bytes():
|
||||
"""PDF magic bytes for testing."""
|
||||
return b"%PDF-1.4 test content"
|
||||
263
tests/test_api.py
Normal file
263
tests/test_api.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Tests for FlaskPaste API endpoints."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestIndex:
|
||||
"""Tests for GET / endpoint."""
|
||||
|
||||
def test_get_api_info(self, client):
|
||||
"""GET / returns API information."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["name"] == "FlaskPaste"
|
||||
assert data["version"] == "1.0.0"
|
||||
assert "endpoints" in data
|
||||
assert "usage" in data
|
||||
|
||||
def test_api_info_contains_endpoints(self, client):
|
||||
"""API info lists all endpoints."""
|
||||
response = client.get("/")
|
||||
data = json.loads(response.data)
|
||||
endpoints = data["endpoints"]
|
||||
assert "GET /" in endpoints
|
||||
assert "POST /" in endpoints
|
||||
assert "GET /<id>" in endpoints
|
||||
assert "GET /<id>/raw" in endpoints
|
||||
assert "DELETE /<id>" in endpoints
|
||||
|
||||
|
||||
class TestHealth:
|
||||
"""Tests for GET /health endpoint."""
|
||||
|
||||
def test_health_endpoint_returns_ok(self, client):
|
||||
"""Health endpoint returns healthy status."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "healthy"
|
||||
assert data["database"] == "ok"
|
||||
|
||||
def test_health_endpoint_json_response(self, client):
|
||||
"""Health endpoint returns JSON content type."""
|
||||
response = client.get("/health")
|
||||
assert "application/json" in response.content_type
|
||||
|
||||
|
||||
class TestCreatePaste:
|
||||
"""Tests for POST / endpoint."""
|
||||
|
||||
def test_create_paste_raw(self, client, sample_text):
|
||||
"""Create paste with raw text body."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert "id" in data
|
||||
assert len(data["id"]) == 12
|
||||
assert data["mime_type"] == "text/plain"
|
||||
assert "url" in data
|
||||
assert "raw" in data
|
||||
|
||||
def test_create_paste_json(self, client, sample_json):
|
||||
"""Create paste with JSON body."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=json.dumps(sample_json),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert "id" in data
|
||||
assert data["mime_type"] == "text/plain"
|
||||
|
||||
def test_create_paste_binary(self, client, png_bytes):
|
||||
"""Create paste with binary content detects MIME type."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=png_bytes,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/png"
|
||||
|
||||
def test_create_paste_empty_fails(self, client):
|
||||
"""Create paste with empty content fails."""
|
||||
response = client.post("/", data="")
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_create_paste_with_auth(self, client, sample_text, auth_header):
|
||||
"""Create paste with authentication includes owner."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert "owner" in data
|
||||
assert data["owner"] == "a" * 40
|
||||
|
||||
def test_create_paste_invalid_auth_ignored(self, client, sample_text):
|
||||
"""Invalid auth header is ignored (treated as anonymous)."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={"X-SSL-Client-SHA1": "invalid"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert "owner" not in data
|
||||
|
||||
|
||||
class TestGetPaste:
|
||||
"""Tests for GET /<id> endpoint."""
|
||||
|
||||
def test_get_paste_metadata(self, client, sample_text):
|
||||
"""Get paste returns metadata."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.get(f"/{paste_id}")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["id"] == paste_id
|
||||
assert data["mime_type"] == "text/plain"
|
||||
assert data["size"] == len(sample_text)
|
||||
assert "created_at" in data
|
||||
assert "raw" in data
|
||||
|
||||
def test_get_paste_not_found(self, client):
|
||||
"""Get nonexistent paste returns 404."""
|
||||
response = client.get("/abcd12345678")
|
||||
assert response.status_code == 404
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_get_paste_invalid_id(self, client):
|
||||
"""Get paste with invalid ID returns 400."""
|
||||
response = client.get("/invalid!")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_get_paste_wrong_length_id(self, client):
|
||||
"""Get paste with wrong length ID returns 400."""
|
||||
response = client.get("/abc")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestGetPasteRaw:
|
||||
"""Tests for GET /<id>/raw endpoint."""
|
||||
|
||||
def test_get_paste_raw_text(self, client, sample_text):
|
||||
"""Get raw paste returns content with correct MIME type."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.get(f"/{paste_id}/raw")
|
||||
assert response.status_code == 200
|
||||
assert response.data.decode("utf-8") == sample_text
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
def test_get_paste_raw_binary(self, client, png_bytes):
|
||||
"""Get raw binary paste returns correct content."""
|
||||
create = client.post(
|
||||
"/",
|
||||
data=png_bytes,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.get(f"/{paste_id}/raw")
|
||||
assert response.status_code == 200
|
||||
assert response.data == png_bytes
|
||||
assert response.content_type == "image/png"
|
||||
|
||||
def test_get_paste_raw_not_found(self, client):
|
||||
"""Get raw nonexistent paste returns 404."""
|
||||
response = client.get("/abcd12345678/raw")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_paste_raw_inline_disposition(self, client, sample_text):
|
||||
"""Text and image pastes have inline disposition."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.get(f"/{paste_id}/raw")
|
||||
assert response.headers.get("Content-Disposition") == "inline"
|
||||
|
||||
|
||||
class TestDeletePaste:
|
||||
"""Tests for DELETE /<id> endpoint."""
|
||||
|
||||
def test_delete_paste_success(self, client, sample_text, auth_header):
|
||||
"""Delete owned paste succeeds."""
|
||||
create = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=auth_header)
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "message" in data
|
||||
|
||||
# Verify deletion
|
||||
get_response = client.get(f"/{paste_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_paste_no_auth(self, client, sample_text):
|
||||
"""Delete without authentication fails."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}")
|
||||
assert response.status_code == 401
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_delete_paste_wrong_owner(
|
||||
self, client, sample_text, auth_header, other_auth_header
|
||||
):
|
||||
"""Delete paste owned by another user fails."""
|
||||
create = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=other_auth_header)
|
||||
assert response.status_code == 403
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_delete_anonymous_paste_fails(self, client, sample_text, auth_header):
|
||||
"""Cannot delete anonymous paste (no owner)."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=auth_header)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_delete_paste_not_found(self, client, auth_header):
|
||||
"""Delete nonexistent paste returns 404."""
|
||||
response = client.delete("/abcd12345678", headers=auth_header)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_paste_invalid_id(self, client, auth_header):
|
||||
"""Delete with invalid ID returns 400."""
|
||||
response = client.delete("/invalid!", headers=auth_header)
|
||||
assert response.status_code == 400
|
||||
90
tests/test_database.py
Normal file
90
tests/test_database.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests for database operations."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestDatabaseOperations:
|
||||
"""Tests for database functionality."""
|
||||
|
||||
def test_paste_persists(self, client, sample_text):
|
||||
"""Paste persists in database and can be retrieved."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.get(f"/{paste_id}/raw")
|
||||
assert response.data.decode("utf-8") == sample_text
|
||||
|
||||
def test_multiple_pastes_independent(self, client):
|
||||
"""Multiple pastes have unique IDs and content."""
|
||||
create1 = client.post("/", data="paste one", content_type="text/plain")
|
||||
create2 = client.post("/", data="paste two", content_type="text/plain")
|
||||
|
||||
id1 = json.loads(create1.data)["id"]
|
||||
id2 = json.loads(create2.data)["id"]
|
||||
|
||||
assert id1 != id2
|
||||
|
||||
raw1 = client.get(f"/{id1}/raw")
|
||||
raw2 = client.get(f"/{id2}/raw")
|
||||
|
||||
assert raw1.data.decode("utf-8") == "paste one"
|
||||
assert raw2.data.decode("utf-8") == "paste two"
|
||||
|
||||
def test_last_accessed_updated_on_get(self, client, sample_text):
|
||||
"""Last accessed timestamp updates on retrieval."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
# Get the paste twice with a small delay
|
||||
client.get(f"/{paste_id}")
|
||||
first_access = time.time()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
client.get(f"/{paste_id}")
|
||||
second_access = time.time()
|
||||
|
||||
# Timestamps should be close to current time (within 2 seconds)
|
||||
assert second_access > first_access
|
||||
|
||||
|
||||
class TestCleanupExpiredPastes:
|
||||
"""Tests for paste expiry and cleanup."""
|
||||
|
||||
def test_expired_paste_cleaned_up(self, app, client, sample_text):
|
||||
"""Expired pastes are removed by cleanup."""
|
||||
from app.database import cleanup_expired_pastes
|
||||
|
||||
# Create a paste
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
# Verify it exists
|
||||
assert client.get(f"/{paste_id}").status_code == 200
|
||||
|
||||
# Mock time to simulate expiry (paste expiry + 1 second)
|
||||
future_time = time.time() + app.config["PASTE_EXPIRY_SECONDS"] + 1
|
||||
|
||||
with patch("time.time", return_value=future_time):
|
||||
with app.app_context():
|
||||
deleted = cleanup_expired_pastes()
|
||||
|
||||
assert deleted >= 1
|
||||
|
||||
# Paste should now be gone
|
||||
assert client.get(f"/{paste_id}").status_code == 404
|
||||
|
||||
def test_non_expired_paste_kept(self, app, client, sample_text):
|
||||
"""Non-expired pastes are preserved by cleanup."""
|
||||
from app.database import cleanup_expired_pastes
|
||||
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
with app.app_context():
|
||||
deleted = cleanup_expired_pastes()
|
||||
|
||||
assert deleted == 0
|
||||
assert client.get(f"/{paste_id}").status_code == 200
|
||||
95
tests/test_mime_detection.py
Normal file
95
tests/test_mime_detection.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for MIME type detection."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestMimeDetection:
|
||||
"""Tests for automatic MIME type detection."""
|
||||
|
||||
def test_detect_png(self, client, png_bytes):
|
||||
"""Detect PNG from magic bytes."""
|
||||
response = client.post("/", data=png_bytes)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/png"
|
||||
|
||||
def test_detect_jpeg(self, client, jpeg_bytes):
|
||||
"""Detect JPEG from magic bytes."""
|
||||
response = client.post("/", data=jpeg_bytes)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/jpeg"
|
||||
|
||||
def test_detect_zip(self, client, zip_bytes):
|
||||
"""Detect ZIP from magic bytes."""
|
||||
response = client.post("/", data=zip_bytes)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "application/zip"
|
||||
|
||||
def test_detect_pdf(self, client, pdf_bytes):
|
||||
"""Detect PDF from magic bytes."""
|
||||
response = client.post("/", data=pdf_bytes)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "application/pdf"
|
||||
|
||||
def test_detect_gif87a(self, client):
|
||||
"""Detect GIF87a from magic bytes."""
|
||||
response = client.post("/", data=b"GIF87a" + b"\x00" * 10)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/gif"
|
||||
|
||||
def test_detect_gif89a(self, client):
|
||||
"""Detect GIF89a from magic bytes."""
|
||||
response = client.post("/", data=b"GIF89a" + b"\x00" * 10)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/gif"
|
||||
|
||||
def test_detect_gzip(self, client):
|
||||
"""Detect GZIP from magic bytes."""
|
||||
response = client.post("/", data=b"\x1f\x8b\x08" + b"\x00" * 10)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "application/gzip"
|
||||
|
||||
def test_detect_utf8_text(self, client):
|
||||
"""UTF-8 text defaults to text/plain."""
|
||||
response = client.post("/", data="Hello, world! 你好")
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "text/plain"
|
||||
|
||||
def test_detect_binary_fallback(self, client):
|
||||
"""Non-UTF8 binary without magic falls back to octet-stream."""
|
||||
response = client.post("/", data=b"\x80\x81\x82\x83\x84")
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "application/octet-stream"
|
||||
|
||||
def test_explicit_content_type_honored(self, client):
|
||||
"""Explicit Content-Type is honored for non-generic types."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data="<html><body>test</body></html>",
|
||||
content_type="text/html",
|
||||
)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "text/html"
|
||||
|
||||
def test_generic_content_type_overridden(self, client, png_bytes):
|
||||
"""Generic Content-Type is overridden by magic detection."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=png_bytes,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/png"
|
||||
|
||||
def test_webp_detection(self, client):
|
||||
"""Detect WebP from RIFF...WEBP magic."""
|
||||
webp_header = b"RIFF\x00\x00\x00\x00WEBP"
|
||||
response = client.post("/", data=webp_header + b"\x00" * 20)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] == "image/webp"
|
||||
|
||||
def test_riff_non_webp_not_detected(self, client):
|
||||
"""RIFF without WEBP marker is not detected as WebP."""
|
||||
riff_other = b"RIFF\x00\x00\x00\x00WAVE"
|
||||
response = client.post("/", data=riff_other + b"\x00" * 20)
|
||||
data = json.loads(response.data)
|
||||
assert data["mime_type"] != "image/webp"
|
||||
325
tests/test_security.py
Normal file
325
tests/test_security.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Security-focused tests for FlaskPaste."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestSecurityHeaders:
|
||||
"""Tests for security headers."""
|
||||
|
||||
def test_x_content_type_options(self, client):
|
||||
"""X-Content-Type-Options header is set."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
def test_x_frame_options(self, client):
|
||||
"""X-Frame-Options header is set."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("X-Frame-Options") == "DENY"
|
||||
|
||||
def test_x_xss_protection(self, client):
|
||||
"""X-XSS-Protection header is set."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("X-XSS-Protection") == "1; mode=block"
|
||||
|
||||
def test_content_security_policy(self, client):
|
||||
"""Content-Security-Policy header is set."""
|
||||
response = client.get("/")
|
||||
csp = response.headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "default-src 'none'" in csp
|
||||
|
||||
def test_referrer_policy(self, client):
|
||||
"""Referrer-Policy header is set."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
|
||||
def test_permissions_policy(self, client):
|
||||
"""Permissions-Policy header is set."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("Permissions-Policy") is not None
|
||||
|
||||
def test_security_headers_on_error_responses(self, client):
|
||||
"""Security headers are present on error responses."""
|
||||
response = client.get("/nonexist1234") # 12 chars, valid format but not found
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("X-Frame-Options") == "DENY"
|
||||
|
||||
def test_hsts_header(self, client):
|
||||
"""Strict-Transport-Security header is set."""
|
||||
response = client.get("/")
|
||||
hsts = response.headers.get("Strict-Transport-Security")
|
||||
assert hsts is not None
|
||||
assert "max-age=" in hsts
|
||||
assert "includeSubDomains" in hsts
|
||||
|
||||
def test_cache_control_header(self, client):
|
||||
"""Cache-Control header prevents caching of sensitive data."""
|
||||
response = client.get("/")
|
||||
cache = response.headers.get("Cache-Control")
|
||||
assert cache is not None
|
||||
assert "no-store" in cache
|
||||
assert "private" in cache
|
||||
|
||||
def test_pragma_header(self, client):
|
||||
"""Pragma header set for HTTP/1.0 compatibility."""
|
||||
response = client.get("/")
|
||||
assert response.headers.get("Pragma") == "no-cache"
|
||||
|
||||
|
||||
class TestRequestIdTracking:
|
||||
"""Tests for X-Request-ID tracking."""
|
||||
|
||||
def test_request_id_generated(self, client):
|
||||
"""Request ID is generated when not provided."""
|
||||
response = client.get("/")
|
||||
request_id = response.headers.get("X-Request-ID")
|
||||
assert request_id is not None
|
||||
# Should be a valid UUID format
|
||||
assert len(request_id) == 36
|
||||
assert request_id.count("-") == 4
|
||||
|
||||
def test_request_id_passed_through(self, client):
|
||||
"""Request ID from client is echoed back."""
|
||||
custom_id = "my-custom-request-id-12345"
|
||||
response = client.get("/", headers={"X-Request-ID": custom_id})
|
||||
assert response.headers.get("X-Request-ID") == custom_id
|
||||
|
||||
def test_request_id_on_error_responses(self, client):
|
||||
"""Request ID is present on error responses."""
|
||||
custom_id = "error-request-id-67890"
|
||||
response = client.get("/nonexist1234", headers={"X-Request-ID": custom_id})
|
||||
assert response.headers.get("X-Request-ID") == custom_id
|
||||
|
||||
|
||||
class TestProxyTrustValidation:
|
||||
"""Tests for reverse proxy trust validation."""
|
||||
|
||||
def test_auth_works_without_proxy_secret_configured(self, client, sample_text, auth_header):
|
||||
"""Auth header trusted when no proxy secret is configured (default)."""
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
import json
|
||||
data = json.loads(response.data)
|
||||
assert "owner" in data
|
||||
|
||||
def test_auth_ignored_with_wrong_proxy_secret(self, app, client, sample_text, auth_header):
|
||||
"""Auth header ignored when proxy secret doesn't match."""
|
||||
# Configure a proxy secret
|
||||
app.config["TRUSTED_PROXY_SECRET"] = "correct-secret-value"
|
||||
|
||||
try:
|
||||
# Request with wrong secret - auth should be ignored
|
||||
headers = dict(auth_header)
|
||||
headers["X-Proxy-Secret"] = "wrong-secret"
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
import json
|
||||
data = json.loads(response.data)
|
||||
# Owner should NOT be set because proxy wasn't trusted
|
||||
assert "owner" not in data
|
||||
finally:
|
||||
# Reset config
|
||||
app.config["TRUSTED_PROXY_SECRET"] = ""
|
||||
|
||||
def test_auth_works_with_correct_proxy_secret(self, app, client, sample_text, auth_header):
|
||||
"""Auth header trusted when proxy secret matches."""
|
||||
# Configure a proxy secret
|
||||
app.config["TRUSTED_PROXY_SECRET"] = "correct-secret-value"
|
||||
|
||||
try:
|
||||
# Request with correct secret - auth should work
|
||||
headers = dict(auth_header)
|
||||
headers["X-Proxy-Secret"] = "correct-secret-value"
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
import json
|
||||
data = json.loads(response.data)
|
||||
assert "owner" in data
|
||||
finally:
|
||||
# Reset config
|
||||
app.config["TRUSTED_PROXY_SECRET"] = ""
|
||||
|
||||
|
||||
class TestInputValidation:
|
||||
"""Tests for input validation and sanitization."""
|
||||
|
||||
def test_paste_id_hex_only(self, client):
|
||||
"""Paste IDs must be hexadecimal."""
|
||||
# IDs that match the route pattern but fail validation (12 chars)
|
||||
invalid_ids = [
|
||||
"ABCD12345678", # uppercase
|
||||
"abcd-2345678", # dash
|
||||
"abcd_2345678", # underscore
|
||||
"abcd<>345678", # angle brackets
|
||||
]
|
||||
for invalid_id in invalid_ids:
|
||||
response = client.get(f"/{invalid_id}")
|
||||
assert response.status_code == 400, f"Expected 400 for ID: {invalid_id}"
|
||||
|
||||
# IDs with slashes are routed differently (404 from Flask routing)
|
||||
slash_ids = ["abcd/2345678", "../abcd12345"]
|
||||
for invalid_id in slash_ids:
|
||||
response = client.get(f"/{invalid_id}")
|
||||
assert response.status_code in (400, 404), f"Unexpected for ID: {invalid_id}"
|
||||
|
||||
def test_paste_id_length_enforced(self, client):
|
||||
"""Paste IDs must be exactly the configured length (12 chars)."""
|
||||
too_short = "abcd1234" # 8 chars
|
||||
too_long = "abcdef1234567890abcd" # 20 chars
|
||||
|
||||
assert client.get(f"/{too_short}").status_code == 400
|
||||
assert client.get(f"/{too_long}").status_code == 400
|
||||
|
||||
def test_auth_header_format_validated(self, client, sample_text):
|
||||
"""Auth header must be valid SHA1 format."""
|
||||
invalid_headers = [
|
||||
"a" * 39, # too short
|
||||
"a" * 41, # too long
|
||||
"g" * 40, # invalid hex
|
||||
"A" * 40, # uppercase (should be lowercased internally)
|
||||
"a-a" * 13 + "a", # dashes
|
||||
]
|
||||
|
||||
for invalid in invalid_headers:
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={"X-SSL-Client-SHA1": invalid},
|
||||
)
|
||||
# Invalid auth is ignored, not rejected (treated as anonymous)
|
||||
data = json.loads(response.data)
|
||||
assert "owner" not in data or data.get("owner") != invalid
|
||||
|
||||
def test_mime_type_sanitized(self, client):
|
||||
"""MIME types are sanitized to prevent injection."""
|
||||
# Flask/Werkzeug rejects newlines in headers at the framework level
|
||||
# Test that MIME type parameters are stripped to base type
|
||||
response = client.post(
|
||||
"/",
|
||||
data="test content",
|
||||
content_type="text/plain; charset=utf-8; boundary=something",
|
||||
)
|
||||
data = json.loads(response.data)
|
||||
# Should be sanitized to just the base MIME type
|
||||
assert data["mime_type"] == "text/plain"
|
||||
assert "charset" not in data["mime_type"]
|
||||
assert "boundary" not in data["mime_type"]
|
||||
|
||||
|
||||
class TestSizeLimits:
|
||||
"""Tests for paste size limits."""
|
||||
|
||||
def test_anonymous_size_limit(self, app, client):
|
||||
"""Anonymous pastes are limited in size."""
|
||||
max_size = app.config["MAX_PASTE_SIZE_ANON"]
|
||||
oversized = "x" * (max_size + 1)
|
||||
|
||||
response = client.post("/", data=oversized, content_type="text/plain")
|
||||
assert response.status_code == 413
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
assert data["authenticated"] is False
|
||||
|
||||
def test_authenticated_larger_limit(self, app, client, auth_header):
|
||||
"""Authenticated users have larger size limit."""
|
||||
anon_max = app.config["MAX_PASTE_SIZE_ANON"]
|
||||
auth_max = app.config["MAX_PASTE_SIZE_AUTH"]
|
||||
|
||||
# Size that exceeds anon but within auth limit
|
||||
# (only test if auth limit is larger)
|
||||
if auth_max > anon_max:
|
||||
size = anon_max + 100
|
||||
content = "x" * size
|
||||
|
||||
response = client.post(
|
||||
"/",
|
||||
data=content,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestOwnershipEnforcement:
|
||||
"""Tests for paste ownership and access control."""
|
||||
|
||||
def test_cannot_delete_others_paste(
|
||||
self, client, sample_text, auth_header, other_auth_header
|
||||
):
|
||||
"""Users cannot delete pastes they don't own."""
|
||||
create = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=other_auth_header)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Paste should still exist
|
||||
assert client.get(f"/{paste_id}").status_code == 200
|
||||
|
||||
def test_anonymous_paste_undeletable(self, client, sample_text, auth_header):
|
||||
"""Anonymous pastes cannot be deleted by anyone."""
|
||||
create = client.post("/", data=sample_text, content_type="text/plain")
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=auth_header)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_owner_can_delete(self, client, sample_text, auth_header):
|
||||
"""Owners can delete their own pastes."""
|
||||
create = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers=auth_header,
|
||||
)
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
|
||||
response = client.delete(f"/{paste_id}", headers=auth_header)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestJsonResponses:
|
||||
"""Tests for JSON response format and encoding."""
|
||||
|
||||
def test_json_content_type(self, client):
|
||||
"""API responses have correct Content-Type."""
|
||||
response = client.get("/")
|
||||
assert "application/json" in response.content_type
|
||||
|
||||
def test_unicode_in_response(self, client):
|
||||
"""Unicode content is properly encoded in responses."""
|
||||
unicode_text = "Hello 你好 مرحبا 🎉"
|
||||
create = client.post("/", data=unicode_text.encode("utf-8"))
|
||||
assert create.status_code == 201
|
||||
|
||||
paste_id = json.loads(create.data)["id"]
|
||||
raw = client.get(f"/{paste_id}/raw")
|
||||
assert raw.data.decode("utf-8") == unicode_text
|
||||
|
||||
def test_error_responses_are_json(self, client):
|
||||
"""Error responses are valid JSON."""
|
||||
response = client.get("/nonexist1234") # 12 chars
|
||||
assert response.status_code in (400, 404)
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
Reference in New Issue
Block a user