forked from claw/flaskpaste
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 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ data/*.db-shm
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
keys/
|
keys/
|
||||||
|
data/.pow_secret
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ GENERIC_MIME_TYPES = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Runtime PoW secret cache
|
|
||||||
_pow_secret_cache: bytes | None = None
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Anti-flood: dynamic PoW difficulty adjustment
|
# Anti-flood: dynamic PoW difficulty adjustment
|
||||||
@@ -753,14 +751,8 @@ def is_admin() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_pow_secret() -> bytes:
|
def get_pow_secret() -> bytes:
|
||||||
"""Get or generate PoW signing secret."""
|
"""Get PoW signing secret from app config."""
|
||||||
global _pow_secret_cache
|
return current_app.config["POW_SECRET"].encode()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def generate_challenge(difficulty_override: int | None = None) -> dict[str, Any]:
|
def generate_challenge(difficulty_override: int | None = None) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets as _secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "1.5.2"
|
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:
|
class Config:
|
||||||
"""Base configuration."""
|
"""Base configuration."""
|
||||||
|
|
||||||
@@ -70,8 +93,12 @@ class Config:
|
|||||||
# Difficulty is number of leading zero bits required in hash (0 = disabled).
|
# Difficulty is number of leading zero bits required in hash (0 = disabled).
|
||||||
POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_POW_DIFFICULTY", "20"))
|
POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_POW_DIFFICULTY", "20"))
|
||||||
POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes
|
POW_CHALLENGE_TTL = int(os.environ.get("FLASKPASTE_POW_TTL", "300")) # 5 minutes
|
||||||
# Secret key for signing challenges (auto-generated if not set)
|
# Secret key for signing challenges.
|
||||||
POW_SECRET = os.environ.get("FLASKPASTE_POW_SECRET", "")
|
# 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)
|
# Registration PoW difficulty (higher than paste creation for security)
|
||||||
REGISTER_POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_REGISTER_POW", "24"))
|
REGISTER_POW_DIFFICULTY = int(os.environ.get("FLASKPASTE_REGISTER_POW", "24"))
|
||||||
|
|
||||||
@@ -159,6 +186,7 @@ class TestingConfig(Config):
|
|||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
DATABASE = ":memory:"
|
DATABASE = ":memory:"
|
||||||
|
POW_SECRET = "test-pow-secret"
|
||||||
|
|
||||||
# Relaxed dedup for testing (100 per second window)
|
# Relaxed dedup for testing (100 per second window)
|
||||||
CONTENT_DEDUP_WINDOW = 1
|
CONTENT_DEDUP_WINDOW = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user