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]>
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
"""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']
|