security: implement HASH-001 and ENUM-001 remediations
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Tests (push) Has been skipped
CI / Memory Leak Check (push) Has been skipped
CI / Security Scan (push) Successful in 23s

HASH-001: Add threading lock to content hash deduplication
- Prevents race condition between SELECT and UPDATE
- Ensures accurate dedup counting under concurrent load

ENUM-001: Add rate limiting to paste lookups
- Separate rate limiter for GET/HEAD on paste endpoints
- Default 60 requests/minute per IP (configurable)
- Prevents brute-force paste ID enumeration attacks
This commit is contained in:
Username
2025-12-24 23:12:28 +01:00
parent da1beca893
commit c130020ab8
5 changed files with 116 additions and 36 deletions

View File

@@ -250,9 +250,9 @@ Testing uses specialized Claude subagents for different security domains, with f
### Medium-term (Medium)
- [ ] **HASH-001**: Add locking to content hash deduplication
- [x] **HASH-001**: Add locking to content hash deduplication
- [x] **FLOOD-001**: Add memory limit to anti-flood request list
- [ ] **ENUM-001**: Add rate limiting to paste metadata endpoints
- [x] **ENUM-001**: Add rate limiting to paste metadata endpoints
- [x] **CLI-002**: Verify SSL certificate hostname matching
- [x] **CLI-003**: Add config file permission validation on startup
- [x] **AUDIT-001**: Add query result limits to prevent enumeration

View File

@@ -273,6 +273,50 @@ def reset_rate_limits() -> None:
_rate_limit_requests.clear()
# ─────────────────────────────────────────────────────────────────────────────
# ENUM-001: Lookup Rate Limiting (prevents paste ID enumeration)
# ─────────────────────────────────────────────────────────────────────────────
_lookup_rate_limit_lock = threading.Lock()
_lookup_rate_limit_requests: dict[str, list[float]] = defaultdict(list)
def check_lookup_rate_limit(client_ip: str) -> tuple[bool, int]:
"""Check if lookup request is within rate limit.
Args:
client_ip: Client IP address
Returns:
Tuple of (allowed, retry_after_seconds)
"""
if not current_app.config.get("LOOKUP_RATE_LIMIT_ENABLED", True):
return True, 0
window = current_app.config.get("LOOKUP_RATE_LIMIT_WINDOW", 60)
max_requests = current_app.config.get("LOOKUP_RATE_LIMIT_MAX", 60)
now = time.time()
cutoff = now - window
with _lookup_rate_limit_lock:
requests = _lookup_rate_limit_requests[client_ip]
requests[:] = [t for t in requests if t > cutoff]
if len(requests) >= max_requests:
retry_after = int(requests[0] + window - now) + 1
return False, max(1, retry_after)
requests.append(now)
return True, 0
def reset_lookup_rate_limits() -> None:
"""Clear lookup rate limit state. For testing only."""
with _lookup_rate_limit_lock:
_lookup_rate_limit_requests.clear()
def add_rate_limit_headers(
response: Response, remaining: int, limit: int, reset_timestamp: int
) -> Response:
@@ -415,6 +459,18 @@ def validate_paste_id(paste_id: str) -> Response | None:
def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None:
"""Fetch paste and store in g.paste. Returns error response or None if OK."""
# ENUM-001: Rate limit lookups to prevent enumeration attacks
client_ip = get_client_ip()
allowed, retry_after = check_lookup_rate_limit(client_ip)
if not allowed:
response = error_response(
f"Lookup rate limit exceeded. Retry after {retry_after} seconds.",
429,
retry_after=retry_after,
)
response.headers["Retry-After"] = str(retry_after)
return response
db = get_db()
now = int(time.time())

View File

