← toutes les CVE
writeupbug-bountysupabasepostgrestrlsfuite-d-informationscwe-200· 6 min de lecture

LUX-2026-002Fuite d'informations sur profiles : quand un grant anon en dit plus que l'interface

Un grant SELECT au niveau colonne trop large pour le rôle anon permettait à n'importe quel visiteur de lister tous les profils du site, UUID et rôle admin compris. Analyse, notation CVSS et correctif.

Lux
Identifiant
LUX-2026-002
Produit
API
CVSS
5.3 moyenne

En bref

  • Cible : comptes, profils, tickets. Type : fuite d'informations (CWE-200).
  • Un visiteur non authentifié, muni seulement de la clé apikey publique, pouvait lire pseudo, role, early_access, hunter, id, bio et link de tous les profils via l'API REST.
  • La colonne role désignait directement le compte administrateur, avec son UUID.
  • CVSS 3.1 : 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N). Palier retenu : Faible, pour les raisons détaillées plus bas.
  • Rapport reçu dans le cadre du bug bounty du site, signalé par [pseudo du hunter]. Merci à lui ou elle.

Contexte

Le site s'appuie sur Supabase : la base PostgreSQL est exposée directement au navigateur par PostgREST, sous /rest/v1/. Chaque requête porte une clé apikey publique (elle est dans le bundle JS, c'est normal) et s'exécute avec le rôle PostgreSQL anon tant que l'utilisateur n'est pas connecté.

Dans ce modèle, il n'y a pas de backend pour filtrer ce qui sort. La sécurité repose entièrement sur deux couches côté base :

  1. les privilèges (GRANT SELECT ... ON profiles TO anon), éventuellement par colonne ;
  2. les policies RLS (Row Level Security), qui décident quelles lignes un rôle peut voir.

Les deux sont indépendantes. Restreindre les colonnes ne restreint pas les lignes, et inversement.

Ce que l'application montre, et ce que l'API permettait

Côté interface, un visiteur ne voit un profil que dans un seul contexte : une discussion à laquelle cet utilisateur a participé. Le front affiche alors pseudo, avatar_url, role, early_access et hunter.

Côté API, le grant accordé à anon couvrait plus de colonnes (id, bio, link en plus) et aucun filtre de ligne ne limitait la lecture aux profils réellement visibles dans une discussion. Toute la table était donc lisible.

Diagramme (source ci-dessous)
Source du diagramme
flowchart LR
    V["Visiteur anonyme<br/>(apikey publique)"] --> API["PostgREST<br/>/rest/v1/profiles"]
    API --> G{"Grant colonne<br/>anon"}
    G -- "pseudo, role, early_access,<br/>hunter, id, bio, link" --> R{"Policy RLS<br/>sur les lignes ?"}
    G -- "notif_tickets, ..." --> X["401 refusé"]
    R -- "aucun filtre utile" --> T["Toute la table<br/>(8 profils)"]
    T --> A["Admin identifié :<br/>pseudo + UUID"]

Preuve

Toutes les requêtes sont anonymes, avec la seule clé publique.

GET /rest/v1/profiles?select=pseudo,role,early_access,hunter,id
→ 200 OK (8 lignes)
[
  {
    "pseudo": "Lux_",
    "role": "admin",
    "id": "a5e3388f-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "early_access": false,
    "hunter": false
  },
  "... 7 autres profils, role: \"user\""
]

(UUID masqué pour la publication.)

Les autres requêtes du rapport délimitent précisément le périmètre du grant :

RequêteRéponseLecture
select=pseudo,bio,link200 OKbio et link de tous les comptes lisibles
select=pseudo,notif_tickets401colonne privée correctement refusée
select=*403pas de grant global sur la table

Le comportement est cohérent avec un grant colonne par colonne : PostgreSQL refuse toute requête qui touche une colonne non accordée, d'où l'échec de select=*. Les colonnes réellement sensibles étaient donc bien protégées. Le problème est la liste des colonnes accordées et l'absence de filtre sur les lignes, pas une absence totale de contrôle.

Impact

N'importe qui pouvait récupérer :

  • la liste complète des membres (8 profils au moment du rapport) ;
  • l'UUID d'authentification de chaque compte, qui est aussi la clé de auth.users ;
  • la bio et le lien de chacun, y compris de membres qui n'ont jamais posté dans une discussion visible ;
  • surtout, l'identité du compte administrateur, désigné en clair par role: "admin".

Le rapport le formule bien : un attaquant dispose d'une cible admin précise (pseudo + UUID) avant même de s'authentifier. L'énumération de comptes « sans impact » est hors-scope du programme, mais ici l'exposition va plus loin, parce qu'elle révèle la structure des privilèges.

