From 8350bef3e3baea6042ed3780a054cc65707e9f8d Mon Sep 17 00:00:00 2001 From: Dennis Fink Date: Mon, 10 Aug 2026 19:36:49 +0200 Subject: fix(receiver): prevent requests to non-public addresses Resolve source hostnames before fetching and reject addresses that are not globally routable to prevent SSRF against local or private services. Repeat the check for every HTTP request so redirects cannot bypass the initial source validation. Treat DNS resolution failures during verification as temporary fetch errors. --- pyproject.toml | 1 + tests/conftest.py | 24 +++++++++++++++++++++ tests/test_forms.py | 2 ++ tests/test_url_security.py | 42 +++++++++++++++++++++++++++++++++++++ tests/test_views.py | 3 +++ uv.lock | 11 ++++++++++ webmentions_ssg/forms/__init__.py | 3 ++- webmentions_ssg/forms/validators.py | 25 ++++++++++++++++++++++ webmentions_ssg/tasks/receiver.py | 18 ++++++++++++++++ webmentions_ssg/url_security.py | 37 ++++++++++++++++++++++++++++++++ 10 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/test_url_security.py create mode 100644 webmentions_ssg/url_security.py diff --git a/pyproject.toml b/pyproject.toml index 9da3352..9af850a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "beautifulsoup4>=4.15.0", "bootstrap-flask>=2.5.0", "coloredlogs>=15.0.1", + "dnspython>=2.8.0", "flask>=3.1.3", "flask-login>=0.6.3", "flask-migrate>=4.1.0", diff --git a/tests/conftest.py b/tests/conftest.py index 94ba650..f1ef4b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,14 @@ HTTPHandler = Callable[ ] +class FakeHostAnswers: + def __init__(self, *addresses: str): + self._addresses = addresses + + def addresses(self): + return iter(self._addresses) + + @pytest.fixture def app() -> Iterator[Flask]: """ @@ -101,6 +109,12 @@ def install_httpx_mock( Replace the HTTPX transport without replacing HTTPX itself. """ + monkeypatch.setattr( + receiver_module, + "ensure_public_url", + lambda url: None, + ) + real_client = httpx.Client def install(handler: HTTPHandler) -> None: @@ -123,3 +137,13 @@ def install_httpx_mock( ) return install + + +@pytest.fixture +def public_dns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "webmentions_ssg.url_security.dns.resolver.resolve_name", + lambda hostname: FakeHostAnswers("93.184.216.34"), + ) diff --git a/tests/test_forms.py b/tests/test_forms.py index 9be6ca8..2d5fc01 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -89,6 +89,7 @@ VALID_TARGET = "https://dennisfink.me/blog/example/" ) def test_endpoint_form_rejects_invalid_data( app: Flask, + public_dns: None, form_data: dict[str, str], invalid_field: str, expected_error: str | None, @@ -111,6 +112,7 @@ def test_endpoint_form_rejects_invalid_data( def test_endpoint_form_accepts_valid_data( app: Flask, + public_dns: None, ) -> None: with app.test_request_context( "/endpoint", diff --git a/tests/test_url_security.py b/tests/test_url_security.py new file mode 100644 index 0000000..fba6d7d --- /dev/null +++ b/tests/test_url_security.py @@ -0,0 +1,42 @@ +import pytest + +from webmentions_ssg.url_security import ( + NonPublicAddressError, + ensure_public_url, +) + + +class FakeAnswers: + def __init__(self, *addresses: str): + self._addresses = addresses + + def addresses(self): + return iter(self._addresses) + + +@pytest.mark.parametrize( + ("url", "resolved_addresses"), + [ + ("http://127.0.0.1/", ["127.0.0.1"]), + ("http://127.0.0.1:8080/test", ["127.0.0.1"]), + ("http://[::1]/", ["::1"]), + ("http://10.0.0.1/", ["10.0.0.1"]), + ("http://172.16.0.1/", ["172.16.0.1"]), + ("http://192.168.1.1/", ["192.168.1.1"]), + ("http://169.254.169.254/", ["169.254.169.254"]), + ("http://localhost/", ["127.0.0.1", "::1"]), + ("http://internal.example/", ["192.168.1.10"]), + ], +) +def test_ensure_public_url_rejects_non_public_addresses( + monkeypatch: pytest.MonkeyPatch, + url: str, + resolved_addresses: list[str], +) -> None: + monkeypatch.setattr( + "webmentions_ssg.url_security.dns.resolver.resolve_name", + lambda hostname: FakeAnswers(*resolved_addresses), + ) + + with pytest.raises(NonPublicAddressError): + ensure_public_url(url) diff --git a/tests/test_views.py b/tests/test_views.py index d7fd798..70ed732 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -42,6 +42,7 @@ def test_endpoint_creates_webmention( client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + public_dns: None, ) -> None: queued: list[uuid.UUID] = [] @@ -85,6 +86,7 @@ def test_endpoint_is_idempotent( client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + public_dns: None, ) -> None: queued: list[uuid.UUID] = [] @@ -138,6 +140,7 @@ def test_resending_resets_failure_state( client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + public_dns: None, ) -> None: monkeypatch.setattr( views_module, diff --git a/uv.lock b/uv.lock index cee73c3..1fb4556 100644 --- a/uv.lock +++ b/uv.lock @@ -144,6 +144,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -541,6 +550,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "bootstrap-flask" }, { name = "coloredlogs" }, + { name = "dnspython" }, { name = "flask" }, { name = "flask-login" }, { name = "flask-migrate" }, @@ -566,6 +576,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "bootstrap-flask", specifier = ">=2.5.0" }, { name = "coloredlogs", specifier = ">=15.0.1" }, + { name = "dnspython", specifier = ">=2.8.0" }, { name = "flask", specifier = ">=3.1.3" }, { name = "flask-login", specifier = ">=0.6.3" }, { name = "flask-migrate", specifier = ">=4.1.0" }, diff --git a/webmentions_ssg/forms/__init__.py b/webmentions_ssg/forms/__init__.py index 98962ad..0c54999 100644 --- a/webmentions_ssg/forms/__init__.py +++ b/webmentions_ssg/forms/__init__.py @@ -4,7 +4,7 @@ from flask_wtf import FlaskForm from wtforms import PasswordField, StringField, SubmitField from wtforms.validators import URL, InputRequired, Regexp -from .validators import AllowedHostname, NotEqualTo +from .validators import AllowedHostname, NotEqualTo, PublicURL class LoginForm(FlaskForm): @@ -25,6 +25,7 @@ class EndpointForm(FlaskForm): message="source must begin with http or https", ), NotEqualTo("target"), + PublicURL(), ], ) target = StringField( diff --git a/webmentions_ssg/forms/validators.py b/webmentions_ssg/forms/validators.py index 20d9ebf..e979644 100644 --- a/webmentions_ssg/forms/validators.py +++ b/webmentions_ssg/forms/validators.py @@ -3,6 +3,12 @@ from urllib.parse import urlsplit from flask import current_app from wtforms import ValidationError +from ..url_security import ( + AddressResolutionError, + NonPublicAddressError, + ensure_public_url, +) + class NotEqualTo: """ @@ -60,3 +66,22 @@ class AllowedHostname: message = field.gettext("Invalid input.") raise ValidationError(message) + + +class PublicURL: + def __init__(self, message=None): + self.message = message + + def __call__(self, form, field): + try: + ensure_public_url(field.data) + except ( + AddressResolutionError, + NonPublicAddressError, + ) as exc: + message = self.message + + if message is None: + message = field.gettext("URL must resolve to a public address.") + + raise ValidationError(message) from exc diff --git a/webmentions_ssg/tasks/receiver.py b/webmentions_ssg/tasks/receiver.py index ea48299..9475866 100644 --- a/webmentions_ssg/tasks/receiver.py +++ b/webmentions_ssg/tasks/receiver.py @@ -10,6 +10,11 @@ from .. import APP_NAME, VERSION from .. import DATABASE as db from .. import HUEY as huey from ..models import ReceivedWebmention +from ..url_security import ( + AddressResolutionError, + NonPublicAddressError, + ensure_public_url, +) class VerificationError(Exception): @@ -103,6 +108,16 @@ def text_mentions_target(body: str, target_url: str) -> bool: return any(match.group() == target_url for match in IRI_PATTERN.finditer(body)) +def ensure_public_request(request: httpx.Request) -> None: + """Prevent requests to non-public network addresses.""" + try: + ensure_public_url(str(request.url)) + except NonPublicAddressError as exc: + raise VerificationError("Source resolves to a non-public address") from exc + except AddressResolutionError as exc: + raise TemporaryFetchError("Source hostname could not be resolved") from exc + + def fetch_source(source_url: str) -> tuple[httpx.Response, bytes]: """Fetch a source with limits on redirects, time, and response size.""" @@ -117,6 +132,9 @@ def fetch_source(source_url: str) -> tuple[httpx.Response, bytes]: follow_redirects=True, max_redirects=current_app.config.get("WEBMENTIONS_SSG_MAX_REDIRECTS", 20), trust_env=False, + event_hooks={ + "request": [ensure_public_request], + }, ) as client: with client.stream("GET", source_url) as response: match response.status_code: diff --git a/webmentions_ssg/url_security.py b/webmentions_ssg/url_security.py new file mode 100644 index 0000000..ce5d700 --- /dev/null +++ b/webmentions_ssg/url_security.py @@ -0,0 +1,37 @@ +import ipaddress +from urllib.parse import urlsplit + +import dns.exception +import dns.resolver + + +class AddressResolutionError(Exception): + pass + + +class NonPublicAddressError(Exception): + pass + + +def ensure_public_url(url: str) -> None: + hostname = urlsplit(url).hostname + + if hostname is None: + raise NonPublicAddressError("URL has no hostname") + + try: + answers = dns.resolver.resolve_name(hostname) + except dns.exception.DNSException as exc: + raise AddressResolutionError( + f"Could not resolve hostname {hostname!r}" + ) from exc + + addresses = {ipaddress.ip_address(address) for address in answers.addresses()} + + if not addresses: + raise AddressResolutionError(f"Hostname {hostname!r} did not resolve") + + if any(not address.is_global for address in addresses): + raise NonPublicAddressError( + f"Hostname {hostname!r} resolves to a non-public address" + ) -- cgit v1.3.1