QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
150 lines
5.3 KiB
Python
150 lines
5.3 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']
|