aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDennis Fink2026-08-10 19:36:49 +0200
committerDennis Fink2026-08-10 19:36:49 +0200
commit8350bef3e3baea6042ed3780a054cc65707e9f8d (patch)
tree81ab5f102045f53d23f89e22a41b2f6a2eef9b52 /tests
parentafcdb411882a6c157c3b5008e1c633d3e40cd984 (diff)
downloadwebmentions-ssg-8350bef3e3baea6042ed3780a054cc65707e9f8d.tar.gz
webmentions-ssg-8350bef3e3baea6042ed3780a054cc65707e9f8d.zip
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.
Diffstat (limited to 'tests')
-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
4 files changed, 71 insertions, 0 deletions
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,