C2 SSRF fix: pre-validate HTML resource refs before WeasyPrint render
Test / Tests (Python 3.12) (push) Canceled after 0s
Test / Tests (Python 3.12) (push) Canceled after 0s
WeasyPrint's image loader swallows per-fetch errors and renders a broken image, so a job could report DONE while a remote URL was contacted (SSRF/data egress against the no-data-egress guarantee). Add scan_for_remote() which rejects http(s)/ftp/ws/wss/file resource references up-front (ValueError -> job error) before WeasyPrint ever runs; local data: URIs still render via the default loader. Also add tests/test_review_findings_20260903.py (remote -> job error, valid data: -> job done).
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""Regression tests for the 2026-09-03 adversarial-review findings.
|
||||
|
||||
Source of truth:
|
||||
.a0proj/_bmad-output/implementation-artifacts/review-findings-epub3-pdf-2026-09-03.md
|
||||
|
||||
C2 - Remote resource fetching by WeasyPrint (blind SSRF / server-side data egress).
|
||||
The server must never fetch a remote http(s)/ftp/ws/wss resource on behalf of a
|
||||
user upload. The fix lives in converters.common._blocking_url_fetcher
|
||||
(common.py:130-150), which refuses remote schemes and delegates local `data:` /
|
||||
same-directory references to WeasyPrint's default loader. The import that makes
|
||||
that delegation possible is converters.common:18.
|
||||
|
||||
Each test maps to C2 and is asserted through the public HTTP API so a regression
|
||||
in the fetcher policy is caught end-to-end (not just at the unit boundary).
|
||||
|
||||
Satisfies: C2
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
|
||||
import app
|
||||
from tests.helpers import wait_for_job
|
||||
|
||||
|
||||
def _upload(client, content, filename="doc.html"):
|
||||
"""POST an upload, wait for the terminal job state, return the snapshot."""
|
||||
files = {"file": (io.BytesIO(content.encode("utf-8")), filename)}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
return wait_for_job(client, resp.get_json()["job_id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding C2: WeasyPrint SSRF guard - remote resource references are refused
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_remote_http_image_reference_rejected(client):
|
||||
"""A user-supplied .html referencing http:// image must FAIL the job rather
|
||||
than let the server contact a remote host (SSRF / data egress).
|
||||
Satisfies: C2
|
||||
"""
|
||||
html = (
|
||||
"<!DOCTYPE html><html><head><title>Remote Img</title></head>"
|
||||
'<body><img src="http://example.com/pic.png" alt="remote">'
|
||||
"</body></html>"
|
||||
)
|
||||
job = _upload(client, html)
|
||||
assert job["status"] == "error", job
|
||||
assert "blocked" in job["error"].lower() or "remote" in job["error"].lower()
|
||||
|
||||
|
||||
def test_remote_https_image_reference_rejected(client):
|
||||
"""Same guard for https:// - the common metadata / SSRF vector.
|
||||
Satisfies: C2
|
||||
"""
|
||||
html = (
|
||||
"<!DOCTYPE html><html><body>"
|
||||
'<img src="https://evil.example.com/x.png">'
|
||||
"</body></html>"
|
||||
)
|
||||
job = _upload(client, html)
|
||||
assert job["status"] == "error", job
|
||||
assert "blocked" in job["error"].lower() or "remote" in job["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding C2 (regression): local data: references remain permitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_local_data_uri_image_succeeds(client):
|
||||
"""A local `data:` URI resolves through WeasyPrint's default loader and the
|
||||
conversion completes. Regression guard: must not regress to returning None
|
||||
(which raised WeasyPrint's 'URL fetcher must return ...' assertion).
|
||||
Satisfies: C2
|
||||
"""
|
||||
# 1x1 red PNG encoded as a data: URI (PIL-generated, valid base64).
|
||||
png = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||
)
|
||||
data_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
||||
html = (
|
||||
"<!DOCTYPE html><html><head><title>Local Img</title></head>"
|
||||
'<body><img src="' + data_uri + '" alt="local">'
|
||||
"</body></html>"
|
||||
)
|
||||
job = _upload(client, html)
|
||||
assert job["status"] == "done", job
|
||||
Reference in New Issue
Block a user