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 0000000..145a07d
Binary files /dev/null and b/maigret/resources/blank.png differ
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,