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.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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'
|
||||
Reference in New Issue
Block a user