From 3bae4108a3ba1e507727d5264697fd7303ba0b89 Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Sun, 12 Jul 2026 15:09:36 +0200 Subject: [PATCH] fix html markdown SSRF IPv4-mapped IPv6 bypass --- .../modules/expansion/html_to_markdown.py | 9 ++++- tests/test_html_to_markdown.py | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/test_html_to_markdown.py diff --git a/misp_modules/modules/expansion/html_to_markdown.py b/misp_modules/modules/expansion/html_to_markdown.py index f1dfeabc..ae8b428b 100755 --- a/misp_modules/modules/expansion/html_to_markdown.py +++ b/misp_modules/modules/expansion/html_to_markdown.py @@ -37,8 +37,13 @@ ] -def _is_ip_blocked(ip_str: str) -> bool: +def _normalize_ip_address(ip_str: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address: ip = ipaddress.ip_address(ip_str) + return ip.ipv4_mapped or ip + + +def _is_ip_blocked(ip_str: str) -> bool: + ip = _normalize_ip_address(ip_str) return any(ip in net for net in BLOCKED_RANGES) @@ -52,7 +57,7 @@ def _hostname_resolves_to_blocked_ip(hostname: str) -> bool: def is_safe_url(url: str) -> bool: parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): + if parsed.scheme not in ("http", "https") or not parsed.hostname: return False try: return not _is_ip_blocked(parsed.hostname) diff --git a/tests/test_html_to_markdown.py b/tests/test_html_to_markdown.py new file mode 100644 index 00000000..1ffaf35b --- /dev/null +++ b/tests/test_html_to_markdown.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import unittest +from unittest.mock import patch + +from misp_modules.modules.expansion.html_to_markdown import is_safe_url + + +class TestHtmlToMarkdownUrlSafety(unittest.TestCase): + + def test_blocks_ipv4_mapped_ipv6_literals_for_blocked_ipv4_ranges(self): + blocked_urls = ( + "http://[::ffff:127.0.0.1]/", + "http://[::ffff:10.0.0.1]/", + "http://[::ffff:172.16.0.1]/", + "http://[::ffff:192.168.0.1]/", + "http://[::ffff:169.254.169.254]/", + ) + + for url in blocked_urls: + with self.subTest(url=url): + self.assertFalse(is_safe_url(url)) + + def test_allows_public_ipv4_mapped_ipv6_literal(self): + self.assertTrue(is_safe_url("http://[::ffff:93.184.216.34]/")) + + def test_blocks_hostnames_resolving_to_ipv4_mapped_blocked_addresses(self): + with patch( + "misp_modules.modules.expansion.html_to_markdown.socket.getaddrinfo", + return_value=[(None, None, None, None, ("::ffff:127.0.0.1", 0, 0, 0))], + ): + self.assertFalse(is_safe_url("http://example.test/")) + + def test_rejects_url_without_hostname(self): + self.assertFalse(is_safe_url("http:///missing-host")) + + +if __name__ == "__main__": + unittest.main()