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 '![]()
![]()