fix(html): escape provider data in reports (#12247)

Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
Prowler BotandHugo Pereira Brito authored and GitHub committed 2026-07-30 14:36:22 +01:00
1 parent 2a5a95eb6d
commit d6667ec2cb
3 files changed
+144 -11

No files matched your search

@@ -0,0 +1 @@
HTML reports escape provider-originated finding fields to prevent stored cross-site scripting through malicious cloud resource tags
+40 -11
View File
@@ -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 <a href> 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'<a href="{safe}">{body}</a>' if safe else body
return re.sub(
r'<a\s[^>]*href="(?P<href>[^"]*)"[^>]*>(?P<body>.*?)</a>',
_replace,
html_content,
flags=re.IGNORECASE | re.DOTALL,
)
class HTML(Output):
@staticmethod
@@ -52,7 +81,7 @@ class HTML(Output):
html_content = html_content.replace("<p>", "")
html_content = html_content.replace("</p>", "")
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"""
<tr class="{row_class}">
<td>{finding_status}</td>
<td>{finding.metadata.Severity.value}</td>
<td>{finding.metadata.ServiceName}</td>
<td>{finding.region.lower()}</td>
<td>{finding.metadata.CheckID.replace("_", "<wbr />_")}</td>
<td>{finding.metadata.CheckTitle}</td>
<td>{finding.resource_uid.replace("<", "&lt;").replace(">", "&gt;").replace("_", "<wbr />_")}</td>
<td>{parse_html_string(unroll_dict(finding.resource_tags))}</td>
<td>{finding.status_extended.replace("<", "&lt;").replace(">", "&gt;").replace("_", "<wbr />_")}</td>
<td><p class="show-read-more">{HTML.process_markdown(finding.metadata.Risk)}</p></td>
<td><p class="show-read-more">{HTML.process_markdown(finding.metadata.Remediation.Recommendation.Text)}</p> <a class="read-more" href="{finding.metadata.Remediation.Recommendation.Url}"><i class="fas fa-external-link-alt"></i></a></td>
<td>{str(escape(finding.metadata.Severity.value))}</td>
<td>{str(escape(finding.metadata.ServiceName))}</td>
<td>{str(escape(finding.region.lower()))}</td>
<td>{str(escape(finding.metadata.CheckID)).replace("_", "<wbr />_")}</td>
<td>{str(escape(finding.metadata.CheckTitle))}</td>
<td>{str(escape(finding.resource_uid)).replace("_", "<wbr />_")}</td>
<td>{parse_html_string(str(escape(unroll_dict(finding.resource_tags))))}</td>
<td>{str(escape(finding.status_extended)).replace("_", "<wbr />_")}</td>
<td><p class="show-read-more">{HTML.process_markdown(str(escape(finding.metadata.Risk)))}</p></td>
<td><p class="show-read-more">{HTML.process_markdown(str(escape(finding.metadata.Remediation.Recommendation.Text)))}</p> <a class="read-more" href="{str(escape(_safe_url(finding.metadata.Remediation.Recommendation.Url)))}"><i class="fas fa-external-link-alt"></i></a></td>
<td><p class="show-read-more">{parse_html_string(unroll_dict(finding.compliance, separator=": "))}</p></td>
</tr>
""")
+103
View File
@@ -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 = '<img src=x onerror="window.PROWLER_TAG_XSS=1">'
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&amp;&lt;&gt;&#39;&#34;" in output_data
assert "resource&amp;&lt;&gt;&#39;&#34;<wbr />_uid" in output_data
assert "status&amp;&lt;&gt;&#39;&#34;<wbr />_&lt;img" in output_data
assert "&#x2022;key&amp;&lt;&gt;&#39;&#34;&lt;img" in output_data
assert "=value&amp;&lt;&gt;&#39;&#34;&lt;img" in output_data
def test_transform_escapes_metadata_fields(self):
finding = generate_finding_output()
finding.metadata.Severity = MagicMock(value='<img data-field="severity" src=x>')
finding.metadata.ServiceName = '<img data-field="service" src=x>'
finding.metadata.CheckID = '<img data-field="check_id" src=x>_suffix'
finding.metadata.CheckTitle = '<img data-field="check_title" src=x>'
finding.metadata.Risk = '**Risk** <img data-field="risk" src=x>'
finding.metadata.Remediation.Recommendation.Text = (
'**Recommendation** <img data-field="recommendation" src=x>'
)
finding.metadata.Remediation.Recommendation.Url = (
'https://example.com"><img data-field="url" src=x>'
)
output_data = HTML([finding]).data[0]
raw_payloads = (
'<img data-field="severity" src=x>',
'<img data-field="service" src=x>',
'<img data-field="check_id" src=x>_suffix',
'<img data-field="check_title" src=x>',
'<img data-field="risk" src=x>',
'<img data-field="recommendation" src=x>',
'href="https://example.com"><img data-field="url" src=x>"',
)
for payload in raw_payloads:
assert payload not in output_data
assert "&lt;img data-field=&#34;severity&#34; src=x&gt;" in output_data
assert "&lt;img data-field=&#34;service&#34; src=x&gt;" in output_data
assert (
"&lt;img data-field=&#34;check<wbr />_id&#34; src=x&gt;<wbr />_suffix"
in output_data
)
assert "&lt;img data-field=&#34;check_title&#34; src=x&gt;" in output_data
assert (
"<strong>Risk</strong> &lt;img data-field=&#34;risk&#34; src=x&gt;"
in output_data
)
assert (
"<strong>Recommendation</strong> &lt;img "
"data-field=&#34;recommendation&#34; src=x&gt;" in output_data
)
assert (
'href="https://example.com&#34;&gt;&lt;img '
'data-field=&#34;url&#34; src=x&gt;"' in output_data
)
def test_transform_pass_finding(self):
findings = [
generate_finding_output(
@@ -1116,3 +1185,37 @@ class TestHTML:
assert "<strong>alternate contacts</strong>" in output_data
assert "<code>monitored aliases</code>" in output_data
assert "<br />" 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(&#34;xss&#34;)) 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 "<a" in result
def test_transform_recommendation_url_javascript_scheme_is_blocked(self):
"""Recommendation.Url with javascript: scheme must render as empty href."""
finding = generate_finding_output(
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
)
finding.metadata.Remediation.Recommendation.Url = "javascript:alert(1)"
output_data = HTML([finding]).data[0]
assert 'href="javascript:' not in output_data
assert 'href=""' in output_data
def test_transform_recommendation_url_https_is_kept(self):
"""Recommendation.Url with https: scheme must appear unchanged in href."""
findings = [
generate_finding_output(
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
)
]
output_data = HTML(findings).data[0]
assert 'href="https://hub.prowler.com/check/check-id"' in output_data