#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════════════ # L'ÉPREUVE DU GARDE D'ADRESSE — il doit refuser, et refuser AU BON PAS # ═══════════════════════════════════════════════════════════════════════════ # # 🔴 POURQUOI UN FICHIER SÉPARÉ DE `eprouver-a11y.py`. Celui-là éprouve le # JUGEMENT de la sonde sur du HTML ; celui-ci éprouve où elle accepte # d'aller. Deux risques sans rapport : l'un fait un rapport faux, l'autre # fait de notre serveur l'instrument de quelqu'un d'autre. # # ⭐ CHAQUE CAS EST EN DEUX SENS. Un garde qui refuse tout est aussi inutile # qu'un garde qui laisse tout passer, et il est plus difficile à voir : # « la sonde ne trouve rien » ressemble à « le site n'a rien ». Les cas # ADMIS sont donc aussi importants que les cas REFUSÉS. # # Usage : python3 eprouver-garde.py → sort 1 si un seul cas tombe. import importlib.util, pathlib, ssl, sys, threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ici = pathlib.Path(__file__).parent spec = importlib.util.spec_from_file_location("sonde", ici / "sonde-a11y.py") sonde = importlib.util.module_from_spec(spec); spec.loader.exec_module(sonde) # ── 0. L'INTERPRÉTEUR, parce que le garde en dépend en silence ───────────── # # 🔴 TROUVÉ PAR LA MUTATION, LE 24/09/2026. Retirer le déballage de # `::ffff:127.0.0.1` de la sonde N'A PAS fait tomber cette épreuve : sur # Python ≥ 3.11.9, `ipaddress` déballe lui-même. La protection existe donc, # mais elle vient de l'interpréteur — et rien ne le disait. # # ⭐ Une dépendance qu'on ne voit pas se perd au premier changement d'image # Docker. On la nomme, et on REFUSE en dessous : le troisième état plutôt # qu'un vert qui ne veut rien dire. if sys.version_info < (3, 11, 9): print(f"🔴 interpréteur {sys.version.split()[0]} — antérieur à 3.11.9. " "`ipaddress` n'y déballe pas les adresses IPv4 encapsulées ; " "l'épreuve ne peut pas conclure.") sys.exit(1) tombes = [] def eprouver(nom, obtenu, attendu): if obtenu != attendu: tombes.append(f"🔴 {nom} : {obtenu!r}, attendu {attendu!r}") def refus(url): """Rend la raison du refus, ou None si l'URL est admise.""" try: sonde.verifier_url(url) return None except sonde.AdresseRefusee as e: return str(e) # ── 1. LA PORTE ──────────────────────────────────────────────────────────── REFUSES = [ ("boucle locale en clair", "http://127.0.0.1/"), ("boucle locale par son nom", "http://localhost/"), ("boucle locale IPv6", "http://[::1]/"), # ⭐ LE CAS QUI SE RATE. `IPv6Address('::ffff:127.0.0.1').is_loopback` est # FAUX : sans déballage, la boucle locale entre par son autre graphie. ("boucle locale enveloppée IPv6", "http://[::ffff:127.0.0.1]/"), ("métadonnées d'instance", "http://169.254.169.254/latest/meta-data/"), ("réseau privé 10/8", "http://10.0.0.1/"), ("réseau privé 192.168/16", "http://192.168.1.1/"), ("pont Docker", "http://172.17.0.1/"), ("adresse non spécifiée", "http://0.0.0.0/"), ("schéma fichier", "file:///etc/passwd"), ("schéma gopher", "gopher://exemple.fr/"), ("port SSH", "http://1.1.1.1:22/"), ("port applicatif interne", "http://1.1.1.1:8000/"), ] for nom, url in REFUSES: eprouver(f"REFUS — {nom}", refus(url) is not None, True) # ⚠️ Les cas ADMIS emploient des adresses EN TOUTES LETTRES : l'épreuve doit # tenir sans réseau. Un cas qui exige Internet devient un cas qui tombe le # jour où le réseau tousse, et on finit par le désactiver. ADMIS = [ ("adresse publique v4", "http://1.1.1.1/"), ("adresse publique v6", "https://[2606:4700:4700::1111]/"), ("port 443 explicite", "https://1.1.1.1:443/"), ("port 80 explicite", "http://1.1.1.1:80/"), ] for nom, url in ADMIS: eprouver(f"ADMIS — {nom}", refus(url), None) # ── 2. LE PAS SUIVANT, qui est le vrai sujet ─────────────────────────────── # # 🔴 UN DOMAINE PUBLIC QUI RÉPOND 302 VERS 127.0.0.1 PASSE LA PORTE. C'est # l'attaque réelle, et la vérification d'entrée n'en voit rien. Pour # l'éprouver il faut un premier saut qui soit ADMIS et un second qui ne le # soit pas — or nous n'avons pas de serveur public sous la main. # # ⭐ On desserre donc la boucle locale, ET SEULEMENT ELLE, le temps de # l'épreuve : le premier saut devient admis, le second vise les métadonnées # d'instance et doit être refusé. Le desserrage est nommé, étroit, et rendu # à la fin. # # ⚠️ ET IL FAUT LE CAS TÉMOIN. Sans lui, un refus prouverait seulement que # « quelque chose a échoué » — le desserrage mal fait, le serveur mort, le # port pris. Le témoin sert la MÊME page sans redirection : il doit CHARGER. # Une mutation qui rate sa cible ne dit rien. class Serveur(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/vers-les-metadonnees": self.send_response(302) self.send_header("Location", "http://169.254.169.254/latest/meta-data/") self.end_headers() else: corps = b"