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