SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.
Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.
Deux motifs generiques de plus dans main.js
data-mirror affichage direct de la valeur d'un curseur.
evaluate_player repetait le meme
oninput="this.nextElementSibling.textContent = ..."
sur ses neuf curseurs de note.
data-submit-on-change remplace onchange="this.form.submit()"
Markup genere dans des chaines JavaScript
match_form construisait sept gestionnaires par concatenation, en y
injectant l'identifiant du joueur. Le markup portait deja data-player-id :
returnToPool et assignToTeam lisent desormais leurs arguments depuis
l'element clique. Cela supprime a la fois l'attribut en ligne et la
concatenation qui l'alimentait. Meme motif que dans coach_availability.
Bascule
CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
La variable d'environnement reste, comme issue de secours si un
deploiement rencontrait un gestionnaire oublie -- mais la laisser active
revient a renoncer a la protection.
Le cliquet devient une garde
Le budget par gabarit est vide et les tests deviennent absolus : aucun
gestionnaire en ligne, et tout bloc <script> inline doit porter son
nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
journaux ne le signale -- d'ou le test.
Verifications
22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
par node --check.
193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.
Co-Authored-By: Claude Opus 5 <[email protected]>
147 lines
5.4 KiB
Python
147 lines
5.4 KiB
Python
"""Content Security Policy.
|
|
|
|
SEC-WEB-001 / OPS-010. script-src no longer carries 'unsafe-inline': the
|
|
directive that let the stored XSS of SEC-XSS-001 execute instead of being
|
|
blocked. Inline scripts are authorised by a per-request nonce, and every
|
|
inline event handler has been replaced by a data-action attribute
|
|
dispatched from main.js.
|
|
|
|
Getting here took removing 82 handlers across 17 templates, because a nonce
|
|
authorises `<script>` elements and can do nothing for `onclick`. These
|
|
tests keep it that way: one new inline handler, and the policy silently
|
|
stops applying to that page.
|
|
"""
|
|
|
|
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.
|
|
#: No template may carry an inline event handler. The migration is done;
|
|
#: this is now a hard rule, not a countdown.
|
|
HANDLER_BUDGET = {}
|
|
|
|
#: 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_script_src_no_longer_allows_inline(self, client):
|
|
csp = client.get('/auth/login').headers['Content-Security-Policy']
|
|
script_src = next(d for d in csp.split(';') if 'script-src' in d)
|
|
|
|
assert "'unsafe-inline'" not in script_src
|
|
assert "'nonce-" in script_src
|
|
|
|
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_the_legacy_mode_still_builds(self):
|
|
"""The escape hatch is kept for a deployment that hits an overlooked
|
|
handler. Emitting both would be pointless: a nonce makes browsers
|
|
ignore 'unsafe-inline' entirely."""
|
|
csp = build_csp(allow_inline_script=True)
|
|
|
|
assert "'unsafe-inline'" in csp
|
|
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_every_inline_script_block_carries_a_nonce(self):
|
|
"""Without a nonce a block is simply not executed now, and nothing
|
|
in the server logs says so."""
|
|
import re as _re
|
|
|
|
offenders = []
|
|
for relative, full in _templates():
|
|
with open(full, encoding='utf-8') as handle:
|
|
content = handle.read()
|
|
for tag in _re.findall(r'<script[^>]*>', content):
|
|
if 'src=' in tag or 'nonce=' in tag:
|
|
continue
|
|
offenders.append(f'{relative}: {tag}')
|
|
|
|
assert not offenders, (
|
|
'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
|
|
)
|
|
|
|
def test_the_shared_layout_is_free_of_them(self):
|
|
"""base.html and macros.html render on every single page."""
|
|
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_no_template_carries_an_inline_handler(self):
|
|
total = sum(_count_handlers(full) for _rel, full in _templates())
|
|
|
|
assert total == 0, (
|
|
f'{total} inline event handler(s) reintroduced. They are not '
|
|
f'covered by the nonce, so they will not run — use '
|
|
f'data-action="..." and registerActions() instead.'
|
|
)
|