Files
team-tryouts/tests/test_subresource_integrity.py
GGThed 3882b6035f fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune
liste de « ce qui reste » ne reprenait.

OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les
valeurs pour la production », puis posait FLASK_DEBUG=true,
SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug
execute du code soumis par le navigateur : cette ligne transformait un
copier-coller en shell distant. Chaque valeur est desormais sure a la
copie, et le fichier refuse de demarrer tant que les deux secrets
obligatoires ne sont pas remplis plutot que de demarrer grand ouvert.

Renomme en .env.example : l orthographe francaise ne correspondait pas a
l exception !.env.example du .gitignore, donc le fichier n etait suivi que
par accident de l ordre des regles. Les deux points de la decision ouverte
du §8 tombent d un seul git mv.

OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les
defauts sont **inchanges**, deliberement : choisir sans connaitre la
topologie coupe la prod si nginx est ailleurs, ou casse la limitation de
debit pour tout le monde si on cesse de croire X-Forwarded-For alors que
c etait la seule source d adresses. Ce sont maintenant des variables, les
valeurs sures sont dans .env.example pour un nouveau deploiement, et
docs/deployment.md donne les quatre topologies avec la valeur de chacune.
wsgi.py avertit au demarrage tant que les deux defauts sont en place.

Le commentaire de HOST annoncait « bind to localhost by default » a cote
d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code
faisait l inverse. Il dit maintenant ce qu il fait.

QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte,
depuis des hotes que la CSP autorise nommement. Qui controle ces CDN
controlait ce qui s execute sur chaque page. Empreintes posees, avec ce
que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier,
ca ne prouve pas qu il etait honnete au moment du calcul.

**Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans
le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque
ouverture du calendrier depuis la montee de version. Une feuille de style
en echec est silencieuse dans le navigateur, c est ce qui l a fait durer.

CI-003 — actions epinglees sur un commit, version en commentaire, dans les
deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du
code arbitraire dans le job qui detient la cle SSH de production. Ce job
recoit aussi enfin un bloc permissions.

517 tests.
2026-08-11 14:42:23 -04:00

105 lines
4.0 KiB
Python

"""Third-party assets are pinned (QUA-004).
Two CDNs serve this site: cdnjs for Font Awesome, jsDelivr for FullCalendar.
The CSP names both as allowed origins, so neither would be stopped by it —
whoever controls those hosts controls what runs on every page, and on the
calendar page that includes executable script.
`integrity` pins each file: the browser refuses one that has been altered
since the hash was taken. It does not prove the file was honest at that
moment. Worth being clear about, because SRI is often read as more than it
is.
This is a guard, not a test of behaviour. Its job is to fail the day someone
adds a CDN URL without a hash, or bumps a version and leaves the old hash
behind — the second of which fails *silently* in a browser, since a script
that fails its integrity check simply does not run.
"""
import re
import pytest
TEMPLATES = 'app/templates'
#: Hosts we deliberately load from. Anything else on a template needs a
#: decision, not a hash.
KNOWN_CDNS = ('cdnjs.cloudflare.com', 'cdn.jsdelivr.net')
#: Discord avatars. Not a subresource — an <img> src built at render time,
#: which cannot carry an integrity hash and executes nothing.
IMAGE_ONLY_HOSTS = ('cdn.discordapp.com',)
def _templates():
import os
for root, _dirs, files in os.walk(TEMPLATES):
for name in files:
if name.endswith('.html'):
path = os.path.join(root, name)
with open(path, encoding='utf-8') as handle:
yield path, handle.read()
def _tags_loading_from(host, markup):
"""Every <script src> and <link href> pointing at this host."""
pattern = re.compile(
r'<(script|link)\b[^>]*?(?:src|href)\s*=\s*"https://' + re.escape(host) + r'[^"]*"[^>]*>',
re.IGNORECASE | re.DOTALL,
)
return pattern.findall(markup), pattern
@pytest.mark.parametrize('host', KNOWN_CDNS)
def test_every_asset_from_a_cdn_carries_an_integrity_hash(host):
offenders = []
for path, markup in _templates():
_names, pattern = _tags_loading_from(host, markup)
for tag in pattern.finditer(markup):
if 'integrity=' not in tag.group(0):
offenders.append(f'{path}: {tag.group(0)[:120]}')
assert not offenders, 'CDN asset loaded without integrity:\n' + '\n'.join(offenders)
@pytest.mark.parametrize('host', KNOWN_CDNS)
def test_integrity_is_paired_with_crossorigin(host):
"""A browser ignores integrity on a request it did not make in CORS mode,
so the hash without crossorigin is decoration."""
offenders = []
for path, markup in _templates():
_names, pattern = _tags_loading_from(host, markup)
for tag in pattern.finditer(markup):
if 'integrity=' in tag.group(0) and 'crossorigin=' not in tag.group(0):
offenders.append(f'{path}: {tag.group(0)[:120]}')
assert not offenders, 'integrity without crossorigin:\n' + '\n'.join(offenders)
def test_no_unexpected_third_party_host_appears():
"""A new CDN is a decision — a supply-chain dependency and a CSP entry —
not something to notice later."""
allowed = set(KNOWN_CDNS) | set(IMAGE_ONLY_HOSTS)
found = set()
for _path, markup in _templates():
found.update(re.findall(r'https://([a-z0-9.-]+)/', markup, re.IGNORECASE))
unexpected = {host for host in found if host not in allowed}
# Documentation links in comments are fine; only loaded assets matter.
unexpected -= {'discord.com', 'www.w3.org', 'developer.mozilla.org'}
assert not unexpected, f'unexpected third-party hosts in templates: {sorted(unexpected)}'
def test_the_csp_and_the_templates_agree(app):
"""A hash is no use if the CSP blocks the request outright, and an origin
left in the CSP after its last use is a widened policy nobody needs."""
with app.test_request_context():
from app.app import build_csp
policy = build_csp(nonce='x', allow_inline_script=False)
for host in KNOWN_CDNS:
assert host in policy, f'{host} is loaded by a template but absent from the CSP'