Enforce consent token on submission, reduce default cooldown

- Require signed _ct cookie (set by POST /api/consent) before accepting any
  submission. Direct API calls without a prior consent flow are rejected 403.
- Add _sign_consent_token / _verify_consent_token (HMAC-SHA256, 24h TTL).
- Lower default CONTRIBUTION_COOLDOWN_SECONDS from 3600 to 240 (4 min).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:08:11 +02:00
parent 1d180acf15
commit e31c268d54
+43 -2
View File
@@ -67,8 +67,9 @@ RATE_LIMIT_CONTRIBUTIONS = os.environ.get(
"RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour"
)
CONTRIBUTION_COOLDOWN_SECONDS = int(
os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "3600")
os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "240")
)
CONSENT_TOKEN_TTL = 86400 # 24h — durée de validité du cookie de consentement
FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10"))
FLOOD_WINDOW_SECONDS = 300
@@ -230,6 +231,26 @@ def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool:
return False
def _sign_consent_token(secret: str) -> str:
ts = int(time.time())
msg = f"consent:{ts}".encode()
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
return f"{ts}.{sig}"
def _verify_consent_token(token: str, secret: str) -> bool:
try:
ts_str, sig = token.rsplit(".", 1)
ts = int(ts_str)
msg = f"consent:{ts}".encode()
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
if not hmac.compare_digest(sig, expected):
return False
return (time.time() - ts) < CONSENT_TOKEN_TTL
except Exception:
return False
def _verify_hcaptcha(token: str) -> bool:
"""
Vérifie un token hCaptcha.
@@ -417,6 +438,15 @@ def submit_idea():
return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée. Veuillez réessayer."}), 400
secret_key = os.environ.get("SECRET_KEY", "").strip()
if secret_key:
ct = request.cookies.get("_ct", "")
if not ct or not _verify_consent_token(ct, secret_key):
logger.info("Soumission refusée — consentement absent ou expiré (IP: %s)", get_remote_address())
return jsonify({
"error": "consent_required",
"message": "Votre consentement est requis avant de contribuer.",
}), 403
if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
cv = request.cookies.get("_cv", "")
if cv and _verify_cooldown(cv, secret_key, CONTRIBUTION_COOLDOWN_SECONDS):
@@ -519,7 +549,18 @@ def record_consent():
"Consentement enregistré — fingerprint: %s... | version: %s",
fingerprint_hash[:8], consent_version,
)
return jsonify({"ok": True}), 201
secret_key = os.environ.get("SECRET_KEY", "").strip()
response = make_response(jsonify({"ok": True}), 201)
if secret_key:
response.set_cookie(
"_ct",
_sign_consent_token(secret_key),
max_age=CONSENT_TOKEN_TTL,
httponly=True,
samesite="Lax",
secure=request.is_secure,
)
return response
@app.get("/api/stats/public")