fix(security): proteger le dernier administrateur, et fermer le CORS permissif

SEC-AUTHZ-007 - auto-verrouillage de l'administration
  Le changement de role n'excluait ni l'utilisateur courant, ni le dernier
  compte admin actif. Une seule manipulation suffisait a transformer le seul
  president en joueur, et plus aucune interface ne permettait de revenir en
  arriere : il fallait une intervention directe en base.

  Deux gardes distinctes, parce que ce sont deux erreurs differentes :
    - changer son propre role est refuse, meme s'il reste d'autres admins.
      Un president qui veut se retrograder doit le faire faire par un autre.
    - retrograder le dernier admin actif est refuse.
  Le decompte exclut les comptes desactives : trois admins dont deux
  desactives, cela fait un seul administrateur reel.

SEC-WEB-003 - CORS ouvert par defaut
  Sans CORS_ALLOWED_ORIGINS, la branche else appelait
  CORS(app, supports_credentials=True) sans argument origins. flask-cors
  retient alors '*' et, les identifiants etant autorises, renvoie en echo
  l'Origin de l'appelant avec Access-Control-Allow-Credentials: true --
  l'inverse exact de ce qu'annonçait le commentaire.

  L'exploitation etait bloquee par SESSION_COOKIE_SAMESITE = 'Lax', qui
  empeche le navigateur de joindre le cookie de session a une requete
  fetch inter-site. Toute la protection tenait donc a ce seul reglage.
  Cette application rend du HTML en meme origine : elle n'a besoin
  d'aucune politique CORS. La branche par defaut est supprimee, la
  configuration explicite reste possible.

5 tests ajoutes, dont deux verifient que les chemins legitimes continuent
de fonctionner : un autre administrateur reste retrogradable, et une
origine explicitement configuree est toujours honoree.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 20:03:50 -04:00
co-authored by Claude Opus 5
parent ab44258d72
commit ca45be80db
3 changed files with 82 additions and 14 deletions
+19
View File
@@ -166,6 +166,25 @@ def edit_user(user_id):
flash('Email already in use by another account.', 'danger')
return _rerender()
if user.role != role:
# Two ways to lock everyone out of administration, neither of
# which any interface can undo afterwards.
if user.id == actor_id:
flash('You cannot change your own role. Ask another president '
'to do it.', 'danger')
return _rerender()
if user.role == 'admin':
remaining_admins = User.query.filter(
User.role == 'admin',
User.is_active_account.is_(True),
User.id != user.id,
).count()
if remaining_admins == 0:
flash('This is the last active president. Promote another '
'account before changing this one.', 'danger')
return _rerender()
# Change role via raw SQL to avoid polymorphic identity corruption.
# Must discard the entire session because the polymorphic discriminator
# change invalidates the identity map for this instance and anything