--- title: L'API publique répond sans clé API area: backend tags: manager-service, sécurité flag: critical | Le contenu de n'importe quelle instance est lisible par qui connaît son identifiant src: conversation 08/09 — vérification de la préprod depuis internet · audit complet 12/09 ---

Constaté le 08/09 sur la préprod, depuis internet, sans authentification :

curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047"
→ 200, avec le contenu complet et ses traductions

La même requête avec X-Api-Key renvoie le même 200. Cause exacte, vérifiée le 08/09 : Get (ConfigurationController.cs:52) et GetDetailAsync (:117) portent un [AllowAnonymous] explicite. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (client.dart:54), et le mécanisme existe et fonctionne : ApiKeyAuthenticationHandler + la policy AppReadAccess, déjà utilisée par byPin (:83) et export (:383) du même contrôleur.

Ce que ça expose : le contenu éditorial de n'importe quelle instance, à qui connaît un instanceId — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.

Audit complet fait le 12/0940 routes anonymes recensées, dont 2 seulement contrôlent la clé (Configuration/{id}/export et Instance/{id}, tous deux corrigés en septembre). Le trou est donc général, pas ponctuel : 24 routes de contenu sont ouvertes — Configuration (2), Section (4), Resource (2), SectionMap (3), SectionEvent (3), SectionAgenda (2), SectionParcours (2), SectionQuiz (1), ApplicationInstance (2).

🔴 Trouvaille plus grave que la carte, corrigée le 12/09. GET /api/Instance/slug/{slug} était anonyme et sans filtrage : il rendait le pinCode — celui qui ouvre l'appairage des tablettes et des casques — plus l'adresse de facturation, le numéro de TVA, les quotas et le plan du client. Et le slug n'est pas un secret : c'est l'URL du site visiteur (app.myinfomate.be/{slug}). La fonction de filtrage StripCommercialFields existait déjà, avec un commentaire qui nommait le risque du pinCode — elle n'était simplement pas appelée ici. Corrigé sur slug et sur byPin, avec 3 tests qui verrouillent le contrat (243 tests au vert). Deux champs restent exposés à dessein : publicApiKey, sans laquelle visitapp-web ne peut pas s'amorcer, et isTrialActive, qui sert au filigrane d'essai affiché au visiteur.

⚠️ Ce que fermer les 24 routes apportera vraiment — et ce que ça n'apportera pas. La clé s'obtient anonymement : par le slug (qui est dans l'URL) ou par app-key avec le pincode. Après fermeture, le contenu ne sera donc pas confidentiel : il sera lisible par qui lit une URL, au lieu de qui devine un instanceId. Le gain réel est ailleurs, et il compte : plus d'énumération par identifiant, un accès tracé et révocable, et un seul chemin d'entrée au lieu de vingt-quatre. Rendre le contenu réellement privé serait un autre chantier, avec une autre décision produit.

Reste à faire : fermer les 24 routes de contenu. Deux obstacles connus. Un : [Authorize] de classe et d'action se combinent — c'est pour ça qu'export passe par [AllowAnonymous] + contrôle manuel ; il faut donc un attribut réutilisable, pas un simple changement de policy. Deux : deux routes doivent rester anonymes et c'est écrit dans le code de visitapp-webinstance/slug/{slug} (l'amorce, qui distribue la clé) et ApplicationInstance?instanceId= (la page /download, où le visiteur arrive d'un QR imprimé sans slug ni clé).