fix(billets): SSRF — validate embed_url public-IP before Chromium screenshot (ref #851)

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 <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-14 08:05:35 +02:00
parent a95f157eb1
commit 965377388f
2 changed files with 38 additions and 6 deletions

View File

@ -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:

View File

@ -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