mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
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:
parent
a95f157eb1
commit
965377388f
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user