Files
flaskpaste/tests/test_paste_options.py
Username 89eee3378a security: implement pentest remediation (PROXY-001, BURN-001, RATE-001)
PROXY-001: Add startup warning when TRUSTED_PROXY_SECRET empty in production
- validate_security_config() checks for missing proxy secret
- Additional warning when PKI enabled without proxy secret
- Tests for security configuration validation

BURN-001: HEAD requests now trigger burn-after-read deletion
- Prevents attacker from probing paste existence before retrieval
- Updated test to verify new behavior

RATE-001: Add RATE_LIMIT_MAX_ENTRIES to cap memory usage
- Default 10000 unique IPs tracked
- Prunes oldest entries when limit exceeded
- Protects against memory exhaustion DoS

Test count: 284 -> 291 (7 new security tests)
2025-12-24 21:42:15 +01:00

512 lines
17 KiB
Python

"""Tests for burn-after-read and custom expiry features."""
import time
import pytest
from app import create_app
from app.database import cleanup_expired_pastes
class TestBurnAfterRead:
"""Test burn-after-read paste functionality."""
@pytest.fixture
def app(self):
"""Create app for burn-after-read tests."""
return create_app("testing")
@pytest.fixture
def client(self, app):
"""Create test client."""
return app.test_client()
def test_create_burn_paste(self, client):
"""Creating a burn-after-read paste should succeed."""
response = client.post(
"/",
data=b"secret message",
headers={"X-Burn-After-Read": "true"},
)
assert response.status_code == 201
data = response.get_json()
assert data["burn_after_read"] is True
def test_burn_paste_deleted_after_raw_get(self, client):
"""Burn paste should be deleted after first GET /raw."""
# Create burn paste
response = client.post(
"/",
data=b"one-time secret",
headers={"X-Burn-After-Read": "true"},
)
paste_id = response.get_json()["id"]
# First GET should succeed
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 200
assert response.data == b"one-time secret"
assert response.headers.get("X-Burn-After-Read") == "true"
# Second GET should fail (paste deleted)
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 404
def test_burn_paste_metadata_does_not_trigger_burn(self, client):
"""GET metadata should not delete burn paste."""
# Create burn paste
response = client.post(
"/",
data=b"secret",
headers={"X-Burn-After-Read": "true"},
)
paste_id = response.get_json()["id"]
# Metadata GET should succeed and show burn flag
response = client.get(f"/{paste_id}")
assert response.status_code == 200
data = response.get_json()
assert data["burn_after_read"] is True
# Paste should still exist
response = client.get(f"/{paste_id}")
assert response.status_code == 200
# Raw GET should delete it
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 200
# Now it's gone
response = client.get(f"/{paste_id}")
assert response.status_code == 404
def test_head_triggers_burn(self, client):
"""HEAD request SHOULD delete burn paste (security fix BURN-001).
HEAD requests trigger burn-after-read deletion to prevent attackers
from probing paste existence before retrieval.
"""
# Create burn paste
response = client.post(
"/",
data=b"secret",
headers={"X-Burn-After-Read": "true"},
)
paste_id = response.get_json()["id"]
# HEAD should succeed and trigger burn
response = client.head(f"/{paste_id}/raw")
assert response.status_code == 200
assert response.headers.get("X-Burn-After-Read") == "true"
# Paste should be deleted - subsequent access should fail
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 404
def test_burn_header_variations(self, client):
"""Different true values for X-Burn-After-Read should work."""
for value in ["true", "TRUE", "1", "yes", "YES"]:
response = client.post(
"/",
data=b"content",
headers={"X-Burn-After-Read": value},
)
data = response.get_json()
assert data.get("burn_after_read") is True, f"Failed for value: {value}"
def test_burn_header_false_values(self, client):
"""False values should not enable burn-after-read."""
for value in ["false", "0", "no", ""]:
response = client.post(
"/",
data=b"content",
headers={"X-Burn-After-Read": value},
)
data = response.get_json()
assert "burn_after_read" not in data, f"Should not be burn for: {value}"
class TestCustomExpiry:
"""Test custom expiry functionality."""
@pytest.fixture
def app(self):
"""Create app with short max expiry for testing."""
app = create_app("testing")
app.config["MAX_EXPIRY_SECONDS"] = 3600 # 1 hour max
app.config["PASTE_EXPIRY_SECONDS"] = 60 # 1 minute default
return app
@pytest.fixture
def client(self, app):
"""Create test client."""
return app.test_client()
def test_create_paste_with_custom_expiry(self, client):
"""Creating a paste with X-Expiry should set expires_at."""
response = client.post(
"/",
data=b"temporary content",
headers={"X-Expiry": "300"}, # 5 minutes
)
assert response.status_code == 201
data = response.get_json()
assert "expires_at" in data
# Should be approximately now + 300
now = int(time.time())
assert abs(data["expires_at"] - (now + 300)) < 5
def test_custom_expiry_capped_at_max(self, client):
"""Custom expiry should be capped at MAX_EXPIRY_SECONDS."""
response = client.post(
"/",
data=b"content",
headers={"X-Expiry": "999999"}, # Way more than max
)
assert response.status_code == 201
data = response.get_json()
assert "expires_at" in data
# Should be capped at 3600 seconds from now
now = int(time.time())
assert abs(data["expires_at"] - (now + 3600)) < 5
def test_expiry_shown_in_metadata(self, client):
"""Custom expiry should appear in paste metadata."""
response = client.post(
"/",
data=b"content",
headers={"X-Expiry": "600"},
)
paste_id = response.get_json()["id"]
response = client.get(f"/{paste_id}")
data = response.get_json()
assert "expires_at" in data
def test_invalid_expiry_uses_default(self, client):
"""Invalid X-Expiry values should use default expiry."""
for value in ["invalid", "-100", "0", ""]:
response = client.post(
"/",
data=b"content",
headers={"X-Expiry": value},
)
assert response.status_code == 201
data = response.get_json()
# Default expiry is applied for anonymous users
assert "expires_at" in data, f"Should have default expiry for: {value}"
def test_paste_without_custom_expiry(self, client):
"""Paste without X-Expiry should use default expiry based on auth level."""
response = client.post("/", data=b"content")
assert response.status_code == 201
data = response.get_json()
# Default expiry is now applied for all users
assert "expires_at" in data
class TestExpiryCleanup:
"""Test cleanup of expired pastes."""
@pytest.fixture
def app(self):
"""Create app with very short expiry for testing."""
app = create_app("testing")
# Set tiered expiry to 1 second for all levels
app.config["EXPIRY_ANON"] = 1
app.config["EXPIRY_UNTRUSTED"] = 1
app.config["EXPIRY_TRUSTED"] = 1
app.config["PASTE_EXPIRY_SECONDS"] = 1 # Legacy
app.config["MAX_EXPIRY_SECONDS"] = 10
return app
@pytest.fixture
def client(self, app):
"""Create test client."""
return app.test_client()
def test_cleanup_custom_expired_paste(self, app, client):
"""Paste with expired custom expiry should be cleaned up."""
# Create paste with 1 second expiry
response = client.post(
"/",
data=b"expiring soon",
headers={"X-Expiry": "1"},
)
paste_id = response.get_json()["id"]
# Should exist immediately
response = client.get(f"/{paste_id}")
assert response.status_code == 200
# Wait for expiry
time.sleep(2)
# Run cleanup
with app.app_context():
deleted = cleanup_expired_pastes()
assert deleted >= 1
# Should be gone
response = client.get(f"/{paste_id}")
assert response.status_code == 404
def test_cleanup_respects_default_expiry(self, app, client):
"""Paste without custom expiry should use default expiry."""
# Create paste without custom expiry
response = client.post("/", data=b"default expiry")
paste_id = response.get_json()["id"]
# Wait for default expiry (1 second in test config)
time.sleep(2)
# Run cleanup
with app.app_context():
deleted = cleanup_expired_pastes()
assert deleted >= 1
# Should be gone
response = client.get(f"/{paste_id}")
assert response.status_code == 404
def test_cleanup_keeps_unexpired_paste(self, app, client):
"""Paste with future custom expiry should not be cleaned up."""
# Create paste with long expiry
response = client.post(
"/",
data=b"not expiring soon",
headers={"X-Expiry": "10"}, # 10 seconds
)
paste_id = response.get_json()["id"]
# Run cleanup immediately
with app.app_context():
cleanup_expired_pastes()
# Should still exist
response = client.get(f"/{paste_id}")
assert response.status_code == 200
class TestCombinedOptions:
"""Test combinations of burn-after-read and custom expiry."""
@pytest.fixture
def app(self):
"""Create app for combined tests."""
return create_app("testing")
@pytest.fixture
def client(self, app):
"""Create test client."""
return app.test_client()
def test_burn_and_expiry_together(self, client):
"""Paste can have both burn-after-read and custom expiry."""
response = client.post(
"/",
data=b"secret with expiry",
headers={
"X-Burn-After-Read": "true",
"X-Expiry": "3600",
},
)
assert response.status_code == 201
data = response.get_json()
assert data["burn_after_read"] is True
assert "expires_at" in data
class TestPasswordProtection:
"""Test password-protected paste functionality."""
@pytest.fixture
def app(self):
"""Create app for password tests."""
return create_app("testing")
@pytest.fixture
def client(self, app):
"""Create test client."""
return app.test_client()
def test_create_password_protected_paste(self, client):
"""Creating a password-protected paste should succeed."""
response = client.post(
"/",
data=b"secret content",
headers={"X-Paste-Password": "mypassword123"},
)
assert response.status_code == 201
data = response.get_json()
assert data["password_protected"] is True
def test_get_protected_paste_without_password(self, client):
"""Accessing protected paste without password should return 401."""
# Create protected paste
response = client.post(
"/",
data=b"protected content",
headers={"X-Paste-Password": "secret"},
)
paste_id = response.get_json()["id"]
# Try to access without password
response = client.get(f"/{paste_id}")
assert response.status_code == 401
data = response.get_json()
assert data["password_protected"] is True
assert "Password required" in data["error"]
def test_get_protected_paste_with_wrong_password(self, client):
"""Accessing protected paste with wrong password should return 403."""
# Create protected paste
response = client.post(
"/",
data=b"protected content",
headers={"X-Paste-Password": "correctpassword"},
)
paste_id = response.get_json()["id"]
# Try with wrong password
response = client.get(
f"/{paste_id}",
headers={"X-Paste-Password": "wrongpassword"},
)
assert response.status_code == 403
data = response.get_json()
assert "Invalid password" in data["error"]
def test_get_protected_paste_with_correct_password(self, client):
"""Accessing protected paste with correct password should succeed."""
password = "supersecret123"
# Create protected paste
response = client.post(
"/",
data=b"protected content",
headers={"X-Paste-Password": password},
)
paste_id = response.get_json()["id"]
# Access with correct password
response = client.get(
f"/{paste_id}",
headers={"X-Paste-Password": password},
)
assert response.status_code == 200
data = response.get_json()
assert data["password_protected"] is True
def test_get_raw_protected_paste_without_password(self, client):
"""Getting raw content without password should return 401."""
response = client.post(
"/",
data=b"secret raw content",
headers={"X-Paste-Password": "secret"},
)
paste_id = response.get_json()["id"]
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 401
def test_get_raw_protected_paste_with_correct_password(self, client):
"""Getting raw content with correct password should succeed."""
password = "mypassword"
response = client.post(
"/",
data=b"secret raw content",
headers={"X-Paste-Password": password},
)
paste_id = response.get_json()["id"]
response = client.get(
f"/{paste_id}/raw",
headers={"X-Paste-Password": password},
)
assert response.status_code == 200
assert response.data == b"secret raw content"
def test_password_too_long_rejected(self, client):
"""Password longer than 1024 chars should be rejected."""
long_password = "x" * 1025
response = client.post(
"/",
data=b"content",
headers={"X-Paste-Password": long_password},
)
assert response.status_code == 400
data = response.get_json()
assert "too long" in data["error"]
def test_unprotected_paste_accessible(self, client):
"""Unprotected paste should be accessible without password."""
response = client.post("/", data=b"public content")
paste_id = response.get_json()["id"]
response = client.get(f"/{paste_id}")
assert response.status_code == 200
assert "password_protected" not in response.get_json()
def test_password_with_special_chars(self, client):
"""Password with special characters should work."""
password = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?"
response = client.post(
"/",
data=b"special content",
headers={"X-Paste-Password": password},
)
paste_id = response.get_json()["id"]
response = client.get(
f"/{paste_id}",
headers={"X-Paste-Password": password},
)
assert response.status_code == 200
def test_password_with_unicode(self, client):
"""Password with unicode characters should work."""
password = "пароль密码🔐"
response = client.post(
"/",
data=b"unicode content",
headers={"X-Paste-Password": password},
)
paste_id = response.get_json()["id"]
response = client.get(
f"/{paste_id}",
headers={"X-Paste-Password": password},
)
assert response.status_code == 200
def test_password_combined_with_burn(self, client):
"""Password protection can be combined with burn-after-read."""
password = "secret"
response = client.post(
"/",
data=b"protected burn content",
headers={
"X-Paste-Password": password,
"X-Burn-After-Read": "true",
},
)
assert response.status_code == 201
data = response.get_json()
assert data["password_protected"] is True
assert data["burn_after_read"] is True
paste_id = data["id"]
# First access with password should succeed
response = client.get(
f"/{paste_id}/raw",
headers={"X-Paste-Password": password},
)
assert response.status_code == 200
# Second access should fail (burned)
response = client.get(
f"/{paste_id}/raw",
headers={"X-Paste-Password": password},
)
assert response.status_code == 404