Durcit la sécurité : plafond de score réaliste, REVOKE explicites, banque de questions du Char réservée aux juges
Build and deploy / deploy (push) Successful in 36s

Un score falsifié envoyé directement à submit_icarus_score/submit_melon_score pouvait se convertir en vraies gloires au Tribunal (plafond abaissé de 1 000 000 à 5000, contrainte + RPC). Les fonctions cron-only award_*_points_if_due() n'étaient protégées que par un commentaire, pas un vrai REVOKE. Le bucket avatars n'avait aucune limite de taille/type. La banque complète des questions du Char (spoiler du jeu) était lisible par tout Citoyen via /char ou un appel direct — RLS restreinte aux juges, /char passe désormais par la RPC chariot_revealed_question().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-24 04:41:39 +02:00
parent 3503dc2593
commit dc99af6bb3
6 changed files with 145 additions and 45 deletions
+97 -11
View File
@@ -56,6 +56,21 @@ alter table public.profiles enable row level security;
-- trigger handle_new_user() (SECURITY DEFINER, contourne la RLS).
grant select, update on public.profiles to authenticated;
-- 1bis. Durcissement de sécurité (audit) --------------------------------------
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut sur toute fonction créée,
-- sauf révocation explicite. Plusieurs RPC de ce fichier n'étaient
-- "protégées" que par un commentaire ("pas de grant à authenticated") sans
-- REVOKE réel — donc en réalité appelables par n'importe quel utilisateur
-- authentifié via l'API REST, malgré l'intention. Cette ligne change le
-- comportement par défaut pour TOUTE fonction créée après elle dans ce
-- script (donc pour toutes les fonctions ci-dessous) : plus aucune RPC
-- n'est exécutable sans un `grant execute` explicite. Les RPC déjà
-- existantes avant cette ligne conservent leurs anciens privilèges tant
-- qu'on ne les révoque pas explicitement — voir les REVOKE ciblés plus bas
-- pour award_icarus_points_if_due()/award_melon_points_if_due(), qui en
-- avaient besoin rétroactivement.
alter default privileges in schema public revoke execute on functions from public;
-- 2. Fonctions & triggers -------------------------------------------------
-- La RLS est au niveau ligne : elle ne peut pas exprimer "cette colonne
-- seulement si tel rôle". On verrouille donc les colonnes sensibles
@@ -202,9 +217,17 @@ create policy "Judges can update any profile"
-- 4. Bucket avatars -------------------------------------------------------
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- file_size_limit/allowed_mime_types ajoutés (audit de sécurité) : sans
-- ça, un appel direct à l'API de Storage (hors de l'app, où l'upload est
-- toujours compressé côté client via cropImageToBlob) pouvait uploader un
-- fichier arbitrairement gros ou non-image comme "avatar" — même
-- principe que sur le bucket wall-images.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', true, 3145728, array['image/webp', 'image/jpeg', 'image/png'])
on conflict (id) do update set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
-- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une
-- policy SELECT reste nécessaire : pour un upload en upsert (remplacement
@@ -524,6 +547,16 @@ create table if not exists public.icarus_scores (
updated_at timestamptz not null default now()
);
-- Plafond abaissé (audit de sécurité) : 1 000 000 était un score
-- absurdement irréaliste, ce qui rendait triviale la soumission d'un
-- score inventé directement via l'API pour se faire attribuer des gloires
-- au Tribunal — voir la contrainte identique sur melon_scores. La
-- contrainte inline de create table ne s'applique qu'à la création ; sur
-- une table déjà existante il faut explicitement la remplacer.
alter table public.icarus_scores drop constraint if exists icarus_scores_best_score_check;
alter table public.icarus_scores add constraint icarus_scores_best_score_check
check (best_score between 0 and 5000);
alter table public.icarus_scores enable row level security;
-- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule
@@ -558,7 +591,11 @@ begin
raise exception 'authentication required';
end if;
if p_score is null or p_score < 0 or p_score > 1000000 then
-- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie
-- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de
-- table associée (best_score_check), la vraie garantie ; cette
-- vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 5000 then
raise exception 'invalid score';
end if;
@@ -593,8 +630,11 @@ end $$;
-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la
-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore
-- passée, et no-op définitif une fois déjà fait (icarus_points_awarded).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
-- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite
-- ci-dessous (ne pas se contenter de "ne pas accorder à authenticated" —
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut, donc sans révocation
-- explicite cette fonction restait appelable par n'importe qui via l'API
-- REST malgré l'intention).
create or replace function public.award_icarus_points_if_due()
returns void
language plpgsql
@@ -632,6 +672,8 @@ begin
end;
$$;
revoke execute on function public.award_icarus_points_if_due() from public, anon, authenticated;
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
-- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce
-- fichier, et pas garanti self-service selon le plan/la région du projet.
@@ -676,9 +718,18 @@ create table if not exists public.chariot_questions (
alter table public.chariot_questions enable row level security;
grant select, insert, update, delete on public.chariot_questions to authenticated;
-- Lecture réservée aux juges (audit de sécurité) : la banque complète est un
-- spoiler du jeu en cours pour les Citoyens tant qu'une question n'a pas été
-- révélée par un Archonte. /char (accessible à tous) n'a plus le droit de
-- lire cette table directement — il passe par la RPC
-- chariot_revealed_question() ci-dessous, qui ne renvoie que la question
-- actuellement révélée. Seul /char/questions (déjà réservé aux juges) lit
-- encore cette table directement.
drop policy if exists "chariot_questions viewable by authenticated" on public.chariot_questions;
create policy "chariot_questions viewable by authenticated"
on public.chariot_questions for select to authenticated using (true);
drop policy if exists "chariot_questions viewable by judges" on public.chariot_questions;
create policy "chariot_questions viewable by judges"
on public.chariot_questions for select to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
drop policy if exists "chariot_questions insert by judges" on public.chariot_questions;
create policy "chariot_questions insert by judges" on public.chariot_questions for insert to authenticated
@@ -700,6 +751,28 @@ exception
when duplicate_object then null;
end $$;
-- Seul moyen pour un non-juge de savoir quelle question est actuellement
-- révélée sans jamais lire la banque complète (RLS ci-dessus, juges
-- uniquement) : renvoie zéro ligne tant qu'aucune question n'est révélée.
-- question_number est calculé sur l'ordre complet (position, created_at)
-- pour afficher "Question N" sur /char sans exposer les autres lignes.
create or replace function public.chariot_revealed_question()
returns table (id uuid, text text, question_number integer)
language sql
security definer
set search_path = public
as $$
select numbered.id, numbered.text, numbered.question_number
from (
select q.id, q.text,
row_number() over (order by q.position, q.created_at)::integer as question_number
from public.chariot_questions q
) numbered
where numbered.id = (select s.chariot_revealed_question_id from public.settings s where s.id = true);
$$;
grant execute on function public.chariot_revealed_question() to authenticated;
-- Nettoyage de l'ancien modèle par statut (team_a/team_b/out), remplacé par
-- un modèle par emplacement fixe ci-dessous — schéma jamais appliqué en
-- prod (retour utilisateur avant la première vraie migration), DROP direct.
@@ -1435,6 +1508,12 @@ create table if not exists public.melon_scores (
updated_at timestamptz not null default now()
);
-- Plafond abaissé (audit de sécurité) : voir la remarque identique sur
-- icarus_scores.
alter table public.melon_scores drop constraint if exists melon_scores_best_score_check;
alter table public.melon_scores add constraint melon_scores_best_score_check
check (best_score between 0 and 5000);
alter table public.melon_scores enable row level security;
-- Même modèle que icarus_scores : verrouillée en écriture, seule la RPC
@@ -1469,7 +1548,11 @@ begin
raise exception 'authentication required';
end if;
if p_score is null or p_score < 0 or p_score > 1000000 then
-- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie
-- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de
-- table associée (best_score_check), la vraie garantie ; cette
-- vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 5000 then
raise exception 'invalid score';
end if;
@@ -1504,8 +1587,9 @@ end $$;
-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la
-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore
-- passée, et no-op définitif une fois déjà fait (melon_points_awarded).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
-- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite
-- ci-dessous (voir la même remarque pour award_icarus_points_if_due —
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut sans révocation explicite).
create or replace function public.award_melon_points_if_due()
returns void
language plpgsql
@@ -1543,6 +1627,8 @@ begin
end;
$$;
revoke execute on function public.award_melon_points_if_due() from public, anon, authenticated;
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
-- (Database → Extensions → "pg_cron" → Enable) — voir la même remarque à la
-- section Icare. Les deux blocs ci-dessous n'échouent jamais bruyamment si