mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
61 lines
2.0 KiB
Python
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)
|