From f2e4f8d3a2db33bd518eb72ff0699726cc8e58bf Mon Sep 17 00:00:00 2001 From: Soxoj <31013580+soxoj@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:15:32 +0200 Subject: [PATCH] Merge commit from fork --- maigret/report.py | 4 +- maigret/utils.py | 7 +++- tests/test_report.py | 93 +++++++++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 23 +++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/maigret/report.py b/maigret/report.py index 5f9d297..35263d6 100644 --- a/maigret/report.py +++ b/maigret/report.py @@ -436,7 +436,9 @@ def generate_report_template(is_pdf: bool): template_content = get_resource_content("simple_report.tpl") css_content = None - template = Template(template_content) + # autoescape: report data comes from scanned profiles and must be escaped + # to avoid XSS in the generated report. + template = Template(template_content, autoescape=True) template.globals["title"] = CaseConverter.snake_to_title # type: ignore template.globals["detect_link"] = enrich_link_str # type: ignore return template, css_content diff --git a/maigret/utils.py b/maigret/utils.py index 6e5d311..10b9926 100644 --- a/maigret/utils.py +++ b/maigret/utils.py @@ -6,6 +6,8 @@ import random import string from typing import Any +from markupsafe import Markup, escape + DEFAULT_USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", @@ -38,7 +40,10 @@ def is_country_tag(tag: str) -> bool: def enrich_link_str(link: str) -> str: link = link.strip() if link.startswith("www.") or (link.startswith("http") and "//" in link): - return f'{link}' + # escape the link to avoid XSS; Markup keeps the tag itself intact + # under the template's autoescaping. + safe = escape(link) + return Markup(f'{safe}') return link diff --git a/tests/test_report.py b/tests/test_report.py index 22f1677..ed09558 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -11,6 +11,7 @@ from io import StringIO import xmind # type: ignore[import-untyped] from jinja2 import Template +from markupsafe import escape from maigret.report import ( filter_supposed_data, @@ -416,11 +417,101 @@ def test_html_report(): report_text = open(report_name).read() - assert SUPPOSED_BRIEF in report_text + # the HTML report escapes its context, so the brief is rendered with + # HTML entities (e.g. the apostrophe in "target's") + assert str(escape(SUPPOSED_BRIEF)) in report_text assert SUPPOSED_GEO in report_text assert SUPPOSED_INTERESTS in report_text +# profile data from scanned sites must be escaped so a planted payload cannot +# execute in the report +XSS_NAME_PAYLOAD = '' +XSS_IMAGE_PAYLOAD = 'x" onerror="alert(1)' +XSS_LINK_PAYLOAD = 'http://evil.example/">' + + +def _xss_username_results(): + result = copy.deepcopy(GOOD_RESULT) + result.tags = ['photo', 'us'] + result.ids_data = { + "name": XSS_NAME_PAYLOAD, + "bio": XSS_NAME_PAYLOAD, + "image": XSS_IMAGE_PAYLOAD, + "external_url": XSS_LINK_PAYLOAD, + } + data = { + 'EvilSite': { + 'username': 'victimtarget', + 'parsing_enabled': True, + 'url_main': 'https://evil.example/', + 'url_user': 'https://evil.example/victimtarget', + 'status': result, + 'http_status': 200, + 'is_similar': False, + 'rank': 1, + 'site': MaigretSite('EvilSite', {}), + 'found': True, + 'ids_data': result.ids_data, + }, + } + return [('victimtarget', 'username', data)] + + +def _assert_no_xss(rendered: str): + # no executable payload markup survives, only escaped (harmless) text + assert XSS_NAME_PAYLOAD not in rendered + assert 'alert(1)' not in rendered + assert 'onerror="alert(1)"' not in rendered # image attribute breakout + assert '<img src=x onerror=alert(document.domain)>' in rendered + + +def test_html_report_escapes_extracted_profile_data(): + context = generate_report_context(_xss_username_results()) + template, _ = generate_report_template(is_pdf=False) + rendered = template.render(**context) + + _assert_no_xss(rendered) + + +def test_pdf_report_escapes_extracted_profile_data(): + context = generate_report_context(_xss_username_results()) + template, _ = generate_report_template(is_pdf=True) + rendered = template.render(**context) + + _assert_no_xss(rendered) + + +def test_report_preserves_legit_auto_link(): + # A benign extracted link must still render as a real, clickable anchor. + result = copy.deepcopy(GOOD_RESULT) + result.ids_data = {"external_url": "https://example.com/profile"} + data = { + 'Site': { + 'username': 'u', + 'parsing_enabled': True, + 'url_main': 'https://example.com/', + 'url_user': 'https://example.com/u', + 'status': result, + 'http_status': 200, + 'is_similar': False, + 'rank': 1, + 'site': MaigretSite('Site', {}), + 'found': True, + 'ids_data': result.ids_data, + }, + } + context = generate_report_context([('u', 'username', data)]) + template, _ = generate_report_template(is_pdf=False) + rendered = template.render(**context) + + assert ( + '' + 'https://example.com/profile' + ) in rendered + + def test_html_report_broken(): report_name = 'report_test_broken.html' BROKEN_DATA = copy.deepcopy(TEST) diff --git a/tests/test_utils.py b/tests/test_utils.py index ddbe303..2a96c80 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,8 @@ import itertools import re +from markupsafe import Markup + from maigret.utils import ( CaseConverter, is_country_tag, @@ -76,6 +78,27 @@ def test_enrich_link_str(): ) +def test_enrich_link_str_escapes_payload(): + # markup inside a link must be escaped while the wrapper is preserved + payload = 'http://evil.example/">' + result = enrich_link_str(payload) + + assert isinstance(result, Markup) + assert '