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
+19 -7
View File
@@ -66,20 +66,21 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
### `public.icarus_scores` (Le Vol d'Icare)
- `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`.
- Contrainte `check (best_score between 0 and 5000)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée.
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
### `public.melon_scores` (La Corne d'Abondance)
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`).
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), y compris la contrainte `check (best_score between 0 and 5000)`.
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`.
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`).
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, même borne de score et même `execute` révoqué sur la fonction cron-only — voir §3bis).
- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens.
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : lecture ouverte, écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`.
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. Même RLS que `chariot_questions`.
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : **lecture réservée aux juges** (audit de sécurité — la liste complète serait un spoiler du jeu en cours pour un Citoyen qui la lirait directement, RLS ouverte à tout le monde jusque-là), écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`. `/char` (accessible à tous) ne lit plus jamais cette table directement : il passe par la RPC `chariot_revealed_question()`, qui ne renvoie que la question actuellement révélée (avec son numéro d'ordre, calculé côté serveur pour ne jamais exposer la position des autres questions).
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. RLS : lecture ouverte à tout le monde (contrairement à `chariot_questions` depuis l'audit — rien à cacher ici, l'occupation des emplacements est publique par nature), écriture réservée aux juges.
- `chariot_entries` : `id, user_id, created_at` — journal append-only (comme `points_log`) d'une ligne à chaque affectation d'un Citoyen à un emplacement, sert uniquement à compter combien de fois chacun est monté dans le char (`count(*) group by user_id`, calculé côté client). RLS : lecture ouverte, insertion réservée aux juges, aucune mise à jour/suppression possible.
- `settings.chariot_revealed_question_id` : question actuellement affichée sur `/char`, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis `/char/questions`.
- Préfixe `chariot_*` (pas `char_*`) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.
@@ -104,15 +105,26 @@ Même pattern sur `public.settings` depuis V7, via `enforce_settings_update()` :
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
### §3bis. Durcissement de sécurité (audit)
PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, sauf révocation explicite — un commentaire disant « pas de grant à authenticated » **n'est pas** une vraie restriction d'accès si le `REVOKE` correspondant n'est pas écrit, la fonction reste appelable via l'endpoint REST RPC de Supabase. `schema.sql` ouvre donc avec `alter default privileges in schema public revoke execute on functions from public;` (s'applique aux fonctions créées après cette ligne dans le script, pas rétroactivement) et chaque fonction cron-only (`award_icarus_points_if_due`, `award_melon_points_if_due`) porte en plus un `revoke execute ... from public, anon, authenticated;` explicite juste après sa définition.
Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond ramené à une valeur généreuse mais réaliste (5000), appliqué à deux niveaux : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
Bucket Storage `avatars` : `file_size_limit`/`allowed_mime_types` ajoutés (`on conflict (id) do update`, pas `do nothing`, pour que ré-exécuter `schema.sql` applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours `cropImageToBlob` qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que `wall-images` (3 Mo, image/webp+jpeg+png).
Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seulement `schema.sql`) : la RLS `select` de `chariot_questions` était ouverte à tout authentifié (`using (true)`), et `/char/page.tsx` en lisait la table entière pour l'embarquer dans le payload RSC envoyé à tout le monde — un Citoyen pouvait donc lire la banque complète des questions à venir (spoiler du jeu de la soirée) simplement en inspectant la page ou en rejouant l'appel Supabase depuis la console, alors même que `/char` n'en affiche jamais qu'une seule à l'écran. Corrigé en deux temps : RLS `select` de `chariot_questions` restreinte aux juges (seul `/char/questions`, déjà réservé aux juges, en a encore besoin), et nouvelle RPC `chariot_revealed_question()` qui ne renvoie que la question révélée (avec son numéro d'ordre) pour que `/char` n'ait plus jamais besoin de lire la table directement.
### RPC exposées
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu.
- `award_icarus_points_if_due()`**pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle, voir §3bis).
- `award_icarus_points_if_due()`**`execute` explicitement révoqué de `public`/`anon`/`authenticated`** (voir §3bis), appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
- `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes.
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris), y compris `image_url`, **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes).
- `post_wall_note(p_text, p_image_url default null)` — authenticated, rejette les juges (seuls les Citoyens publient), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`).
@@ -127,7 +139,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. `image_url` est facultative, pointe vers le bucket `wall-images`.
- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`.
- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`.
### `public.urn_votes` (L'Urne de l'Agora, V10)