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]>
147 lines
5.6 KiB
Python
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.'
|
|
)
|