From e7c278be0d1ea874b24090e6c3e6cab909bb2692 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 23 Feb 2026 21:32:06 +0100 Subject: [PATCH] fix: share PoW HMAC secret across gunicorn workers get_pow_secret() generated a random secret per process, so challenges signed by worker A failed verification on worker B (~90% failure rate with 2 workers). Persist a file-backed secret to data/.pow_secret using O_EXCL for atomic creation. FLASKPASTE_POW_SECRET env var still takes priority when configured. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + app/api/routes.py | 12 ++---------- app/config.py | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 6466a22..613c2fd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ data/*.db-shm *.pem *.key keys/ +data/.pow_secret # Build dist/ diff --git a/app/api/routes.py b/app/api/routes.py index a11c602..112f812 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -68,8 +68,6 @@ GENERIC_MIME_TYPES = frozenset( } ) -# Runtime PoW secret cache -_pow_secret_cache: bytes | None = None # ───────────────────────────────────────────────────────────────────────────── # Anti-flood: dynamic PoW difficulty adjustment @@ -753,14 +751,8 @@ def is_admin() -> bool: def get_pow_secret() -> bytes: - """Get or generate PoW signing secret.""" - global _pow_secret_cache - configured: str = current_app.config.get("POW_SECRET", "") - if configured: - return configured.encode() - if _pow_secret_cache is None: - _pow_secret_cache = secrets.token_bytes(32) - return _pow_secret_cache + """Get PoW signing secret from app config.""" + return current_app.config["POW_SECRET"].encode() def generate_challenge(difficulty_override: int | None = None) -> dict[str, Any]: diff --git a/app/config.py b/app/config.py index e0d521c..8aa498a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,12 +1,35 @@ """Application configuration.""" import os +import secrets as _secrets from pathlib import Path # Application version VERSION = "1.5.2" +def _get_pow_secret(data_dir: Path) -> str: + """Read or create a shared PoW signing secret. + + Ensures all gunicorn workers use the same HMAC key by persisting + the generated secret to a file in the data directory. Uses O_EXCL + for atomic creation so concurrent workers converge on one secret. + """ + secret_file = data_dir / ".pow_secret" + try: + return secret_file.read_text().strip() + except FileNotFoundError: + data_dir.mkdir(parents=True, exist_ok=True) + secret = _secrets.token_hex(32) + try: + fd = os.open(str(secret_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.write(fd, secret.encode()) + os.close(fd) + return secret + except FileExistsError: + return secret_file.read_text().strip() + + class Config: """Base configuration.""" @@ -70,8 +93,12 @@ class Config: # Difficulty is number of leading zero bits required in hash (0 = disabled). POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_POW_DIFFICULTY", "20")) POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes - # Secret key for signing challenges (auto-generated if not set) - POW_SECRET = os.environ.get("FLASKPASTE_POW_SECRET", "") + # Secret key for signing challenges. + # When not set via env, a shared secret is persisted to data/.pow_secret + # so all gunicorn workers use the same HMAC key. + POW_SECRET = os.environ.get("FLASKPASTE_POW_SECRET", "") or _get_pow_secret( + Path(os.environ.get("FLASKPASTE_DB", Path(__file__).parent.parent / "data" / "pastes.db")).parent + ) # Registration PoW difficulty (higher than paste creation for security) REGISTER_POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_REGISTER_POW", "24")) @@ -159,6 +186,7 @@ class TestingConfig(Config): TESTING = True DATABASE = ":memory:" + POW_SECRET = "test-pow-secret" # Relaxed dedup for testing (100 per second window) CONTENT_DEDUP_WINDOW = 1