Files
team-tryouts/tests/test_csp.py
T
GGThedandClaude Opus 5 09453199b8 refactor(csp): migrer dix gabarits vers les comportements declaratifs
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]>
2026-08-07 20:51:07 -04:00

147 lines
5.6 KiB
Python

"""Content Security Policy, and the migration away from 'unsafe-inline'.
SEC-WEB-001 / OPS-010. script-src still carries 'unsafe-inline', which is
why the stored XSS of SEC-XSS-001 executed instead of being blocked.
Removing it is not a one-line change. A nonce authorises `<script>`
elements; it can do nothing for `onclick="..."` attributes, and there are
dozens of those across the templates. Under CSP level 3 a browser also
ignores 'unsafe-inline' the moment a nonce appears, so the two cannot
coexist as a gradual transition — the switch is atomic.
The counts below are a ratchet: they may only go down. Migrating a
template and lowering the number is a deliberate act, recorded in the
diff. Adding a new inline handler turns the suite red.
"""
import os
import re
import pytest
from app.app import build_csp
TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'templates',
)
#: Attributes a nonce can never authorise.
INLINE_HANDLER = re.compile(
r'\son(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=',
re.I,
)
#: Remaining inline handlers, per template. Lower these as you migrate;
#: never raise one. Templates absent from this map must have none.
HANDLER_BUDGET = {
'pages/match_form.html': 13,
'pages/calendar.html': 11,
'pages/teams.html': 11,
'pages/evaluate_player.html': 9,
'pages/view_tryout.html': 8,
}
#: What the ratchet is counting down to.
TOTAL_BUDGET = sum(HANDLER_BUDGET.values())
def _templates():
for root, _dirs, files in os.walk(TEMPLATE_ROOT):
for name in files:
if name.endswith('.html'):
full = os.path.join(root, name)
rel = os.path.relpath(full, TEMPLATE_ROOT).replace(os.sep, '/')
yield rel, full
def _count_handlers(path):
with open(path, encoding='utf-8') as handle:
return len(INLINE_HANDLER.findall(handle.read()))
class TestPolicyHeader:
def test_the_current_policy_still_allows_inline_script(self, client):
"""Documents where we are, not where we want to be."""
csp = client.get('/auth/login').headers['Content-Security-Policy']
assert "'unsafe-inline'" in csp
def test_the_policy_pins_the_dangerous_directives(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "base-uri 'self'" in csp
assert "form-action 'self'" in csp
assert "object-src" not in csp or "object-src 'none'" in csp
def test_no_nonce_is_emitted_while_inline_script_is_allowed(self, client):
"""Emitting both would silently drop every inline script in modern
browsers, since a nonce makes them ignore 'unsafe-inline'."""
csp = client.get('/auth/login').headers['Content-Security-Policy']
assert 'nonce-' not in csp
def test_the_hardened_policy_carries_a_nonce_and_no_unsafe_inline(self):
csp = build_csp(allow_inline_script=False, nonce='abc123')
assert "'nonce-abc123'" in csp
assert "'unsafe-inline'" not in csp.split('style-src')[0]
def test_each_request_gets_a_distinct_nonce(self, app):
"""A reused nonce is worth no more than 'unsafe-inline'."""
application = app
application.config['CSP_ALLOW_INLINE_SCRIPT'] = False
client = application.test_client()
seen = set()
for _ in range(5):
csp = client.get('/auth/login').headers['Content-Security-Policy']
seen.add(re.search(r"'nonce-([^']+)'", csp).group(1))
assert len(seen) == 5
class TestInlineHandlerRatchet:
@pytest.mark.parametrize('relative,full', list(_templates()))
def test_a_template_never_gains_an_inline_handler(self, relative, full):
allowed = HANDLER_BUDGET.get(relative, 0)
found = _count_handlers(full)
assert found <= allowed, (
f'{relative} has {found} inline event handler(s), budget is '
f'{allowed}. A nonce cannot authorise these — use '
f'data-action="..." and the delegated listener in main.js.'
)
def test_the_budget_map_has_no_stale_entries(self):
"""Lower an entry to zero and it must be deleted, so the map keeps
reflecting the real remaining work."""
actual = {rel: _count_handlers(full) for rel, full in _templates()}
stale = [rel for rel, allowed in HANDLER_BUDGET.items()
if actual.get(rel, 0) < allowed]
assert not stale, (
f'Budget is now higher than reality for {stale}. Lower or remove '
f'these entries so the count keeps meaning something.'
)
def test_the_shared_layout_is_already_free_of_them(self):
"""base.html and macros.html render on every single page, so they
were migrated first."""
for relative in ('layouts/base.html', 'layouts/macros.html'):
full = os.path.join(TEMPLATE_ROOT, *relative.split('/'))
assert _count_handlers(full) == 0, f'{relative} regressed'
def test_progress_is_recorded(self):
"""Fails when the total drops, as a reminder to update the budget
and, once it reaches zero, to flip CSP_ALLOW_INLINE_SCRIPT."""
total = sum(_count_handlers(full) for _rel, full in _templates())
assert total <= TOTAL_BUDGET
assert total == TOTAL_BUDGET, (
f'{TOTAL_BUDGET - total} handler(s) removed since the budget was '
f'last updated — lower HANDLER_BUDGET to {total}. At zero, set '
f'CSP_ALLOW_INLINE_SCRIPT to false and delete this ratchet.'
)