From d6667ec2cb15352c866847dd625e639224b2ad41 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 30 Jul 2026 15:36:22 +0200 Subject: [PATCH] fix(html): escape provider data in reports (#12247) Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- .../changelog.d/html-report-xss.security.md | 1 + prowler/lib/outputs/html/html.py | 51 +++++++-- tests/lib/outputs/html/html_test.py | 103 ++++++++++++++++++ 3 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 prowler/changelog.d/html-report-xss.security.md diff --git a/prowler/changelog.d/html-report-xss.security.md b/prowler/changelog.d/html-report-xss.security.md new file mode 100644 index 0000000000..e500556594 --- /dev/null +++ b/prowler/changelog.d/html-report-xss.security.md @@ -0,0 +1 @@ +HTML reports escape provider-originated finding fields to prevent stored cross-site scripting through malicious cloud resource tags diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 3463014232..675811951d 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1,5 +1,7 @@ +import re import sys from io import TextIOWrapper +from urllib.parse import urlparse import markdown from markupsafe import escape @@ -16,6 +18,33 @@ from prowler.lib.outputs.output import Finding, Output from prowler.lib.outputs.utils import parse_html_string, unroll_dict from prowler.providers.common.provider import Provider +_SAFE_URL_SCHEMES = {"http", "https"} + + +def _safe_url(url: str) -> str: + """Return url if its scheme is http/https, otherwise return empty string.""" + if not url: + return "" + scheme = urlparse(url).scheme.lower() + return url if scheme in _SAFE_URL_SCHEMES else "" + + +def _strip_unsafe_links(html_content: str) -> str: + """Replace tags whose href is not http/https with their link text.""" + + def _replace(match: re.Match) -> str: + href = match.group("href") + body = match.group("body") + safe = _safe_url(href) + return f'{body}' if safe else body + + return re.sub( + r']*href="(?P[^"]*)"[^>]*>(?P.*?)', + _replace, + html_content, + flags=re.IGNORECASE | re.DOTALL, + ) + class HTML(Output): @staticmethod @@ -52,7 +81,7 @@ class HTML(Output): html_content = html_content.replace("

", "") html_content = html_content.replace("

", "") - return html_content + return _strip_unsafe_links(html_content) def transform(self, findings: list[Finding]) -> None: """Transforms the findings into the HTML format. @@ -77,16 +106,16 @@ class HTML(Output): self._data.append(f""" {finding_status} - {finding.metadata.Severity.value} - {finding.metadata.ServiceName} - {finding.region.lower()} - {finding.metadata.CheckID.replace("_", "_")} - {finding.metadata.CheckTitle} - {finding.resource_uid.replace("<", "<").replace(">", ">").replace("_", "_")} - {parse_html_string(unroll_dict(finding.resource_tags))} - {finding.status_extended.replace("<", "<").replace(">", ">").replace("_", "_")} -

{HTML.process_markdown(finding.metadata.Risk)}

-

{HTML.process_markdown(finding.metadata.Remediation.Recommendation.Text)}

+ {str(escape(finding.metadata.Severity.value))} + {str(escape(finding.metadata.ServiceName))} + {str(escape(finding.region.lower()))} + {str(escape(finding.metadata.CheckID)).replace("_", "_")} + {str(escape(finding.metadata.CheckTitle))} + {str(escape(finding.resource_uid)).replace("_", "_")} + {parse_html_string(str(escape(unroll_dict(finding.resource_tags))))} + {str(escape(finding.status_extended)).replace("_", "_")} +

{HTML.process_markdown(str(escape(finding.metadata.Risk)))}

+

{HTML.process_markdown(str(escape(finding.metadata.Remediation.Recommendation.Text)))}

{parse_html_string(unroll_dict(finding.compliance, separator=": "))}

""") diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 3eccc8ba8d..1459c34b3c 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -701,6 +701,75 @@ class TestHTML: assert isinstance(output_data, str) assert output_data == fail_html_finding + def test_transform_escapes_provider_originated_fields(self): + xss_payload = '' + findings = [ + generate_finding_output( + region="REGION&<>'\"", + resource_uid="resource&<>'\"_uid", + resource_tags={f"key&<>'\"{xss_payload}": f"value&<>'\"{xss_payload}"}, + status_extended=f"status&<>'\"_{xss_payload}", + remediation_recommendation_url="https://hub.prowler.com/check/check-id", + ) + ] + + output_data = HTML(findings).data[0] + + assert xss_payload not in output_data + assert "region&<>'"" in output_data + assert "resource&<>'"_uid" in output_data + assert "status&<>'"_<img" in output_data + assert "•key&<>'"<img" in output_data + assert "=value&<>'"<img" in output_data + + def test_transform_escapes_metadata_fields(self): + finding = generate_finding_output() + finding.metadata.Severity = MagicMock(value='') + finding.metadata.ServiceName = '' + finding.metadata.CheckID = '_suffix' + finding.metadata.CheckTitle = '' + finding.metadata.Risk = '**Risk** ' + finding.metadata.Remediation.Recommendation.Text = ( + '**Recommendation** ' + ) + finding.metadata.Remediation.Recommendation.Url = ( + 'https://example.com">' + ) + + output_data = HTML([finding]).data[0] + + raw_payloads = ( + '', + '', + '_suffix', + '', + '', + '', + 'href="https://example.com">"', + ) + for payload in raw_payloads: + assert payload not in output_data + + assert "<img data-field="severity" src=x>" in output_data + assert "<img data-field="service" src=x>" in output_data + assert ( + "<img data-field="check_id" src=x>_suffix" + in output_data + ) + assert "<img data-field="check_title" src=x>" in output_data + assert ( + "Risk <img data-field="risk" src=x>" + in output_data + ) + assert ( + "Recommendation <img " + "data-field="recommendation" src=x>" in output_data + ) + assert ( + 'href="https://example.com"><img ' + 'data-field="url" src=x>"' in output_data + ) + def test_transform_pass_finding(self): findings = [ generate_finding_output( @@ -1116,3 +1185,37 @@ class TestHTML: assert "alternate contacts" in output_data assert "monitored aliases" in output_data assert "
" in output_data # Line breaks converted + + def test_process_markdown_strips_javascript_links(self): + """Markdown links with javascript: scheme must not produce clickable hrefs.""" + result = HTML.process_markdown( + "Click [here](javascript:alert("xss")) to continue" + ) + assert 'href="javascript:' not in result + assert "here" in result + + def test_process_markdown_keeps_https_links(self): + """Markdown links with https: scheme must be preserved.""" + result = HTML.process_markdown("[docs](https://docs.prowler.com)") + assert 'href="https://docs.prowler.com"' in result + assert "