Picarones / tests /security /test_s1_ssrf_attack.py
Claude
feat(sprint-S1.5+S1.6+S1.7): tests d'attaque ZIP slip / SSRF / CSRF + durcissement validate_http_url
02c6322 unverified
Raw
History Blame
7.32 kB
"""Sprint S1.6 β€” Tests d'attaque SSRF (Server-Side Request Forgery).
``picarones.adapters.corpus._http.validate_http_url`` est censΓ©
empΓͺcher un import IIIF/Gallica/HuggingFace malicieux de faire
fetcher des ressources internes.
Vecteurs couverts
-----------------
1. **SchΓ©mas non-HTTP** (``file://``, ``ftp://``, ``data:``) β€”
dΓ©fense dΓ©jΓ  annoncΓ©e.
2. **Localhost / loopback** (``http://127.0.0.1``, ``http://localhost``,
``http://[::1]``).
3. **MΓ©tadonnΓ©es cloud** (``http://169.254.169.254`` β€” AWS,
``http://metadata.google.internal`` β€” GCP).
4. **RΓ©seaux privΓ©s RFC 1918** (``10.0.0.0/8``, ``172.16.0.0/12``,
``192.168.0.0/16``).
5. **Lien local** (``169.254.0.0/16``).
NB : la dΓ©fense statique ne suffit pas contre les attaques DNS
rebinding ou les redirections HTTP qui pointent ensuite sur du
loopback. Mais l'absence de dΓ©fense statique est un signal clair.
"""
from __future__ import annotations
import pytest
# ──────────────────────────────────────────────────────────────────────
# 1. SchΓ©mas non-HTTP (dΓ©jΓ  censΓ© Γͺtre bloquΓ©)
# ──────────────────────────────────────────────────────────────────────
class TestNonHttpSchemes:
"""``file://``, ``ftp://``, ``data:`` doivent lever
``ValueError``."""
@pytest.mark.parametrize(
"url",
[
"file:///etc/passwd",
"ftp://internal.corp/secrets.txt",
"data:text/html,<script>alert(1)</script>",
"javascript:alert(1)",
"gopher://internal:11211/_set%20foo%20bar",
],
)
def test_non_http_scheme_rejected(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
with pytest.raises(ValueError):
validate_http_url(url)
# ──────────────────────────────────────────────────────────────────────
# 2. Loopback / localhost
# ──────────────────────────────────────────────────────────────────────
class TestLoopbackBlocked:
"""Un import IIIF qui pointe ``http://127.0.0.1:6379`` peut
parler au Redis interne."""
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1/manifest.json",
"http://127.0.0.1:8080/admin",
"http://localhost/",
"http://localhost:5432/",
"http://[::1]/",
"http://0.0.0.0/",
],
)
def test_loopback_rejected(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
with pytest.raises(ValueError):
validate_http_url(url)
# ──────────────────────────────────────────────────────────────────────
# 3. MΓ©tadonnΓ©es cloud (AWS / GCP)
# ──────────────────────────────────────────────────────────────────────
class TestCloudMetadataBlocked:
"""169.254.169.254 expose les credentials AWS IAM, GCP project
metadata, etc. Doit Γͺtre refusΓ©."""
@pytest.mark.parametrize(
"url",
[
"http://169.254.169.254/latest/meta-data/",
"http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token",
"http://metadata.google.internal/",
],
)
def test_cloud_metadata_rejected(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
with pytest.raises(ValueError):
validate_http_url(url)
# ──────────────────────────────────────────────────────────────────────
# 4. RΓ©seaux privΓ©s RFC 1918
# ──────────────────────────────────────────────────────────────────────
class TestPrivateNetworksBlocked:
"""Dans une institution, l'app peut Γͺtre derriΓ¨re un reverse-proxy
avec accès au réseau interne. Bloquer les IP privées par défaut."""
@pytest.mark.parametrize(
"url",
[
"http://10.0.0.1/",
"http://10.255.255.254/admin",
"http://172.16.0.1/",
"http://172.31.255.254/",
"http://192.168.1.1/",
"http://192.168.255.254/",
],
)
def test_private_ipv4_rejected(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
with pytest.raises(ValueError):
validate_http_url(url)
# ──────────────────────────────────────────────────────────────────────
# 5. Lien-local IPv4 (hors AWS metadata, autres usages)
# ──────────────────────────────────────────────────────────────────────
class TestLinkLocalBlocked:
@pytest.mark.parametrize(
"url",
[
"http://169.254.1.1/",
"http://169.254.255.254/",
],
)
def test_link_local_rejected(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
with pytest.raises(ValueError):
validate_http_url(url)
# ──────────────────────────────────────────────────────────────────────
# 6. Sanity : URLs publiques lΓ©gitimes acceptΓ©es
# ──────────────────────────────────────────────────────────────────────
class TestPublicURLsAccepted:
@pytest.mark.parametrize(
"url",
[
"https://gallica.bnf.fr/iiif/ark:/12148/btv1b8451639t/manifest.json",
"https://huggingface.co/datasets/test/resolve/main/file.json",
"https://github.com/PRImA-Research-Lab/PAGE-XML/raw/master/sample.xml",
"http://images.example.org/iiif/abc/manifest.json",
],
)
def test_public_url_accepted(self, url: str) -> None:
from picarones.adapters.corpus._http import validate_http_url
# Ne lève pas — URL valide.
validate_http_url(url)