mirror of
https://github.com/soxoj/maigret.git
synced 2026-08-17 19:25:41 +02:00
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:
+69
-1
@@ -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 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user