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,