"""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" 'remote' "" ) 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" 'local' "" ) 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

" 'local>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

" "" ) job = _upload(client, html, "xss.html") assert job["status"] == "done", job full = pdf_text(download_bytes(client, job["filename"])) # Guard must not blank the document: visible content still renders. assert "VISIBLE_MAIN_TEXT" in full assert "SECOND_VISIBLE" in full # Guarded vectors must not be reachable in the rendered document. assert "SCRIPT_MARKER_TEXT" not in full assert "EVIL_MARKER" not in full assert "EVIL_LINK" not in full def test_c3_svg_guard_wired_job_done(client): """An .svg upload with a " 'SVG_VISIBLE_TEXT' "" ) job = _upload(client, svg, "xss.svg") assert job["status"] == "done", job