SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf
reponses possibles et se resolvait en lisant la question comme une chaine.
Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche
une etape a chaque personne, lecteur d ecran compris, contre une apparence
de protection — ce qui est pire que rien, puisque ca se compte comme une
protection.
L autre branche proposee par l audit etait un vrai service de CAPTCHA :
un tiers, une cle d API, une requete a chaque affichage, et un script
etranger remis dans script-src, defaisant le travail qui a ferme
SEC-WEB-001. Disproportionne pour le site d un club.
A la place, deux verifications invisibles pour un visiteur : un champ
piege, cache par la feuille de style et hors du parcours clavier, qu un
robot remplisseur complete et qu une personne ne voit jamais ; et un delai
minimal entre la remise du formulaire et son retour, l horodatage etant
dans la session signee et non dans un champ.
Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le
pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait
vraiment les inscriptions serait l activation des comptes par le staff —
is_active_account vaut True par defaut. C est une decision de produit.
L angle « session forgeable » du constat tombe : avec SECRET_KEY
compromise (SEC-001), on forge une session connectee sur n importe quel
compte et on n a aucune raison de s inscrire.
Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de
passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge
des mots de passe ne peut plus etre oubliee dans la cinquieme copie.
Les refus sont journalises avec leur motif — c est le seul endroit ou un
abus du formulaire devient visible — mais restent indistinguables pour
l expediteur : nommer la regle indique comment la contourner.
429 tests.
Le site s'affiche desormais integralement en francais. 483 chaines, aucune
non traduite, dans les deux catalogues.
Couvert : navigation et mise en page partagee, connexion, les cinq pages
d'erreur, et les 29 gabarits de pages.
Methode
Marquage semi-automatique, conservateur par construction : seuls des
motifs sans ambiguite sont balises -- contenu de balises de texte,
attributs placeholder/title/aria-label, texte suivant une icone, blocs
title et page_title -- et tout contenu comportant du Jinja ou du balisage
imbrique est laisse de cote. Deux passes, la seconde pour td, li, h6,
strong, em et caption.
97 entrees etaient marquees fuzzy par pybabel update, c'est-a-dire
devinees par similarite. Une entree fuzzy est **ignoree a l'execution** :
elles ont donc ete traitees comme non traduites, et le drapeau retire une
fois la traduction ecrite.
Deux pieges rencontres, tous deux documentes dans docs/translations.md
Une entite HTML n'est pas du texte. ×, utilise comme libelle de
bouton de fermeture, a ete balise par la passe automatique. Jinja
l'echappait alors en × et le bouton aurait affiche le texte
litteral × au lieu de la croix. Corrige dans cinq gabarits.
Huit chaines subsistent dans des blocs <script>. Elles fonctionnent mais
restent fragiles : Jinja echappe & < > " ' dans un bloc script, et ces
entites n'y sont pas decodees -- une traduction contenant une apostrophe
droite arriverait dans la chaine JavaScript sous la forme '. Le
francais retenu utilise des apostrophes typographiques, non echappees,
donc l'existant est sur. Toute nouvelle chaine a cet endroit devra passer
par un attribut data- ou un bloc <script type="application/json">.
Vocabulaire retenu -- a valider avec le club
tryout -> selection · manager -> gerant · coach -> coach (conserve, terme
d'usage en e-sport) · scout -> recruteur · email -> courriel · scrim ->
scrim. Les noms de jeux et les postes (Support, Duelist, AWPer) restent
en anglais : ce sont les termes employes par les joueurs.
Ces choix vivent dans un seul catalogue, donc chacun se change en un
endroit.
Reste a faire : les messages flash hors routes/auth.py, et les messages de
validation de app/validators.py, qui necessitent lazy_gettext puisque les
champs de schema sont construits a l'import.
Verifie : 13 pages parcourues dans les deux langues, aucune ne laisse de
marqueur Jinja non evalue ni d'entite doublement echappee. 193 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.
Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.
Deux motifs generiques de plus dans main.js
data-mirror affichage direct de la valeur d'un curseur.
evaluate_player repetait le meme
oninput="this.nextElementSibling.textContent = ..."
sur ses neuf curseurs de note.
data-submit-on-change remplace onchange="this.form.submit()"
Markup genere dans des chaines JavaScript
match_form construisait sept gestionnaires par concatenation, en y
injectant l'identifiant du joueur. Le markup portait deja data-player-id :
returnToPool et assignToTeam lisent desormais leurs arguments depuis
l'element clique. Cela supprime a la fois l'attribut en ligne et la
concatenation qui l'alimentait. Meme motif que dans coach_availability.
Bascule
CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
La variable d'environnement reste, comme issue de secours si un
deploiement rencontrait un gestionnaire oublie -- mais la laisser active
revient a renoncer a la protection.
Le cliquet devient une garde
Le budget par gabarit est vide et les tests deviennent absolus : aucun
gestionnaire en ligne, et tout bloc <script> inline doit porter son
nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
journaux ne le signale -- d'ou le test.
Verifications
22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
par node --check.
193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.
Co-Authored-By: Claude Opus 5 <[email protected]>
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.
Six motifs recurrents, generalises dans main.js plutot que traites un a un
data-action clic, resolu par un ecouteur delegue
data-change changement -- attribut distinct du clic, sans quoi un
<select> declencherait son gestionnaire des le clic
qui l'ouvre
data-confirm confirmation avant un envoi destructeur, en
remplacement de onsubmit="return confirm(...)". Le
texte reste dans le markup, donc traduisible.
data-navigate navigation sur selection, {value} etant encode
remove-element suppression d'un ancetre designe par data-remove
history-back retour arriere
registerActions()
Les fonctions propres a une page vivent dans son bloc de script et ne
peuvent donc pas figurer dans la table globale. Chaque page declare les
siennes, l'ecouteur delegue reste unique.
Cas particulier, coach_availability
Le gestionnaire y etait construit dans une chaine JavaScript, au moment
de generer la grille de creneaux. Le markup portait deja data-day et
data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
qui supprime a la fois l'attribut en ligne et la concatenation.
Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.
Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.
Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.
A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.
Ce qui bloque reellement
Un nonce autorise des elements <script> ; il ne peut rien pour un
attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
durcie.
Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
les deux ne serait donc pas une transition douce -- ce serait couper
d'un coup tous les scripts en ligne et tous les onclick, et uniquement
sur les navigateurs recents. La bascule doit etre atomique, d'ou un
drapeau unique : CSP_ALLOW_INLINE_SCRIPT.
Infrastructure posee
build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
<script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
bascule finale sera un changement de configuration, pas de gabarits.
Couche partagee migree en premier
base.html et macros.html sont rendus sur absolument toutes les pages. Six
gestionnaires retires, remplaces par des attributs data-action et un
ecouteur delegue unique dans main.js. La delegation plutot qu'un
ecouteur par widget : le contenu injecte dynamiquement herite du
comportement sans re-attachement.
Un cliquet plutot qu'une promesse
tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
mettre le budget a jour aussi, ce qui force a enregistrer la progression
dans le diff. A zero, il ne reste qu'a basculer le drapeau.
Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
verifiait pas l'espace avant l'attribut.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
Le site s'affiche desormais en francais par defaut, avec un selecteur de
langue permettant de basculer vers l'anglais.
Choix de conception : les chaines sources restent en anglais
Elles servent d'identifiants gettext, et le francais est fourni par
catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une
seule langue -- la meme que ses commentaires et docstrings -- tandis que
ce qu'un membre voit est du francais.
Consequence qui rend la migration praticable : une chaine non encore
traduite retombe en anglais, pas sur un identifiant brut. Les gabarits
peuvent donc etre migres un par un sans jamais laisser le site a moitie
casse.
Selection de la langue (app/i18n.py)
1. choix explicite via le selecteur, garde en session
2. sinon en-tete Accept-Language du navigateur, restreint a fr et en
3. sinon francais
Un choix explicite prime toujours, y compris sur un navigateur anglophone.
Selecteur
Extrait en partiel et inclus dans les deux branches de la mise en page :
barre laterale une fois connecte, ET page d'authentification. Quelqu'un
qui ne lit pas la langue courante doit pouvoir en changer AVANT de se
connecter -- le laisser derriere l'authentification aurait ete un defaut
d'accessibilite. Chaque langue est ecrite dans sa propre langue.
La route /lang/<locale> valide le Referer avant de rediriger : sans ce
controle, elle constituait une redirection ouverte.
Migre dans cette passe
navigation complete, page de connexion, les cinq pages d'erreur, et
l'integralite des messages flash de routes/auth.py. 64 chaines, dont
aucune non traduite.
Verification
25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant
ou une entree non traduite font echouer la suite. Sans cela, une
compilation oubliee servirait de l'anglais partout, en silence et sans
rien dans les journaux.
Un test existant a du etre corrige, et c'est instructif
test_login_failure_message_does_not_reveal_account_existence cherchait la
sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en
francais, elle etait absente des deux cotes, l'assertion passait, et le
mode strict a signale le faux succes. Le test comparait donc l'anglais,
pas le comportement. Il compare desormais les messages flash rendus,
quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le
test la documente toujours.
Les catalogues .po ET .mo sont versionnes : le deploiement est un simple
miroir de fichiers, sans etape de compilation. messages.pot, regenerable,
ne l'est pas.
docs/translations.md documente le processus, les deux pieges (concatenation
de phrases, traduction a l'import), et l'etat de la migration. A noter pour
la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees
telles quelles, il faudra les passer par des attributs data- -- ce qui
rejoint le chantier de sortie de unsafe-inline (OPS-010).
Co-Authored-By: Claude Opus 5 <[email protected]>
SEC-XSS-001. Chaine complete : un nom d'utilisateur libre arrivait dans le
DOM d'un coach ou d'un administrateur, en meme origine, avec sa session.
La CSP autorisant 'unsafe-inline', rien ne l'arretait.
Cote serveur - la cause
/matches/api/events construisait de la presentation dans un champ JSON :
match_desc = participants_str + f"<br>{match.description}"
Le navigateur deposait cette valeur telle quelle dans innerHTML. Les noms
de joueurs y transitaient sans echappement -- et il ne pouvait pas y en
avoir : c'est du JSON, pas du HTML.
Les deux valeurs etaient deja des cles distinctes du payload. La
concatenation faisait donc aussi afficher les participants deux fois dans
le modal : une fois dans "Teams", une fois en tete de "Description".
Corriger la faille corrige l'affichage.
Cote navigateur - le sink
showEventModal assemblait une chaine HTML puis l'affectait a innerHTML.
Remplace par une construction de noeuds : makeEl / detailItem /
multilineNode / teamNode passent tout texte par textContent. Les retours
a la ligne d'une description restent rendus, via des <br> crees en dur.
Les deux listes deroulantes concatenaient egalement titres de tryout et
noms d'equipe dans innerHTML. Remplacees par new Option(), dont le
premier argument est pose en texte.
Verification
5 tests sur le contrat de l'API, dont un avec un nom d'utilisateur
hostile ecrit directement en base -- ce que la validation refuse
desormais, mais que des lignes anterieures peuvent contenir.
Le JavaScript inline extrait passe `node --check`.
Reste ouvert : la CSP autorise toujours 'unsafe-inline' (SEC-WEB-001), donc
la defense en profondeur manque encore. Suivi en OPS-010.
Co-Authored-By: Claude Opus 5 <[email protected]>