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)
This commit is contained in:
Username
2025-12-24 21:42:15 +01:00
parent bebc6e0354
commit 89eee3378a
10 changed files with 271 additions and 15 deletions

View File

@@ -80,8 +80,12 @@ class TestBurnAfterRead:
response = client.get(f"/{paste_id}")
assert response.status_code == 404
def test_head_does_not_trigger_burn(self, client):
"""HEAD request should not delete burn paste."""
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(
"/",
@@ -90,13 +94,14 @@ class TestBurnAfterRead:
)
paste_id = response.get_json()["id"]
# HEAD should succeed
# 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 still exist
# Paste should be deleted - subsequent access should fail
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 200
assert response.status_code == 404
def test_burn_header_variations(self, client):
"""Different true values for X-Burn-After-Read should work."""

View File

@@ -193,3 +193,77 @@ class TestRateLimitCleanup:
cleaned = cleanup_rate_limits(window=60)
assert isinstance(cleaned, int)
assert cleaned >= 0
class TestRateLimitMaxEntries:
"""Tests for RATE-001: rate limit storage cap to prevent memory DoS."""
def test_max_entries_config_exists(self, app):
"""RATE_LIMIT_MAX_ENTRIES config should exist."""
assert "RATE_LIMIT_MAX_ENTRIES" in app.config
assert isinstance(app.config["RATE_LIMIT_MAX_ENTRIES"], int)
assert app.config["RATE_LIMIT_MAX_ENTRIES"] > 0
def test_rate_limit_storage_bounded(self, app):
"""Rate limit storage should not exceed max entries."""
from app.api.routes import (
_rate_limit_requests,
check_rate_limit,
reset_rate_limits,
)
# Reset state
reset_rate_limits()
# Set very low max entries for testing
original_max = app.config.get("RATE_LIMIT_MAX_ENTRIES", 10000)
app.config["RATE_LIMIT_MAX_ENTRIES"] = 10
try:
with app.app_context():
# Add more entries than max
for i in range(20):
check_rate_limit(f"192.168.1.{i}", authenticated=False)
# Storage should be bounded
assert len(_rate_limit_requests) <= 10
finally:
app.config["RATE_LIMIT_MAX_ENTRIES"] = original_max
reset_rate_limits()
def test_prune_keeps_recent_entries(self, app):
"""Pruning should keep the most recent entries."""
import time
from app.api.routes import (
_rate_limit_requests,
check_rate_limit,
reset_rate_limits,
)
reset_rate_limits()
original_max = app.config.get("RATE_LIMIT_MAX_ENTRIES", 10000)
app.config["RATE_LIMIT_MAX_ENTRIES"] = 5
try:
with app.app_context():
# Add entries with known order
for i in range(3):
check_rate_limit(f"old-{i}.0.0.1", authenticated=False)
time.sleep(0.01) # Ensure distinct timestamps
# Add more recent entries
for i in range(3):
check_rate_limit(f"new-{i}.0.0.1", authenticated=False)
time.sleep(0.01)
# When we exceed max, old entries should be pruned
# Storage should be bounded
assert len(_rate_limit_requests) <= 5
# Most recent should still be present
assert any("new" in ip for ip in _rate_limit_requests)
finally:
app.config["RATE_LIMIT_MAX_ENTRIES"] = original_max
reset_rate_limits()

View File

@@ -1,6 +1,105 @@
"""Security-focused tests for FlaskPaste."""
import json
import logging
class TestSecurityConfigValidation:
"""Tests for security configuration validation at startup."""
def test_proxy_secret_warning_in_production(self, app, caplog):
"""Warning logged when TRUSTED_PROXY_SECRET not set in production mode."""
from app import validate_security_config
# Simulate production: debug=False, TESTING=False
original_debug = app.debug
original_testing = app.config.get("TESTING")
original_secret = app.config.get("TRUSTED_PROXY_SECRET", "")
try:
app.debug = False
app.config["TESTING"] = False
app.config["TRUSTED_PROXY_SECRET"] = ""
with caplog.at_level(logging.WARNING):
validate_security_config(app)
assert "TRUSTED_PROXY_SECRET is not set" in caplog.text
assert "SECURITY WARNING" in caplog.text
finally:
app.debug = original_debug
app.config["TESTING"] = original_testing
app.config["TRUSTED_PROXY_SECRET"] = original_secret
def test_no_warning_when_proxy_secret_set(self, app, caplog):
"""No warning when TRUSTED_PROXY_SECRET is properly configured."""
from app import validate_security_config
original_debug = app.debug
original_testing = app.config.get("TESTING")
original_secret = app.config.get("TRUSTED_PROXY_SECRET", "")
try:
app.debug = False
app.config["TESTING"] = False
app.config["TRUSTED_PROXY_SECRET"] = "my-secure-secret"
with caplog.at_level(logging.WARNING):
validate_security_config(app)
assert "TRUSTED_PROXY_SECRET is not set" not in caplog.text
finally:
app.debug = original_debug
app.config["TESTING"] = original_testing
app.config["TRUSTED_PROXY_SECRET"] = original_secret
def test_no_warning_in_debug_mode(self, app, caplog):
"""No warning in debug mode even without proxy secret."""
from app import validate_security_config
original_debug = app.debug
original_secret = app.config.get("TRUSTED_PROXY_SECRET", "")
original_pki = app.config.get("PKI_ENABLED", False)
try:
app.debug = True
app.config["TRUSTED_PROXY_SECRET"] = ""
app.config["PKI_ENABLED"] = False
caplog.clear()
with caplog.at_level(logging.WARNING):
validate_security_config(app)
assert "TRUSTED_PROXY_SECRET is not set" not in caplog.text
finally:
app.debug = original_debug
app.config["TRUSTED_PROXY_SECRET"] = original_secret
app.config["PKI_ENABLED"] = original_pki
def test_pki_warning_without_proxy_secret(self, app, caplog):
"""Warning when PKI enabled but no proxy secret."""
from app import validate_security_config
original_debug = app.debug
original_testing = app.config.get("TESTING")
original_secret = app.config.get("TRUSTED_PROXY_SECRET", "")
original_pki = app.config.get("PKI_ENABLED", False)
try:
app.debug = False
app.config["TESTING"] = False
app.config["TRUSTED_PROXY_SECRET"] = ""
app.config["PKI_ENABLED"] = True
with caplog.at_level(logging.WARNING):
validate_security_config(app)
assert "PKI is enabled but TRUSTED_PROXY_SECRET is not set" in caplog.text
finally:
app.debug = original_debug
app.config["TESTING"] = original_testing
app.config["TRUSTED_PROXY_SECRET"] = original_secret
app.config["PKI_ENABLED"] = original_pki
class TestSecurityHeaders: