Security: close the 9 items from the June review + one found along the way
Findings from the 2026-06-24 security review, now mandatory before this platform leaves the LAN for a public VPS (see budget/externalization note): - Timing attack on admin password: hmac.compare_digest instead of != - CORS wildcard removed, restricted to ALLOWED_ORIGINS (needed for credentials) - hCaptcha now fails closed on network error instead of open - HMAC signatures use the full SHA-256 digest instead of a 64-bit truncation - Consultation-scoped submissions were missing the consent/cooldown checks the global submission route already had - Admin search capped at 200 chars (ILIKE cost) - Webhook URLs validated against SSRF (private/loopback/link-local/metadata IPs rejected, re-checked at trigger time too, redirects not followed) - Flood tracker backed by Redis when REDIS_URL is set (shared across Gunicorn workers, survives restarts), falls back to in-memory otherwise - Admin session moved from sessionStorage/Bearer token to an httpOnly+Secure+ SameSite=Strict cookie; new /api/admin/logout and /api/admin/session routes Also found while wiring ProxyFix for the cookie's Secure flag: the app never told Flask about the Nginx reverse proxy, so request.remote_addr saw Nginx's own IP for every request in production — silently breaking rate limiting, flood detection, and IP blacklist (everyone would collapse onto one shared key). Added ProxyFix (1 hop). CSV exports now stream instead of loading everything into memory. AI prompts delimit citizen-submitted content explicitly and instruct the model to treat it as inert data, never instructions, to reduce prompt injection surface. Docs (DEX.md, SECURITE_ANTI_ABUS.md, README.md, DEPLOIEMENT.md) and dev-local.sh updated to match.
This commit is contained in:
1 parent
2c0bb379c9
commit
defc07648c
10 files changed
+382
-116
No files matched your search
+237
-41
@@ -14,11 +14,14 @@ Sécurité :
|
||||
- Fingerprinting non-PII (FingerprintJS hash SHA-256, sans cookie tiers)
|
||||
- Détection de flood (> FLOOD_THRESHOLD soumissions / 5 min / même IP)
|
||||
- Cooldown session (cookie httpOnly signé HMAC-SHA256, si SECRET_KEY défini)
|
||||
- hCaptcha stub (activer via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY)
|
||||
- hCaptcha stub (activer via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY) — fail-closed
|
||||
si le service est injoignable
|
||||
- Validation et assainissement des entrées (bleach)
|
||||
- CORS restreint
|
||||
- CORS restreint à ALLOWED_ORIGINS (jamais de wildcard, requis pour les cookies)
|
||||
- En-têtes de sécurité HTTP
|
||||
- Panel admin protégé par ADMIN_SECRET (Bearer token)
|
||||
- Panel admin protégé par ADMIN_SECRET, session via cookie httpOnly signé (jamais
|
||||
exposée à JS ni stockée côté client)
|
||||
- ProxyFix (1 saut) pour voir la vraie IP/le vrai schéma derrière Nginx
|
||||
"""
|
||||
|
||||
import csv
|
||||
@@ -27,13 +30,16 @@ import hashlib
|
||||
import hmac
|
||||
import html as html_module
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
import bleach
|
||||
@@ -41,6 +47,7 @@ from flask import Flask, jsonify, make_response, request, Response
|
||||
from flask_cors import CORS
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from database import (
|
||||
init_db, get_connection, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
|
||||
@@ -83,11 +90,39 @@ _flood_lock = threading.Lock()
|
||||
app = Flask(__name__)
|
||||
app.config["JSON_SORT_KEYS"] = False
|
||||
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False)
|
||||
# Un seul saut de proxy en prod (Nginx → Gunicorn, voir deploy/nginx.conf) : sans ça,
|
||||
# request.remote_addr / request.is_secure voient l'IP/le schéma de Nginx pour TOUTE
|
||||
# requête, pas celui du vrai visiteur — casse silencieusement le rate limiting, la
|
||||
# détection de flood et la blacklist IP (tout le monde retombe sur la même clé).
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||
|
||||
|
||||
def _get_allowed_origins() -> list[str]:
|
||||
"""Origines autorisées en CORS — pas de wildcard, nécessaire pour les cookies
|
||||
(supports_credentials=True n'est jamais compatible avec origins="*")."""
|
||||
raw = os.environ.get("ALLOWED_ORIGINS", "") or os.environ.get("VITE_APP_URL", "")
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
CORS(
|
||||
app,
|
||||
resources={r"/api/*": {"origins": _get_allowed_origins()}},
|
||||
supports_credentials=True,
|
||||
)
|
||||
|
||||
_redis_url = os.environ.get("REDIS_URL", "")
|
||||
_storage_uri = _redis_url if _redis_url else "memory://"
|
||||
|
||||
_redis_client = None
|
||||
if _redis_url:
|
||||
try:
|
||||
import redis as _redis_lib
|
||||
_redis_client = _redis_lib.from_url(_redis_url)
|
||||
_redis_client.ping()
|
||||
except Exception:
|
||||
logger.warning("REDIS_URL configuré mais injoignable — flood tracker replié sur la mémoire du process")
|
||||
_redis_client = None
|
||||
|
||||
limiter = Limiter(
|
||||
get_remote_address,
|
||||
app=app,
|
||||
@@ -221,8 +256,8 @@ def require_admin(f):
|
||||
secret = _get_admin_secret()
|
||||
if not secret:
|
||||
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer ") or not _verify_admin_token(auth[7:], secret):
|
||||
token = request.cookies.get("admin_session", "")
|
||||
if not token or not _verify_admin_token(token, secret):
|
||||
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
@@ -245,7 +280,7 @@ def _get_ip_hash() -> str:
|
||||
def _sign_cooldown(secret: str) -> str:
|
||||
ts = int(time.time())
|
||||
msg = ts.to_bytes(8, "big")
|
||||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return f"{ts}.{sig}"
|
||||
|
||||
|
||||
@@ -254,7 +289,7 @@ def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool:
|
||||
ts_str, sig = cookie.rsplit(".", 1)
|
||||
ts = int(ts_str)
|
||||
msg = ts.to_bytes(8, "big")
|
||||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return False
|
||||
return (time.time() - ts) < cooldown_seconds
|
||||
@@ -265,7 +300,7 @@ def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool:
|
||||
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]
|
||||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return f"{ts}.{sig}"
|
||||
|
||||
|
||||
@@ -274,7 +309,7 @@ def _verify_consent_token(token: str, secret: str) -> bool:
|
||||
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]
|
||||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return False
|
||||
return (time.time() - ts) < CONSENT_TOKEN_TTL
|
||||
@@ -286,7 +321,7 @@ def _sign_admin_token(secret: str) -> str:
|
||||
"""Émet un token de session admin signé HMAC-SHA256 (TTL 8h)."""
|
||||
ts = int(time.time())
|
||||
msg = f"admin:{ts}".encode()
|
||||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return f"{ts}.{sig}"
|
||||
|
||||
|
||||
@@ -296,7 +331,7 @@ def _verify_admin_token(token: str, secret: str, max_age: int = 28800) -> bool:
|
||||
ts_str, sig = token.rsplit(".", 1)
|
||||
ts = int(ts_str)
|
||||
msg = f"admin:{ts}".encode()
|
||||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return False
|
||||
return (time.time() - ts) < max_age
|
||||
@@ -325,12 +360,34 @@ def _verify_hcaptcha(token: str) -> bool:
|
||||
result = json.loads(resp.read())
|
||||
return bool(result.get("success", False))
|
||||
except Exception:
|
||||
logger.warning("Vérification hCaptcha impossible (erreur réseau) — skip")
|
||||
return True
|
||||
# Fail-closed : une panne réseau de hCaptcha ne doit jamais laisser passer
|
||||
# une soumission non vérifiée quand la protection est explicitement activée.
|
||||
logger.warning("Vérification hCaptcha impossible (erreur réseau) — rejet")
|
||||
return False
|
||||
|
||||
|
||||
def _check_flood_redis(client, key: str) -> bool:
|
||||
"""Fenêtre glissante sur un sorted set Redis — partagée entre tous les workers
|
||||
Gunicorn et survit à un redémarrage, contrairement au dict en mémoire du process."""
|
||||
now = time.time()
|
||||
redis_key = f"flood:{key}"
|
||||
member = f"{now}:{uuid.uuid4().hex[:8]}" # membre unique même à timestamp identique
|
||||
pipe = client.pipeline()
|
||||
pipe.zremrangebyscore(redis_key, 0, now - FLOOD_WINDOW_SECONDS)
|
||||
pipe.zadd(redis_key, {member: now})
|
||||
pipe.zcard(redis_key)
|
||||
pipe.expire(redis_key, FLOOD_WINDOW_SECONDS)
|
||||
_, _, count, _ = pipe.execute()
|
||||
return count > FLOOD_THRESHOLD
|
||||
|
||||
|
||||
def _check_flood(ip: str, fingerprint_hash: str | None) -> bool:
|
||||
key = fingerprint_hash if fingerprint_hash else ip
|
||||
if _redis_client is not None:
|
||||
try:
|
||||
return _check_flood_redis(_redis_client, key)
|
||||
except Exception:
|
||||
logger.warning("Flood tracker Redis indisponible pour cette requête — repli mémoire")
|
||||
now = time.time()
|
||||
with _flood_lock:
|
||||
times = _flood_tracker.get(key, [])
|
||||
@@ -372,11 +429,51 @@ def serialize_consultation(c: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Bloque toute redirection — une 30x pourrait rediriger vers une adresse interne
|
||||
après que l'URL d'origine ait passé la vérification SSRF ci-dessous."""
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _is_safe_webhook_host(hostname: str) -> bool:
|
||||
"""Résout le nom d'hôte et rejette toute IP privée/loopback/lien-local/réservée —
|
||||
protection SSRF contre le réseau interne et le metadata du cloud une fois le
|
||||
service hébergé sur un VPS public."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
if not infos:
|
||||
return False
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_safe_webhook_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
return _is_safe_webhook_host(parsed.hostname)
|
||||
|
||||
|
||||
def _trigger_webhook(consultation: dict, synthesis: dict | None) -> None:
|
||||
"""Envoie les résultats de clôture au webhook configuré (non-bloquant)."""
|
||||
url = consultation.get("webhook_url")
|
||||
if not url:
|
||||
return
|
||||
# Revalidé ici (pas seulement à la création de la consultation) : protège aussi
|
||||
# les webhooks enregistrés avant ce correctif, et contre le DNS rebinding entre
|
||||
# la création et la clôture de la consultation.
|
||||
if not _is_safe_webhook_url(url):
|
||||
logger.warning("Webhook consultation '%s' — URL non autorisée (SSRF), envoi annulé", consultation.get("slug"))
|
||||
return
|
||||
try:
|
||||
payload = {
|
||||
"event": "consultation_closed",
|
||||
@@ -399,7 +496,8 @@ def _trigger_webhook(consultation: dict, synthesis: dict | None) -> None:
|
||||
"User-Agent": "LaVoixDuPeuple/1.0",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
with opener.open(req, timeout=10) as resp:
|
||||
logger.info("Webhook consultation '%s' → HTTP %d", consultation["slug"], resp.status)
|
||||
except Exception:
|
||||
logger.warning("Webhook consultation '%s' échoué (non-bloquant)", consultation.get("slug"))
|
||||
@@ -691,23 +789,53 @@ def export_contributions_json():
|
||||
)
|
||||
|
||||
|
||||
def _stream_csv_rows(header: list[str], fetch_page, row_to_fields, page_size: int = 1000):
|
||||
"""Génère un CSV en streaming, sans jamais charger l'ensemble des lignes en mémoire
|
||||
ni côté buffer CSV ni côté requête base de données (pagination interne).
|
||||
|
||||
fetch_page(page, per_page) -> (rows, total) — même contrat que les fonctions
|
||||
de pagination existantes de database.py.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, quoting=csv.QUOTE_ALL)
|
||||
|
||||
def _flush() -> bytes:
|
||||
data = buf.getvalue().encode("utf-8")
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
return data
|
||||
|
||||
yield "".encode("utf-8")
|
||||
writer.writerow(header)
|
||||
yield _flush()
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
rows, total = fetch_page(page, page_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
writer.writerow(row_to_fields(row))
|
||||
yield _flush()
|
||||
if page * page_size >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
|
||||
@app.get("/api/contributions/export/csv")
|
||||
@limiter.limit("10 per minute")
|
||||
def export_contributions_csv():
|
||||
"""Export CSV des contributions globales acceptées (champs publics uniquement)."""
|
||||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(["id", "content", "author", "created_at"])
|
||||
for c in contributions:
|
||||
writer.writerow([
|
||||
def fields(c):
|
||||
return [
|
||||
c.get("id"),
|
||||
c.get("content", ""),
|
||||
c.get("author", ""),
|
||||
c["created_at"].isoformat() if c.get("created_at") else "",
|
||||
])
|
||||
]
|
||||
|
||||
return Response(
|
||||
output.getvalue().encode("utf-8-sig"),
|
||||
_stream_csv_rows(["id", "content", "author", "created_at"], get_public_contributions, fields),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||||
)
|
||||
@@ -841,6 +969,28 @@ def submit_consultation_idea(slug: str):
|
||||
if not _verify_hcaptcha(hcaptcha_token):
|
||||
return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée."}), 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é (consultation: %s, IP: %s)",
|
||||
slug, 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):
|
||||
logger.info("Cooldown actif — soumission rejetée (même session, consultation: %s)", slug)
|
||||
return jsonify({
|
||||
"error": "cooldown",
|
||||
"message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.",
|
||||
}), 429
|
||||
|
||||
validated, error = validate_idea_input(data)
|
||||
if error:
|
||||
return jsonify({"error": "validation_error", "message": error}), 400
|
||||
@@ -880,13 +1030,26 @@ def submit_consultation_idea(slug: str):
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return jsonify({
|
||||
resp_data = {
|
||||
"id": idea["id"],
|
||||
"accepted": accepted,
|
||||
"reason": rejection_reason,
|
||||
"legalBasis": legal_basis if not accepted else None,
|
||||
"idea": serialize_idea(idea),
|
||||
}), 201
|
||||
}
|
||||
response = make_response(jsonify(resp_data), 201)
|
||||
|
||||
if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
|
||||
response.set_cookie(
|
||||
"_cv",
|
||||
_sign_cooldown(secret_key),
|
||||
max_age=CONTRIBUTION_COOLDOWN_SECONDS,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
secure=request.is_secure,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/consultations/<slug>/export/print")
|
||||
@@ -982,12 +1145,36 @@ def admin_login():
|
||||
if not secret:
|
||||
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get("password", "")
|
||||
if password != secret:
|
||||
password = str(data.get("password", "") or "")
|
||||
if not hmac.compare_digest(password, secret):
|
||||
logger.warning("Tentative de connexion admin échouée")
|
||||
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
|
||||
logger.info("Connexion admin réussie")
|
||||
return jsonify({"ok": True, "token": _sign_admin_token(secret)})
|
||||
resp = jsonify({"ok": True})
|
||||
resp.set_cookie(
|
||||
"admin_session",
|
||||
_sign_admin_token(secret),
|
||||
max_age=28800,
|
||||
httponly=True,
|
||||
secure=request.is_secure,
|
||||
samesite="Strict",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/api/admin/logout")
|
||||
def admin_logout():
|
||||
"""Efface le cookie de session admin côté navigateur."""
|
||||
resp = jsonify({"ok": True})
|
||||
resp.delete_cookie("admin_session", samesite="Strict")
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/api/admin/session")
|
||||
@require_admin
|
||||
def admin_session_check():
|
||||
"""Permet au frontend de savoir si le cookie de session admin est encore valide."""
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/admin/stats")
|
||||
@@ -1006,7 +1193,9 @@ def admin_list_ideas():
|
||||
status = request.args.get("status", "all")
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
|
||||
search = request.args.get("q", "").strip()
|
||||
# Limite de longueur : une chaîne ILIKE arbitrairement longue coûte cher à
|
||||
# matcher sur chaque ligne (LIKE %...% force un scan complet quoi qu'il arrive).
|
||||
search = request.args.get("q", "").strip()[:200]
|
||||
|
||||
if status not in ("all", "accepted", "rejected", "flagged"):
|
||||
status = "all"
|
||||
@@ -1105,16 +1294,11 @@ def admin_regenerate_synthesis():
|
||||
@require_admin
|
||||
def admin_export_csv():
|
||||
"""Exporte toutes les contributions en CSV (admin)."""
|
||||
ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||
writer.writerow([
|
||||
"id", "content", "author", "accepted", "flagged", "flag_count",
|
||||
"rejection_reason", "legal_basis", "admin_note",
|
||||
"fingerprint_hash", "consultation_id", "created_at",
|
||||
])
|
||||
for idea in ideas:
|
||||
writer.writerow([
|
||||
def fetch_page(page, per_page):
|
||||
return get_ideas_admin(status="all", page=page, per_page=per_page)
|
||||
|
||||
def fields(idea):
|
||||
return [
|
||||
idea.get("id"),
|
||||
idea.get("content", ""),
|
||||
idea.get("author", ""),
|
||||
@@ -1127,9 +1311,15 @@ def admin_export_csv():
|
||||
idea.get("fingerprint_hash", ""),
|
||||
idea.get("consultation_id", ""),
|
||||
idea.get("created_at").isoformat() if idea.get("created_at") else "",
|
||||
])
|
||||
]
|
||||
|
||||
header = [
|
||||
"id", "content", "author", "accepted", "flagged", "flag_count",
|
||||
"rejection_reason", "legal_basis", "admin_note",
|
||||
"fingerprint_hash", "consultation_id", "created_at",
|
||||
]
|
||||
return Response(
|
||||
output.getvalue().encode("utf-8-sig"),
|
||||
_stream_csv_rows(header, fetch_page, fields),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||||
)
|
||||
@@ -1174,6 +1364,12 @@ def admin_create_consultation():
|
||||
if not slug or not title or not subject:
|
||||
return jsonify({"error": "validation_error", "message": "slug, title et subject sont requis."}), 400
|
||||
|
||||
if webhook_url and not _is_safe_webhook_url(webhook_url):
|
||||
return jsonify({
|
||||
"error": "validation_error",
|
||||
"message": "URL de webhook invalide ou pointant vers une adresse non autorisée (réseau interne).",
|
||||
}), 400
|
||||
|
||||
try:
|
||||
starts_at_str = data.get("startsAt", "")
|
||||
starts_at = dt.datetime.fromisoformat(starts_at_str) if starts_at_str else dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
Reference in new issue
Block a user