fix: block SSRF / local-file reads via report image URLs during PDF generation (#2908)

* fix: block SSRF and local-file reads via report image URLs in PDF generation

save_pdf_report() rendered scraped profile image URLs (ids_data['image'])
straight into xhtml2pdf, which fetches <img src> while building the PDF.
The image field is attacker-influenced and pisaDocument ran with no
link_callback, so a profile carrying image = "file:///etc/passwd" or an
intranet/metadata URL turned report generation into a local file read or
an SSRF from the machine running maigret. In the web UI this is
server-side and fires on every search, since save_pdf_report is always
called.

Add a link_callback that only lets public http(s) images through and
diverts everything else (file://, data:, other schemes, and hosts that
resolve to loopback/private/link-local/reserved addresses) to a bundled
1x1 placeholder, so no fetch or read happens. Diverting rather than
raising keeps report generation working when a scanned profile carries a
hostile image URL.

Tests cover the URL classifier, the callback's placeholder diversion, and
an end-to-end check that PDF generation does not fetch an internal image.

* fix: use is_global to also block CGNAT (100.64.0.0/10) report image hosts

The flag chain missed 100.64.0.0/10, which is neither is_private nor
is_global and is routable inside many cloud and k8s networks. is_global
covers it along with private, loopback, link-local and unspecified.

Multicast and reserved stay explicit: both are still is_global on
CPython, and 64:ff9b::/96 reaches IPv4 through a NAT64 gateway.
This commit is contained in:
Ashvin
2026-07-27 17:57:08 +05:00
committed by GitHub
parent 76c994f7bc
commit 0fd5bf66e8
3 changed files with 188 additions and 1 deletions
+69 -1
View File
@@ -1,11 +1,14 @@
import ast
import csv
import io
import ipaddress
import json
import logging
import os
import socket
from datetime import datetime
from typing import Dict, Any
from urllib.parse import urlparse
import xmind # type: ignore[import-untyped]
from dateutil.tz import gettz
@@ -83,6 +86,66 @@ PDF_EXTRA_HINT = (
"Install it with: pip install 'maigret[pdf]'"
)
# 1x1 transparent PNG substituted for any report image URL that isn't a safe
# public http(s) resource (see _is_safe_report_image_url).
_BLANK_IMAGE_PATH = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "resources", "blank.png"
)
def _is_safe_report_image_url(uri) -> bool:
"""Whether ``uri`` is a public http(s) image safe to fetch during PDF render.
Report images come from scraped profile data (``ids_data['image']``), which
is attacker-influenced. xhtml2pdf resolves ``<img src>`` while building the
PDF, so an intranet or cloud-metadata URL is a request made by the machine
running maigret (server-side in the web UI). A src with no scheme at all is
worse than a URL: xhtml2pdf falls back to treating it as a local path and
opens it. Only allow http(s) hosts that resolve to public addresses.
"""
if not isinstance(uri, str):
return False
parsed = urlparse(uri.strip())
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
try:
infos = socket.getaddrinfo(host, None)
except (socket.gaierror, UnicodeError, ValueError):
return False
if not infos:
return False
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return False
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
# is_global is False for private, loopback, link-local, unspecified and
# CGNAT (100.64.0.0/10) addresses. Multicast and reserved ranges are
# still is_global on CPython, so they stay explicit: 64:ff9b::/96 is
# reserved but routes to IPv4 through a NAT64 gateway.
if not ip.is_global or ip.is_multicast or ip.is_reserved:
return False
return True
def _pdf_report_link_callback(uri, rel):
"""xhtml2pdf resource resolver: let safe public images through, and divert
everything else to a local blank placeholder so no fetch/read happens.
Returning a local path (rather than raising) keeps report generation working
even when a scanned profile carries a hostile image URL — a raise would abort
the whole PDF.
"""
if _is_safe_report_image_url(uri):
return uri
return _BLANK_IMAGE_PATH
def save_pdf_report(filename: str, context: dict):
# Imported lazily so that users without the optional 'pdf' extra
@@ -96,7 +159,12 @@ def save_pdf_report(filename: str, context: dict):
filled_template = template.render(**context)
with open(filename, "w+b") as f:
pisa.pisaDocument(io.StringIO(filled_template), dest=f, default_css=css)
pisa.pisaDocument(
io.StringIO(filled_template),
dest=f,
default_css=css,
link_callback=_pdf_report_link_callback,
)
def save_json_report(filename: str, username: str, results: dict, report_type: str):
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+119
View File
@@ -31,6 +31,9 @@ from maigret.report import (
generate_json_report,
get_plaintext_report,
_graph_to_cypher,
_is_safe_report_image_url,
_pdf_report_link_callback,
_BLANK_IMAGE_PATH,
)
from maigret.errors import CheckError
from maigret.result import MaigretCheckResult, MaigretCheckStatus
@@ -651,6 +654,122 @@ def test_xhtml2pdf_is_not_module_level_dependency():
assert 'pisa' not in module_globals
# Report images come from scraped profile data and are attacker-influenced;
# xhtml2pdf resolves <img src> while rendering the PDF, so an intranet URL is a
# request from the host, and a src with no scheme is opened as a local file.
def test_is_safe_report_image_url_rejects_dangerous():
bad = [
"file:///etc/passwd",
"file://C:/Windows/win.ini",
"data:text/html,<script>",
"ftp://example.com/x.png",
"http://127.0.0.1/a.png",
"http://localhost/a.png",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.5/a.png",
"http://192.168.1.1/a.png",
"http://172.16.0.1/a.png",
"http://[::1]/a.png",
"http://0.0.0.0/a.png",
"//example.com/a.png", # scheme-relative, no scheme
# No scheme at all: xhtml2pdf treats these as local paths and opens them.
"/etc/passwd",
"C:/Windows/win.ini",
"../../../../etc/shadow",
"",
None,
42,
]
for value in bad:
assert _is_safe_report_image_url(value) is False, value
def test_is_safe_report_image_url_rejects_non_global_ranges():
# Ranges that the private/loopback/link-local flags alone do not catch.
bad = [
# CGNAT / shared address space, common inside cloud and k8s networks.
"http://100.64.0.1/a.png",
"http://100.127.255.254/a.png",
# Multicast and reserved are still is_global on CPython, so a bare
# `return ip.is_global` would let these through.
"http://224.0.0.1/a.png",
"http://239.255.255.250/a.png",
"http://[ff02::1]/a.png",
# NAT64 well-known prefix: reaches 10.0.0.1 through a NAT64 gateway.
"http://[64:ff9b::a00:1]/a.png",
# IPv4-mapped IPv6 forms of blocked v4 addresses.
"http://[::ffff:127.0.0.1]/a.png",
"http://[::ffff:169.254.169.254]/a.png",
]
for value in bad:
assert _is_safe_report_image_url(value) is False, value
def test_is_safe_report_image_url_allows_public_hosts():
# IP literals resolve without DNS, so this stays offline and deterministic.
assert _is_safe_report_image_url("https://1.1.1.1/avatar.png") is True
assert _is_safe_report_image_url("http://8.8.8.8/avatar.png") is True
def test_pdf_link_callback_diverts_unsafe_to_local_blank():
assert os.path.exists(_BLANK_IMAGE_PATH)
# Unsafe URLs resolve to a local placeholder path, so xhtml2pdf never
# fetches or reads them.
assert _pdf_report_link_callback("file:///etc/passwd", "") == _BLANK_IMAGE_PATH
assert (
_pdf_report_link_callback("http://169.254.169.254/x", "") == _BLANK_IMAGE_PATH
)
# Safe public URLs pass through unchanged.
url = "https://1.1.1.1/a.png"
assert _pdf_report_link_callback(url, "") == url
def test_pdf_report_does_not_fetch_unsafe_image(tmp_path):
pytest.importorskip("xhtml2pdf")
import http.server
import socketserver
import threading
hits = []
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
hits.append(self.path)
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass
srv = socketserver.TCPServer(("127.0.0.1", 0), _Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
internal = f"http://127.0.0.1:{port}/SSRF-should-not-happen"
res = MaigretCheckResult(
"alice",
"TestSite",
"http://testsite/alice",
MaigretCheckStatus.CLAIMED,
ids_data={"image": internal},
)
username_results = [
(
"alice",
"username",
{"TestSite": {"status": res, "url_user": "http://testsite/alice"}},
)
]
context = generate_report_context(username_results)
target = tmp_path / "report.pdf"
save_pdf_report(str(target), context)
assert target.exists()
finally:
srv.shutdown()
assert hits == [], f"PDF generation fetched a blocked URL: {hits}"
def test_import_maigret_without_pdf_extras():
# End-to-end check: spawn a fresh interpreter with every package in the
# [pdf] extra blocked before any maigret module is loaded, and confirm