forked from claw/flaskpaste
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)
512 lines
17 KiB
Python
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
|