From 0fd5bf66e8da32f866c0ee7f4af0eeccd60dc250 Mon Sep 17 00:00:00 2001
From: Ashvin <76151462+ashvinctrl@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:27:08 +0530
Subject: [PATCH] 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
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.
---
maigret/report.py | 70 ++++++++++++++++++++-
maigret/resources/blank.png | Bin 0 -> 70 bytes
tests/test_report.py | 119 ++++++++++++++++++++++++++++++++++++
3 files changed, 188 insertions(+), 1 deletion(-)
create mode 100644 maigret/resources/blank.png
diff --git a/maigret/report.py b/maigret/report.py
index 2429ecf..4711aa6 100644
--- a/maigret/report.py
+++ b/maigret/report.py
@@ -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 ``
`` 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):
diff --git a/maigret/resources/blank.png b/maigret/resources/blank.png
new file mode 100644
index 0000000000000000000000000000000000000000..145a07dbcb5cf07a4a8560492854177f3d6ce292
GIT binary patch
literal 70
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9x<|HQo0g%hez_|3As{@e5
N;OXk;vd$@?2>_B<4XpqG
literal 0
HcmV?d00001
diff --git a/tests/test_report.py b/tests/test_report.py
index 50603ce..10c6d71 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -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
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,