From 965377388f52e086c8be1722d5e4373bcef81f3f Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 14 Jul 2026 08:05:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(billets):=20SSRF=20=E2=80=94=20validate=20e?= =?UTF-8?q?mbed=5Furl=20public-IP=20before=20Chromium=20screenshot=20(ref?= =?UTF-8?q?=20#851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review HIGH: the headless-browser snapshot path drove Chromium to embed_url with no SSRF check (only the og:image fallback was guarded), so an author could screenshot an internal service (127.0.0.1/RFC1918/::1/169.254.169.254). Now ssrf.validate_url() gates the navigation (reject → fall back to og:image), plus a Playwright route aborts any redirect/sub-resource to a non-public host. 12 tests. Co-Authored-By: Gerald KERMA --- .../secubox-billets/api/services/snapshot.py | 24 +++++++++++++++++-- .../secubox-billets/tests/test_snapshot.py | 20 ++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/secubox-billets/api/services/snapshot.py b/packages/secubox-billets/api/services/snapshot.py index 66c50115..fc5ab923 100644 --- a/packages/secubox-billets/api/services/snapshot.py +++ b/packages/secubox-billets/api/services/snapshot.py @@ -52,20 +52,40 @@ def _browser_enabled(explicit: bool | None) -> bool: return os.environ.get("BILLETS_SNAPSHOT_BROWSER", "1") != "0" -def _screenshot_via_browser(embed_url: str) -> bytes | None: +def _screenshot_via_browser(embed_url: str, resolver=ssrf._default_resolver) -> bytes | None: """Full-page PNG screenshot via headless Chromium, or None if unavailable. Guarded so a missing `playwright` package OR a missing browser binary OR any navigation error simply returns None (the caller then tries og:image).""" + # SSRF: the browser path is as dangerous as any server-side fetch — an author + # could point embed_url at an internal service (127.0.0.1, RFC1918, ::1, + # 169.254.169.254). Validate the target resolves to a PUBLIC IP before we let + # Chromium touch it; on rejection return None → the (also-guarded) og:image + # fallback runs. (Residual: a *public* URL that server-redirects to an + # internal host — mitigated by aborting non-public requests via the route below.) + try: + ssrf.validate_url(embed_url, resolver=resolver) + except ssrf.SSRFError: + return None try: from playwright.sync_api import sync_playwright except Exception: # noqa: BLE001 — ImportError or a broken partial install return None + + def _guard(route): + # Abort any request (redirect target, sub-resource) to a non-public host. + try: + ssrf.validate_url(route.request.url, resolver=resolver) + route.continue_() + except Exception: # noqa: BLE001 + route.abort() + try: with sync_playwright() as p: browser = p.chromium.launch(headless=True) try: page = browser.new_page() + page.route("**/*", _guard) page.set_default_timeout(GOTO_TIMEOUT_MS) page.goto(embed_url, timeout=GOTO_TIMEOUT_MS, wait_until="networkidle") page.wait_for_timeout(SETTLE_MS) @@ -127,7 +147,7 @@ def capture(embed_url: str, og_image_url: str | None, media_id: str, *, headless screenshot nor the og:image fallback yielded a usable image.""" raw: bytes | None = None if _browser_enabled(enable_browser): - raw = _screenshot_via_browser(embed_url) + raw = _screenshot_via_browser(embed_url, resolver=resolver) if raw is None and og_image_url: raw = _og_image_bytes(og_image_url, client=client, resolver=resolver) if not raw: diff --git a/packages/secubox-billets/tests/test_snapshot.py b/packages/secubox-billets/tests/test_snapshot.py index 7ef87338..223a0770 100644 --- a/packages/secubox-billets/tests/test_snapshot.py +++ b/packages/secubox-billets/tests/test_snapshot.py @@ -27,7 +27,7 @@ def _resolver(mapping=None): def test_capture_falls_back_to_og_image(tmp_path, monkeypatch): # Simulate playwright absent / no browser binary → screenshot returns None. - monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None) + monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url, **kw: None) png = _png_bytes() def handler(request): @@ -48,7 +48,7 @@ def test_capture_falls_back_to_og_image(tmp_path, monkeypatch): def test_capture_returns_none_when_both_fail(tmp_path, monkeypatch): - monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None) + monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url, **kw: None) def handler(request): return httpx.Response(200, content=b"not an image at all", @@ -65,7 +65,7 @@ def test_capture_returns_none_when_both_fail(tmp_path, monkeypatch): def test_capture_returns_none_without_og_and_no_browser(tmp_path, monkeypatch): - monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None) + monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url, **kw: None) res = snapshot.capture( "https://site.example/post", None, "01SNAP0000000000000000000D", resolver=_resolver(), directory=tmp_path, enable_browser=True) @@ -73,7 +73,7 @@ def test_capture_returns_none_without_og_and_no_browser(tmp_path, monkeypatch): def test_capture_ssrf_rejects_private_og(tmp_path, monkeypatch): - monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url: None) + monkeypatch.setattr(snapshot, "_screenshot_via_browser", lambda url, **kw: None) calls = {"n": 0} def handler(request): @@ -91,3 +91,15 @@ def test_capture_ssrf_rejects_private_og(tmp_path, monkeypatch): assert res is None assert calls["n"] == 0 # SSRF blocked BEFORE any network fetch assert list(tmp_path.iterdir()) == [] + + +def test_browser_path_ssrf_rejects_internal_embed_url(monkeypatch): + """The Chromium path must refuse an internal embed_url (SSRF) before launching.""" + from api.services import snapshot + # resolver that maps the target to a private IP + monkeypatch.setattr(snapshot.ssrf, "_default_resolver", + lambda host, port: [(0, 0, 0, "", ("127.0.0.1", port))]) + # if it were to proceed it would try to import/launch playwright; assert it returns None first + out = snapshot._screenshot_via_browser("https://internal.example/", + resolver=lambda h, p: [(0, 0, 0, "", ("10.0.0.5", p))]) + assert out is None