"""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 os
import tempfile
import zipfile
import pytest
import app
import converters.common as C
from tests.helpers import wait_for_job, download_bytes, pdf_text
def _upload(client, content, filename="doc.html"):
"""POST an upload, wait for the terminal job state, return the snapshot.
Accepts either a str (encoded to utf-8) or bytes so the HTML/SVG string
tests and the binary-epub C1 zip-bomb test can both share it.
"""
if isinstance(content, str):
content = content.encode("utf-8")
files = {"file": (io.BytesIO(content), 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 = (
"
Remote Img"
'
'
""
)
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 = (
""
'
'
""
)
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 = (
"Local Img"
'
'
""
)
job = _upload(client, html)
assert job["status"] == "done", job
# ---------------------------------------------------------------------------
# Finding C1 (regression): decompression-bomb guard on zip extraction
# ---------------------------------------------------------------------------
# Two independent mechanisms are exercised:
# * the SHARED streaming guard common._copy_stream_with_cap (proven by the
# unit tests: it counts WRITTEN decompressed bytes and aborts mid-stream
# past the cap), and
# * app.py's zip-bomb guard end-to-end (proven by the e2e: a genuinely large
# member trips the per-member size cap and the job fails with 'zip-bomb').
def _append_member(epub_bytes, name, data=b"x", external_attr=None):
"""Append a member to an existing epub byte string (mirrors test_review_fixes.py)."""
src = io.BytesIO(epub_bytes)
out = io.BytesIO()
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.namelist():
zout.writestr(item, zin.read(item))
zi = zipfile.ZipInfo(name)
if external_attr is not None:
zi.external_attr = external_attr
zout.writestr(zi, data)
return out.getvalue()
def test_copy_stream_with_cap_counts_written_bytes():
"""_copy_stream_with_cap returns the exact byte count it streamed to dst.
Satisfies: C1
"""
data = b"x" * (10 * 1024) # 10 KiB
buf = io.BytesIO()
written = C._copy_stream_with_cap(io.BytesIO(data), buf, cap=10 * 1024 * 1024)
assert written == len(data)
assert buf.getvalue() == data
def test_copy_stream_with_cap_count_only_when_dst_none():
"""When dst is None the bytes are counted but NOT written (used by the pandoc
pre-check, which must not touch disk). Satisfies: C1
"""
data = b"x" * (10 * 1024)
written = C._copy_stream_with_cap(io.BytesIO(data), None, cap=10 * 1024 * 1024)
assert written == len(data)
def test_copy_stream_with_cap_aborts_on_overflow():
"""_copy_stream_with_cap raises a zip-bomb guard error mid-stream when the
decompressed total exceeds the cap - the real decompression-bomb protection.
Satisfies: C1
"""
data = b"x" * (10 * 1024) # 10 KiB
with pytest.raises(ValueError) as excinfo:
C._copy_stream_with_cap(io.BytesIO(data), None, cap=1024) # 1 KiB cap
assert "zip-bomb" in str(excinfo.value).lower()
def test_c1_zip_bomb_guard_rejects_large_member(client, fixture_epub_bytes, monkeypatch):
"""A genuinely large member (ZIP_BOMB_MIN_CAP) fails the job with a 'zip-bomb'
error through the public HTTP API. Production floor is 2 GiB (impractical in a
unit test), so the floor is shrunk so the guard logic is exercised with a 4 MiB
member in a ~8 KiB upload. Satisfies: C1
"""
monkeypatch.setattr(app, "ZIP_BOMB_MIN_CAP", 1024 * 1024) # 1 MiB floor
large = b"\x00" * (4 * 1024 * 1024) # 4 MiB of NUL bytes, exceeds the 1 MiB cap
content = _append_member(fixture_epub_bytes, "OEBPS/bomb.bin", large)
job = _upload(client, content, "bomb.epub")
assert job["status"] == "error", job
assert "zip-bomb" in job["error"].lower()
# ---------------------------------------------------------------------------
# Finding C3 (regression): stored-XSS stripping in rendered HTML/SVG
# ---------------------------------------------------------------------------
# WeasyPrint already drops "
""
'JS_LINK'
'VB_LINK'
)
out = C._strip_scripts(html)
assert "SCRIPT_BODY" not in out
assert "IFRAME_BODY" not in out
assert "EVIL_ATTR()" not in out
assert "EVIL_JS()" not in out
assert "EVIL_VB()" not in out
assert "onmouseover" not in out.lower()
assert "javascript:" not in out.lower()
assert "vbscript:" not in out.lower()
def test_strip_scripts_preserves_visible_and_safe_urls():
"""_strip_scripts leaves visible text and safe data:/https: references intact.
Satisfies: C3
"""
html = (
"VISIBLE_TEXT
"
'
SAFE_LINK'
)
out = C._strip_scripts(html)
assert "VISIBLE_TEXT" in out
assert "SAFE_LINK" in out
assert "https://example.com/ok" in out
assert "data:image/png;base64," in out
def test_c3_html_guard_wired_job_done_and_content_preserved(client):
"""An .html upload with "
"VISIBLE_MAIN_TEXT
"
'LINK'
"SECOND_VISIBLE
"
"