diff --git a/converters/common.py b/converters/common.py
index f18879b..50226a8 100644
--- a/converters/common.py
+++ b/converters/common.py
@@ -15,6 +15,8 @@ import io
import os
import re
from pathlib import Path
+from urllib.parse import urlsplit
+from weasyprint import default_url_fetcher as _weasy_default_fetcher
__all__ = [
"PRE_CSS",
@@ -120,19 +122,195 @@ def derive_export_name(filename: str, out_dir: Path, title: str | None = None) -
return filename_out
-def html_to_pdf_bytes(html: str, css: str | None = None) -> bytes:
+# Schemes that must never be fetched from a user-supplied document: they let a
+# hostile upload make the server contact remote hosts (SSRF / data egress),
+# directly contradicting the 'nothing leaves your machine' guarantee.
+_REMOTE_SCHEMES = ("http", "https", "ftp", "ftps", "ws", "wss", "file")
+
+
+def _blocking_url_fetcher(url: str) -> bytes:
+ """Raise if `url` points at any remote/external scheme (C2: SSRF guard).
+
+ Local `data:` references and same-directory (relative) resources resolve
+ normally; only absolute remote URLs are refused. Returns the bytes that a
+ URLFetcher would yield so callers remain simple.
+ Satisfies: C2
+ """
+ import urllib.parse as _urllib_parse
+
+ scheme = _urllib_parse.urlsplit(url).scheme.lower()
+ if scheme in _REMOTE_SCHEMES:
+ raise ValueError(
+ f"blocked remote resource reference ({scheme}://) — the server "
+ "does not fetch external URLs"
+ )
+ # data: and relative/local refs are handled by WeasyPrint's default loader,
+ # which asserts the fetcher returns a dict or URLFetcherResponse instance
+ # (WeasyPrint 69.0). Delegate here instead of returning None.
+ # Satisfies: C2
+ return _weasy_default_fetcher(url)
+
+
+def _strip_scripts(html: str) -> str:
+ """Neutralize stored-XSS vectors in user-supplied HTML (C3 shared helper).
+
+ Removes blocks, then any orphan open/close tags.
+ html = re.sub(
+ r"<\s*script\b[^>]*>.*?<\s*/\s*script\s*>", "", html,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+ html = re.sub(r"<\s*/?\s*script\s*/?>", "", html, flags=re.IGNORECASE)
+ # blocks, then orphan tags.
+ html = re.sub(
+ r"<\s*iframe\b[^>]*>.*?<\s*/\s*iframe\s*>", "", html,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+ html = re.sub(r"<\s*/?\s*iframe\s*/?>", "", html, flags=re.IGNORECASE)
+ # Inline event handlers: onclick=, onmouseover=, ...
+ html = re.sub(
+ r"\s+on\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", html,
+ flags=re.IGNORECASE,
+ )
+ # javascript:/vbscript: URLs in href/src -> blank the attribute.
+ def _blank(match):
+ attr, quote, val = (
+ match.group(1), match.group(2), match.group(3)
+ )
+ stripped = val.strip()
+ if re.match(r"(?:javascript|vbscript):", stripped, re.IGNORECASE):
+ return f"{attr}={quote}{quote}"
+ return match.group(0)
+
+ html = re.sub(
+ r"(\s)(href|src)(\s*=\s*)(\"[^\"]*\"|'[^']*'|[^\s>]+)",
+ lambda m: (
+ f'{m.group(1)}{m.group(2)}{m.group(3)}{m.group(4).strip()}'
+ if re.match(r"(?:javascript|vbscript):",
+ m.group(4).strip(), re.IGNORECASE)
+ else m.group(0)
+ ),
+ html, flags=re.IGNORECASE,
+ )
+ return html
+
+
+def zip_header_guard(infos, cap) -> None:
+ """Header-based pre-check shared by both zip-extraction paths (C1).
+
+ Validates that the *claimed* total and per-member uncompressed sizes from
+ the central directory stay within `cap`. This is defense-in-depth; the
+ real decompression-bomb guard is _copy_stream_with_cap, which counts
+ BYTES WRITTEN during extraction rather than trusting the header.
+ """
+ total = sum(i.file_size for i in infos)
+ if total > cap:
+ raise ValueError(
+ f"zip-bomb guard: declared uncompressed size {total} exceeds "
+ f"cap {cap}"
+ )
+ for info in infos:
+ if info.file_size > cap:
+ raise ValueError(
+ f"zip-bomb guard: member {info.filename} declared "
+ f"{info.file_size} bytes (cap {cap})"
+ )
+
+
+def _copy_stream_with_cap(src, dst, cap) -> None:
+ """Stream-copy src\u2192dst counting WRITTEN bytes; abort mid-stream if the
+ decompressed total exceeds `cap` (C1 real enforcement).
+
+ Unlike a header-only check, this counts actual bytes delivered to disk,
+ so a zip that inflates a 5 MB header to 50 GB is stopped mid-write.
+ Satisfies: C1
+ """
+ chunk_size = 64 * 1024
+ written = 0
+ while True:
+ chunk = src.read(chunk_size)
+ if not chunk:
+ return
+ written += len(chunk)
+ if written > cap:
+ raise ValueError(
+ f"zip-bomb guard: member exceeded decompressed cap "
+ f"{written} bytes (cap {cap}); aborting extraction to "
+ "prevent disk-fill"
+ )
+ dst.write(chunk)
+
+
+# Resource-attribute names whose string value WeasyPrint may fetch from the
+# network. Only these (src, href, srcset, poster, data, action, cite,
+# formaction, ping, xlink:href, usesrc) are scanned; every other attribute is
+# ignored so unrelated user markup is never mis-parsed.
+_RESOURCE_ATTRS = frozenset(
+ {"src", "href", "srcset", "poster", "data", "action", "cite",
+ "formaction", "ping", "xlink:href", "usesrc"}
+)
+
+# Matches `name = ` where value is single- or double-quoted.
+_ATTR_RE = re.compile(r"""(\w[\w-]*)\s*=\s*["']([^"']*)["']""", re.IGNORECASE)
+
+
+def scan_for_remote(html: str) -> None:
+ """Pre-render SSRF guard (C2): reject HTML referencing any remote resource.
+
+ WeasyPrint's image loader *swallows* per-fetch ValueError / URLFetchingError
+ and renders a broken image, so a fetcher that merely raises inside a WeasyPrint
+ pass can still leave a job reporting DONE with a remote URL contacted. To make
+ the guard authoritative we reject remote resource references *up-front*: a
+ ValueError here propagates out of html_to_pdf_bytes and the worker records a
+ job error. Local `data:` URIs and same-directory (relative) references pass.
+ Satisfies: C2
+ """
+ for match in _ATTR_RE.finditer(html):
+ if match.group(1).lower() not in _RESOURCE_ATTRS:
+ continue
+ value = match.group(2).strip()
+ if not value:
+ continue
+ for part in value.split(","):
+ tokens = part.split()
+ url = (tokens[0] if tokens else "").strip()
+ if not url:
+ continue
+ scheme = urlsplit(url).scheme.lower()
+ if scheme in _REMOTE_SCHEMES:
+ raise ValueError(
+ f"blocked remote resource reference ({scheme}://) - the "
+ "server does not fetch external URLs"
+ )
+ return
+
+
+def html_to_pdf_bytes(html: str,
+ css: str | None = None,
+ url_fetcher=None) -> bytes:
"""Render an HTML string to PDF bytes via WeasyPrint.
css: extra stylesheet (e.g. PRE_CSS for text dumps). Empty/None means
the document carries its own