Ce que la faille ne permet pas : se connecter, modifier une donnée, lire une colonne privée, ou accéder à quoi que ce soit avec l'UUID seul. Elle n'a de valeur qu'en préparation d'une autre attaque (phishing ciblé sur l'admin, ou exploitation d'une autre faille où connaître l'UUID admin servirait).

Notation CVSS

Vecteur CVSS 3.1 : CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N → 5.3 (Moyenne)

MétriqueValeurJustification
Attack VectorNetworkune requête HTTP suffit
Attack ComplexityLowaucune condition particulière
Privileges RequiredNonerôle anon, clé publique
User InteractionNoneaucune action d'un utilisateur
ScopeUnchangedl'impact reste dans la base du site
ConfidentialityLowdonnées peu sensibles ; colonnes privées refusées
Integrity / AvailabilityNonelecture seule

Équivalent CVSS 4.0 : CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N → 6.9.

Pourquoi le palier Faible malgré le 5.3

Le 5.3 est mécanique : CVSS attribue C:L à toute fuite accessible sans authentification, quelle que soit la nature des données. Pour le palier de récompense, le contexte compte :

  • pseudo, bio et link sont publics par design : ils s'affichent déjà dans les discussions ;
  • un UUID n'est pas un secret d'authentification. Supabase le traite comme un identifiant, la sécurité repose sur le JWT et les policies ;
  • savoir qui est admin ne donne aucun accès sans une seconde vulnérabilité.

Le palier Faible proposé par le hunter est donc validé. Le rapport reste pertinent : il pointe un écart réel entre ce que l'application veut montrer et ce que la base autorise.

Remédiation

Trois mesures, de la plus structurante à la plus ponctuelle.

1. Retirer les grants qui n'ont pas lieu d'être. id, bio et link n'ont pas besoin d'être lisibles par anon sur toute la table.

REVOKE SELECT (id, bio, link) ON public.profiles FROM anon;

2. Exposer une vue dédiée limitée aux profils réellement liés à du contenu visible, au lieu d'ouvrir la table. La vue ne renvoie que les colonnes utiles au front et que les auteurs de messages publics.

CREATE VIEW public.profils_publics
WITH (security_invoker = false) AS
SELECT DISTINCT p.pseudo, p.avatar_url, p.early_access, p.hunter
FROM public.profiles p
JOIN public.[table_des_messages] m ON m.author_id = p.id
WHERE [condition de visibilité publique du message];

REVOKE ALL ON public.profiles FROM anon;
GRANT SELECT ON public.profils_publics TO anon;

(Les noms entre crochets dépendent du schéma réel.) La vue s'exécute avec les droits de son propriétaire (security_invoker = false) : c'est voulu, puisque anon n'a plus accès à la table, mais son filtre WHERE devient alors la seule barrière et doit être relu avec soin. Le front doit lire profils_publics au lieu de profiles.

3. Ne plus divulguer le rôle à un anonyme. Si l'interface doit afficher un badge « admin », mieux vaut l'exposer sous une forme décorative décidée côté vue (un booléen badge_staff, par exemple) qu'en recopiant la colonne de privilèges utilisée par les policies.

Statut : corrigé le : 02/11/2026.

Leçons

  • Une RLS sans grant serré, ou un grant sans RLS, ne suffit pas. Les colonnes et les lignes sont deux axes indépendants. Ici, les colonnes étaient partiellement filtrées, les lignes pas du tout.
  • Partir de l'usage réel du front. La bonne question n'est pas « cette colonne est-elle sensible ? » mais « l'interface en a-t-elle besoin pour un visiteur anonyme, et pour quelles lignes ? ». Tout ce qui dépasse est une surface gratuite.
  • Avec Supabase, l'API, c'est la base. Il n'y a pas de couche serveur pour rattraper un grant trop large : tester les accès avec la clé anon directement sur /rest/v1/ doit faire partie de la recette, comme le recommande déjà le brief du site.
  • Ne pas recycler une colonne de privilèges comme donnée d'affichage. role sert aux policies ; l'afficher publiquement revient à publier la carte des comptes à privilèges.
  • Les rapports « Faible » valent le coup. Celui-ci n'ouvrait aucune porte, mais il a mis en évidence un modèle d'accès plus permissif que prévu, ce qui aurait pu peser lourd le jour où une colonne sensible aurait été ajoutée au même grant.

Références

Markdown supporté · Ctrl+Entrée pour publierSe connecter pour publier

chargement de la discussion…