Files
team-tryouts/tests/test_security_headers.py
T
GGThedandClaude Opus 5 fcb58e8a17 feat(csp): retirer unsafe-inline de script-src
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]>
2026-08-07 21:03:31 -04:00

80 lines
3.0 KiB
Python

"""HTTP hardening, error disclosure, and template escaping."""
from app.app import nl2br
class TestSecurityHeaders:
def test_core_headers_are_present(self, client):
headers = client.get('/auth/login').headers
assert headers['X-Content-Type-Options'] == 'nosniff'
assert headers['X-Frame-Options'] == 'DENY'
assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin'
assert 'frame-ancestors' in headers['Content-Security-Policy']
def test_deprecated_xss_auditor_header_is_not_sent(self, client):
"""X-XSS-Protection was removed: deprecated, and harmful in its
last implementations."""
assert 'X-XSS-Protection' not in client.get('/auth/login').headers
def test_csp_does_not_allow_inline_script(self, client):
"""SEC-WEB-001, closed: inline scripts are authorised by a
per-request nonce instead."""
csp = client.get('/auth/login').headers['Content-Security-Policy']
script_src = [d for d in csp.split(';') if d.strip().startswith('script-src')][0]
assert "'unsafe-inline'" not in script_src
assert "'nonce-" in script_src
class TestErrorDisclosure:
def test_health_reports_status_without_the_driver_message(self, app, client):
"""/health is unauthenticated. Driver exceptions routinely carry the
host, database name and user of the connection string."""
from app.extensions import db
def boom(*args, **kwargs):
raise RuntimeError(
'FATAL: password authentication failed for user "app" '
'host=db.internal port=5432'
)
original = db.session.execute
db.session.execute = boom
try:
response = client.get('/health')
finally:
db.session.execute = original
assert response.status_code == 503
body = response.get_data(as_text=True)
assert response.get_json()['database'] == 'error'
for leaked in ('password', 'db.internal', '5432', 'FATAL'):
assert leaked not in body, f'{leaked!r} leaked through /health'
def test_health_reports_healthy_when_the_database_answers(self, client):
response = client.get('/health')
assert response.status_code == 200
assert response.get_json()['database'] == 'connected'
class TestTemplateEscaping:
def test_nl2br_escapes_markup(self, app):
"""Markup('<br>'.join(...)) marked attacker text as safe.
Markup('<br>').join(...) escapes each segment first."""
with app.app_context():
rendered = str(nl2br('<script>alert(1)</script>\nsecond'))
assert '<script>' not in rendered
assert '&lt;script&gt;' in rendered
assert '<br>' in rendered, 'the line break itself must stay markup'
def test_nl2br_still_joins_lines(self, app):
with app.app_context():
assert str(nl2br('a\nb')) == 'a<br>b'
def test_nl2br_handles_empty_input(self, app):
with app.app_context():
assert nl2br('') == ''
assert nl2br(None) == ''