From e31c268d54def718273961a8c3d28e3365d14cec Mon Sep 17 00:00:00 2001 From: billisdead Date: Tue, 23 Jun 2026 16:08:11 +0200 Subject: [PATCH] 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 --- artifacts/flask-api/app.py | 45 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 25d13b7..3841894 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -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")