aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pyproject.toml1
-rw-r--r--tests/conftest.py24
-rw-r--r--tests/test_forms.py2
-rw-r--r--tests/test_url_security.py42
-rw-r--r--tests/test_views.py3
-rw-r--r--uv.lock11
-rw-r--r--webmentions_ssg/forms/__init__.py3
-rw-r--r--webmentions_ssg/forms/validators.py25
-rw-r--r--webmentions_ssg/tasks/receiver.py18
-rw-r--r--webmentions_ssg/url_security.py37
10 files changed, 165 insertions, 1 deletions
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
@@ -145,6 +145,15 @@ wheels = [
]
[[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"
source = { registry = "https://pypi.org/simple" }
@@ -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"
+ )