@@ -108,6 +108,16 @@ class Config:
os.environ.get("FLASKPASTE_RATE_CLEANUP_THRESHOLD", "0.8")
)
# ENUM-001: Rate limiting for paste lookups (prevents enumeration attacks)
# Separate from creation limits - allows more reads but prevents brute-force
LOOKUP_RATE_LIMIT_ENABLED = os.environ.get("FLASKPASTE_LOOKUP_RATE_LIMIT", "1").lower() in (
"1",
"true",
"yes",
)
LOOKUP_RATE_LIMIT_WINDOW = int(os.environ.get("FLASKPASTE_LOOKUP_RATE_WINDOW", "60"))
LOOKUP_RATE_LIMIT_MAX = int(os.environ.get("FLASKPASTE_LOOKUP_RATE_MAX", "60"))
# Audit Logging
# Track security-relevant events (paste creation, deletion, rate limits, etc.)
AUDIT_ENABLED = os.environ.get("FLASKPASTE_AUDIT", "1").lower() in ("1", "true", "yes")
@@ -154,6 +164,11 @@ class TestingConfig(Config):
RATE_LIMIT_WINDOW = 1
RATE_LIMIT_MAX = 100
# Relaxed lookup rate limiting for tests (ENUM-001)
LOOKUP_RATE_LIMIT_ENABLED = True
LOOKUP_RATE_LIMIT_WINDOW = 1
LOOKUP_RATE_LIMIT_MAX = 1000
# PKI testing configuration
PKI_ENABLED = True
PKI_CA_PASSWORD = "test-ca-password"

View File

@@ -5,12 +5,16 @@ from __future__ import annotations
import hashlib
import secrets
import sqlite3
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
from flask import current_app, g
# HASH-001: Lock for content hash deduplication to prevent race conditions
_content_hash_lock = threading.Lock()
if TYPE_CHECKING:
from flask import Flask
@@ -264,44 +268,47 @@ def check_content_hash(content_hash: str) -> tuple[bool, int]:
db = get_db()
# Check existing hash record
row = db.execute(
"SELECT count, last_seen FROM content_hashes WHERE hash = ?", (content_hash,)
).fetchone()
# HASH-001: Lock to prevent race condition between SELECT and UPDATE
with _content_hash_lock:
# Check existing hash record
row = db.execute(
"SELECT count, last_seen FROM content_hashes WHERE hash = ?", (content_hash,)
).fetchone()
if row is None:
# First time seeing this content
if row is None:
# First time seeing this content
db.execute(
"INSERT INTO content_hashes (hash, first_seen, last_seen, count) "
"VALUES (?, ?, ?, 1)",
(content_hash, now, now),
)
db.commit()
return True, 1
if row["last_seen"] < cutoff:
# Outside window, reset counter
db.execute(
"UPDATE content_hashes SET first_seen = ?, last_seen = ?, count = 1 WHERE hash = ?",
(now, now, content_hash),
)
db.commit()
return True, 1
# Within window, check threshold
current_count = row["count"] + 1
if current_count > max_count:
# Exceeded threshold, don't increment (prevent counter overflow)
return False, row["count"]
# Update counter
db.execute(
"INSERT INTO content_hashes (hash, first_seen, last_seen, count) VALUES (?, ?, ?, 1)",
(content_hash, now, now),
"UPDATE content_hashes SET last_seen = ?, count = ? WHERE hash = ?",
(now, current_count, content_hash),
)
db.commit()
return True, 1
if row["last_seen"] < cutoff:
# Outside window, reset counter
db.execute(
"UPDATE content_hashes SET first_seen = ?, last_seen = ?, count = 1 WHERE hash = ?",
(now, now, content_hash),
)
db.commit()
return True, 1
# Within window, check threshold
current_count = row["count"] + 1
if current_count > max_count:
# Exceeded threshold, don't increment (prevent counter overflow)
return False, row["count"]
# Update counter
db.execute(
"UPDATE content_hashes SET last_seen = ?, count = ? WHERE hash = ?",
(now, current_count, content_hash),
)
db.commit()
return True, current_count
return True, current_count
def init_app(app: Flask) -> None:

View File

@@ -4,7 +4,7 @@ import pytest
import app.database as db_module
from app import create_app
from app.api.routes import reset_rate_limits
from app.api.routes import reset_lookup_rate_limits, reset_rate_limits
def _clear_database():
@@ -22,6 +22,7 @@ def app():
"""Create application for testing."""
# Reset global state for test isolation
reset_rate_limits()
reset_lookup_rate_limits()
_clear_database()
test_app = create_app("testing")
@@ -33,6 +34,7 @@ def app():
# Cleanup after test
reset_rate_limits()
reset_lookup_rate_limits()
@pytest.fixture