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

@@ -165,6 +165,7 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
window = current_app.config["RATE_LIMIT_WINDOW"]
max_requests = current_app.config["RATE_LIMIT_MAX"]
max_entries = current_app.config.get("RATE_LIMIT_MAX_ENTRIES", 10000)
if authenticated:
max_requests *= current_app.config.get("RATE_LIMIT_AUTH_MULTIPLIER", 5)
@@ -173,6 +174,11 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
cutoff = now - window
with _rate_limit_lock:
# RATE-001: Enforce maximum entries to prevent memory exhaustion
if len(_rate_limit_requests) >= max_entries and client_ip not in _rate_limit_requests:
# Evict oldest entries (those with oldest last request time)
_prune_rate_limit_entries(max_entries // 2, cutoff)
# Clean old requests and get current list
requests = _rate_limit_requests[client_ip]
requests[:] = [t for t in requests if t > cutoff]
@@ -192,6 +198,33 @@ def check_rate_limit(client_ip: str, authenticated: bool = False) -> tuple[bool,
return True, remaining, max_requests, reset_timestamp
def _prune_rate_limit_entries(target_size: int, cutoff: float) -> None:
"""Prune rate limit entries to target size. Must hold _rate_limit_lock.
Removes entries with no recent activity first, then oldest entries.
"""
# First pass: remove entries with all expired requests
to_remove = []
for ip, requests in _rate_limit_requests.items():
if not requests or all(t <= cutoff for t in requests):
to_remove.append(ip)
for ip in to_remove:
del _rate_limit_requests[ip]
# Second pass: if still over target, remove entries with oldest last activity
if len(_rate_limit_requests) > target_size:
# Sort by most recent request timestamp (ascending = oldest first)
entries = sorted(
_rate_limit_requests.items(),
key=lambda x: max(x[1]) if x[1] else 0,
)
# Remove oldest until we're at target size
remove_count = len(entries) - target_size
for ip, _ in entries[:remove_count]:
del _rate_limit_requests[ip]
def cleanup_rate_limits(window: int | None = None) -> int:
"""Remove expired rate limit entries. Returns count of cleaned entries.
@@ -1512,7 +1545,11 @@ class PasteRawView(MethodView):
return response
def head(self, paste_id: str) -> Response:
"""Return raw paste headers without triggering burn."""
"""Return raw paste headers. HEAD triggers burn-after-read deletion.
Security note: HEAD requests count as paste access for burn-after-read
to prevent attackers from probing paste existence before retrieval.
"""
# Validate and fetch
if err := validate_paste_id(paste_id):
return err
@@ -1520,13 +1557,21 @@ class PasteRawView(MethodView):
return err
row: Row = g.paste
g.db.commit()
db = g.db
# BURN-001: HEAD triggers burn-after-read like GET
burn_after_read = row["burn_after_read"]
if burn_after_read:
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
current_app.logger.info("Burn-after-read paste deleted via HEAD: %s", paste_id)
db.commit()
response = Response(mimetype=row["mime_type"])
response.headers["Content-Length"] = str(row["size"])
if row["mime_type"].startswith(("image/", "text/")):
response.headers["Content-Disposition"] = "inline"
if row["burn_after_read"]:
if burn_after_read:
response.headers["X-Burn-After-Read"] = "true"
return response