Files
rengine/reports/render/fetcher.py
T
Yogesh OjhaandClaude Opus 5 927b95296c fix(reports): images are upload-only, and a small one can no longer exhaust memory
Audited every path an image can reach the renderer by, on the principle that
uploads are the only source. Four of the five were already closed, and the one
that mattered most turned out to be closed for a reason worth recording: an SVG
logo cannot beacon or read a file, because references made from inside the SVG
go through the same fetcher, and WeasyPrint's SVG parser does not resolve XML
entities. An <image href> pointing at a listener got zero hits; an XXE entity
leaked nothing. So SVG stays. Rasterising a logo to avoid a risk that is already
closed would cost real print quality for nothing.

The gap was not a URL at all. WeasyPrint passes a PNG stream straight into the
PDF, so the output stays small, but it still decodes the image: inside the 500 KB
logo limit, a 182 KB file decodes to 64M pixels and cost a measured +594 MB of
RSS, and Pillow's 89M default only warns below twice the limit. Across eight
workers that is several gigabytes from a handful of small uploads. The render now
caps decoded pixels, which takes that same payload to +0 MB, and a dropped image
is surfaced as a report warning instead of vanishing.

Two things this exposed about the previous commit. The fetcher was built on
default_url_fetcher, deprecated in WeasyPrint 69; it is now a URLFetcher subclass
pinned to the data and file protocols. And the first attempt at the pixel cap
silently missed, because the edit was written against a function ruff had already
reformatted: the guard existed, nothing called it, and the probe reported success
by rendering the bomb. Measuring the test harness separately from the renderer is
what caught it.

cover_image was a validated field nothing rendered. It is wired now, uploaded and
embedded like the logo, so it is no longer dead configuration.

Verified after the rewrite: 11/11 on the fetcher matrix, a 124-page report with
the uploaded typeface still embedded, and screenshots still embedding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
2026-09-06 13:07:53 +05:30

61 lines
2.0 KiB
Python

"""The one place the renderer is allowed to load bytes from. Everything else is refused."""
from __future__ import annotations
from pathlib import Path
from urllib.parse import unquote, urlparse
from shared.logging import get_logger
logger = get_logger(__name__)
ASSETS = Path(__file__).resolve().parent.parent / "assets"
CUSTOM_FONTS = Path("/app/report-fonts")
SCAN_MEDIA = Path("/app/scan_media")
# a rendered document may read vendored faces, uploaded faces and captured screenshots
ALLOWED_ROOTS: tuple[Path, ...] = (ASSETS, CUSTOM_FONTS, SCAN_MEDIA)
ALLOWED_PROTOCOLS: tuple[str, ...] = ("data", "file")
class BlockedResourceError(ValueError):
"""The document asked for something outside the allowed roots."""
def _allowed(path: Path) -> bool:
try:
resolved = path.resolve(strict=True)
except OSError:
return False
return any(resolved.is_relative_to(root) for root in ALLOWED_ROOTS)
def permitted(url: str) -> bool:
"""Data URIs, and files under a known root. No network, ever."""
if url.startswith("data:"):
return True
if not url.startswith("file:"):
return False
parsed = urlparse(url)
if parsed.netloc not in ("", "localhost"):
return False
return _allowed(Path(unquote(parsed.path)))
def build_fetcher():
"""A WeasyPrint fetcher that cannot reach the network, whatever the document asks for."""
from weasyprint.urls import URLFetcher # noqa: PLC0415
class SafeFetcher(URLFetcher):
def fetch(self, url, headers=None):
if not permitted(url):
logger.warning("report blocked a resource", url=url[:200])
msg = (
"A report may only load embedded data and files shipped with the "
f"instance. Refused: {url[:120]}"
)
raise BlockedResourceError(msg)
return super().fetch(url, headers)
return SafeFetcher(timeout=5, allowed_protocols=ALLOWED_PROTOCOLS)