fix(security): supprimer le XSS stocke du calendrier

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]>
This commit is contained in:
GGThed
2026-08-07 19:56:27 -04:00
co-authored by Claude Opus 5
parent 32f193c008
commit d05e9cde32
3 changed files with 259 additions and 55 deletions
+145
View File
@@ -0,0 +1,145 @@
"""Contract of the calendar JSON API.
SEC-XSS-001. /matches/api/events used to build presentation markup into its
`description` field:
match_desc = participants_str + f"<br>{match.description}"
The browser dropped that value straight into innerHTML, so a player username
— free text at the time — reached the DOM of every coach and administrator
who opened the calendar. Escaping cannot be the browser's job alone here;
the API must not emit markup in the first place.
These tests assert the API contract. The DOM side is fixed in
calendar.html, which now builds nodes with textContent.
"""
from datetime import date, time
import pytest
from app.extensions import db
@pytest.fixture
def match_factory(app):
"""Create a tryout with one player_scrim match and one participant."""
from app.models import (
Match, MatchParticipant, Tryout, TryoutRegistration,
)
def _make(owner_id, player_id, *, description=None, username=None):
from app.models import User
with app.app_context():
if username is not None:
# Written straight to the column: edit_profile now rejects
# this, but rows predating that fix can still hold anything.
player = db.session.get(User, player_id)
player.username = username
tryout = Tryout(
title='Spring tryout', game='Valorant', date=date(2030, 5, 1),
created_by=owner_id, status='upcoming',
)
db.session.add(tryout)
db.session.flush()
db.session.add(TryoutRegistration(
tryout_id=tryout.id, player_id=player_id))
match = Match(
tryout_id=tryout.id, title='Scrim A', description=description,
date=date(2030, 5, 2), start_time=time(18, 0), end_time=time(19, 0),
match_type='player_scrim', created_by=owner_id,
)
db.session.add(match)
db.session.flush()
db.session.add(MatchParticipant(
match_id=match.id, player_id=player_id))
db.session.commit()
return tryout.id, match.id
return _make
def _match_event(payload):
return next(e for e in payload if e['id'].startswith('match_'))
class TestCalendarEventPayload:
def test_description_is_returned_verbatim(
self, app, client, as_role, make_user, match_factory
):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id, description='Bring your own peripherals')
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
assert props['description'] == 'Bring your own peripherals'
def test_description_does_not_carry_generated_markup(
self, app, client, as_role, make_user, match_factory
):
"""The <br> and the duplicated participant list are both gone."""
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id, description='Warm-up first')
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
assert '<br>' not in props['description']
assert props['participants'] not in props['description']
def test_an_empty_description_stays_empty(
self, app, client, as_role, make_user, match_factory
):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id, description=None)
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
assert props['description'] == ''
def test_participants_remain_available_separately(
self, app, client, as_role, make_user, match_factory
):
"""Removing the concatenation must not lose the participant list:
the modal still renders it, from its own field."""
from app.models import User
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id, description='x')
with app.app_context():
username = db.session.get(User, player_id).username
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
assert props['participants'] == username
class TestLegacyHostileData:
"""Rows written before the username policy can still hold markup."""
PAYLOAD = '<img src=x onerror=alert(1)>'
def test_a_hostile_username_is_confined_to_its_own_field(
self, app, client, as_role, make_user, match_factory
):
player_id = make_user('player')
admin_id = as_role('admin')
match_factory(admin_id, player_id,
description='Normal text', username=self.PAYLOAD)
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
# The payload is data: it may legitimately appear in `participants`,
# which the client renders through textContent. What must never
# happen again is it being spliced into a field the client treats
# as markup.
assert props['description'] == 'Normal text'
assert self.PAYLOAD not in props['description']