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:
billisdead committed 2026-09-10 09:27:50 +02:00
1 parent 2c0bb379c9
commit defc07648c
10 files changed
+382 -116

No files matched your search

+81 -54
View File
@@ -32,6 +32,18 @@ import {
const API_BASE = import.meta.env.VITE_API_URL ?? "";
/**
* fetch() vers l'API admin — envoie toujours le cookie de session httpOnly
* (jamais lu ni stocké côté JS) et pose le Content-Type JSON par défaut.
*/
function adminFetch(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
});
}
// ─── Types ────────────────────────────────────────────────────────────────────
type Idea = {
@@ -97,27 +109,38 @@ type Consultation = {
// ─── Hook auth ────────────────────────────────────────────────────────────────
/**
* L'authentification vit dans un cookie httpOnly (invisible à JS, jamais dans
* sessionStorage) — au chargement, on interroge /api/admin/session pour savoir
* s'il est encore valide. `authed === null` = vérification en cours.
*/
function useAdminAuth() {
const [token, setToken] = useState<string | null>(() =>
sessionStorage.getItem("admin_token")
);
const login = (t: string) => {
sessionStorage.setItem("admin_token", t);
setToken(t);
const [authed, setAuthed] = useState<boolean | null>(null);
const checkSession = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/session");
setAuthed(res.ok);
} catch {
setAuthed(false);
}
}, []);
useEffect(() => { checkSession(); }, [checkSession]);
const login = () => setAuthed(true);
const logout = async () => {
try {
await adminFetch("/api/admin/logout", { method: "POST" });
} catch { /* ignore */ }
setAuthed(false);
};
const logout = () => {
sessionStorage.removeItem("admin_token");
setToken(null);
};
const headers = token
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
: { "Content-Type": "application/json" };
return { token, login, logout, headers };
return { authed, login, logout };
}
// ─── Composants utilitaires ───────────────────────────────────────────────────
function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) {
function LoginPanel({ onLogin }: { onLogin: () => void }) {
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -128,14 +151,13 @@ function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) {
setLoading(true);
setError("");
try {
const res = await fetch(`${API_BASE}/api/admin/login`, {
const res = await adminFetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) { setError(data.message || "Mot de passe incorrect."); return; }
onLogin(data.token);
onLogin();
toast({ title: "Connecté", description: "Bienvenue dans le panel admin." });
} catch {
setError("Erreur réseau. Vérifiez que l'API est accessible.");
@@ -192,7 +214,7 @@ function StatsBadge({ label, value, color }: { label: string; value: number; col
// ─── Panel Consultations ──────────────────────────────────────────────────────
function ConsultationsPanel({ headers, token }: { headers: Record<string, string>; token: string }) {
function ConsultationsPanel() {
const { toast } = useToast();
const [consultations, setConsultations] = useState<Consultation[]>([]);
const [loading, setLoading] = useState(false);
@@ -216,11 +238,11 @@ function ConsultationsPanel({ headers, token }: { headers: Record<string, string
const fetchConsultations = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/admin/consultations`, { headers });
const res = await adminFetch("/api/admin/consultations");
if (res.ok) setConsultations(await res.json());
} catch { /* ignore */ }
finally { setLoading(false); }
}, [token]);
}, []);
useEffect(() => { fetchConsultations(); }, [fetchConsultations]);
@@ -240,9 +262,8 @@ function ConsultationsPanel({ headers, token }: { headers: Record<string, string
setCreating(true);
setCreateError("");
try {
const res = await fetch(`${API_BASE}/api/admin/consultations`, {
const res = await adminFetch("/api/admin/consultations", {
method: "POST",
headers,
body: JSON.stringify({
slug: fSlug, title: fTitle, subject: fSubject,
introMessage: fIntro || undefined,
@@ -271,8 +292,8 @@ function ConsultationsPanel({ headers, token }: { headers: Record<string, string
const handleClose = async () => {
if (!closeTarget) return;
const res = await fetch(`${API_BASE}/api/admin/consultations/${closeTarget.slug}/close`, {
method: "POST", headers,
const res = await adminFetch(`/api/admin/consultations/${closeTarget.slug}/close`, {
method: "POST",
});
if (res.ok) {
toast({ title: "Consultation fermée", description: "Le webhook a été déclenché si configuré." });
@@ -285,8 +306,8 @@ function ConsultationsPanel({ headers, token }: { headers: Record<string, string
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await fetch(`${API_BASE}/api/admin/consultations/${deleteTarget.slug}`, {
method: "DELETE", headers,
const res = await adminFetch(`/api/admin/consultations/${deleteTarget.slug}`, {
method: "DELETE",
});
if (res.ok) {
toast({ title: "Consultation supprimée" });
@@ -559,7 +580,7 @@ function ConsultationsPanel({ headers, token }: { headers: Record<string, string
// ─── Panel Blacklist IP ───────────────────────────────────────────────────────
function BlacklistPanel({ headers }: { headers: Record<string, string> }) {
function BlacklistPanel() {
const [entries, setEntries] = React.useState<IpBlacklistEntry[]>([]);
const [loading, setLoading] = React.useState(false);
const { toast } = useToast();
@@ -567,19 +588,18 @@ function BlacklistPanel({ headers }: { headers: Record<string, string> }) {
const fetchEntries = React.useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/admin/ip-blacklist`, { headers });
const res = await adminFetch("/api/admin/ip-blacklist");
if (res.ok) setEntries(await res.json());
} finally {
setLoading(false);
}
}, [headers]);
}, []);
React.useEffect(() => { fetchEntries(); }, [fetchEntries]);
const lift = async (ipHash: string) => {
const res = await fetch(`${API_BASE}/api/admin/ip-blacklist/${ipHash}`, {
const res = await adminFetch(`/api/admin/ip-blacklist/${ipHash}`, {
method: "DELETE",
headers,
});
if (res.ok) {
toast({ title: "Blacklist levée", description: `Hash ${ipHash.slice(0, 8)}… retiré.` });
@@ -653,7 +673,7 @@ function BlacklistPanel({ headers }: { headers: Record<string, string> }) {
// ─── Composant principal Admin ────────────────────────────────────────────────
export default function Admin() {
const { token, login, logout, headers } = useAdminAuth();
const { authed, login, logout } = useAdminAuth();
const { toast } = useToast();
const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist">("contributions");
@@ -674,36 +694,36 @@ export default function Admin() {
const [regenLoading, setRegenLoading] = useState(false);
const fetchStats = useCallback(async () => {
if (!token) return;
if (!authed) return;
try {
const res = await fetch(`${API_BASE}/api/admin/stats`, { headers });
const res = await adminFetch("/api/admin/stats");
if (res.ok) setStats(await res.json());
} catch { /* ignore */ }
}, [token, headers]);
}, [authed]);
const fetchList = useCallback(async () => {
if (!token) return;
if (!authed) return;
setLoading(true);
try {
const params = new URLSearchParams({
status, page: String(page), per_page: "50",
...(search ? { q: search } : {}),
});
const res = await fetch(`${API_BASE}/api/admin/ideas?${params}`, { headers });
const res = await adminFetch(`/api/admin/ideas?${params}`);
if (res.ok) {
setList(await res.json());
setSelected(new Set());
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [token, headers, status, page, search]);
}, [authed, status, page, search]);
useEffect(() => {
if (token && activePanel === "contributions") { fetchStats(); fetchList(); }
}, [token, fetchStats, fetchList, activePanel]);
if (authed && activePanel === "contributions") { fetchStats(); fetchList(); }
}, [authed, fetchStats, fetchList, activePanel]);
const deleteOne = async (id: number) => {
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}`, { method: "DELETE", headers });
const res = await adminFetch(`/api/admin/ideas/${id}`, { method: "DELETE" });
if (res.ok) {
toast({ title: "Contribution supprimée", description: "La synthèse est en cours de mise à jour." });
fetchStats(); fetchList();
@@ -715,8 +735,8 @@ export default function Admin() {
const bulkDelete = async () => {
const ids = Array.from(selected);
const res = await fetch(`${API_BASE}/api/admin/ideas/bulk-delete`, {
method: "POST", headers, body: JSON.stringify({ ids }),
const res = await adminFetch("/api/admin/ideas/bulk-delete", {
method: "POST", body: JSON.stringify({ ids }),
});
if (res.ok) {
const data = await res.json();
@@ -730,8 +750,8 @@ export default function Admin() {
const override = async () => {
if (!overrideTarget) return;
const res = await fetch(`${API_BASE}/api/admin/ideas/${overrideTarget.id}/override`, {
method: "POST", headers,
const res = await adminFetch(`/api/admin/ideas/${overrideTarget.id}/override`, {
method: "POST",
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
});
if (res.ok) {
@@ -744,13 +764,13 @@ export default function Admin() {
};
const unflag = async (id: number) => {
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}/unflag`, { method: "POST", headers });
const res = await adminFetch(`/api/admin/ideas/${id}/unflag`, { method: "POST" });
if (res.ok) { toast({ title: "Signalement retiré" }); fetchList(); }
};
const regenerate = async () => {
setRegenLoading(true);
const res = await fetch(`${API_BASE}/api/admin/synthesis/regenerate`, { method: "POST", headers });
const res = await adminFetch("/api/admin/synthesis/regenerate", { method: "POST" });
if (res.ok) toast({ title: "Régénération lancée", description: "Mise à jour dans quelques secondes." });
setRegenLoading(false);
};
@@ -759,7 +779,7 @@ export default function Admin() {
const url = `${API_BASE}/api/admin/export/csv`;
const req = new XMLHttpRequest();
req.open("GET", url);
req.setRequestHeader("Authorization", `Bearer ${token}`);
req.withCredentials = true; // envoie le cookie de session httpOnly
req.responseType = "blob";
req.onload = () => {
if (req.status !== 200) {
@@ -799,7 +819,14 @@ export default function Admin() {
setPage(1);
};
if (!token) return <LoginPanel onLogin={login} />;
if (authed === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!authed) return <LoginPanel onLogin={login} />;
const TABS = [
{ key: "all", label: "Toutes" },
@@ -873,13 +900,13 @@ export default function Admin() {
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
{/* ─── Panel Consultations ─── */}
{activePanel === "consultations" && token && (
<ConsultationsPanel headers={headers} token={token} />
{activePanel === "consultations" && (
<ConsultationsPanel />
)}
{/* ─── Panel Blacklist IP ─── */}
{activePanel === "blacklist" && token && (
<BlacklistPanel headers={headers} />
{activePanel === "blacklist" && (
<BlacklistPanel />
)}
{/* ─── Panel Contributions ─── */}