Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@
# Mastodon uses a comparable window (SUD-F2).
AP_SIGNATURE_MAX_SKEW = int(os.environ.get("AP_SIGNATURE_MAX_SKEW", "300"))

# Whether plain-http actor fetches are permitted. https is mandatory by default;
# development overrides this to reach local http peers (SUD-F3).
AP_ALLOW_INSECURE_HTTP = os.environ.get("AP_ALLOW_INSECURE_HTTP", "0") == "1"

# =================================================================
# INGESTION
# =================================================================
Expand Down
3 changes: 3 additions & 0 deletions config/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
# ActivityPub base URL
AP_BASE_URL = f"http://{DOMAIN}"

# Allow plain-http actor fetches in dev (local peers over http)
AP_ALLOW_INSECURE_HTTP = True

CSRF_TRUSTED_ORIGINS = [f"http://{DOMAIN}", "http://localhost:8000"]

EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
Expand Down
28 changes: 24 additions & 4 deletions suddenly/activitypub/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@


def _is_blocked_ip(ip: str) -> bool:
"""Return True if an IP is private, loopback, or link-local (SSRF target)."""
"""Return True if an IP is a non-routable SSRF target.

Blocks private/RFC1918, loopback, and link-local ranges, plus reserved,
multicast, and the unspecified address (0.0.0.0 / ::) — all of which can
reach internal or non-public destinations (SUD-F3).
"""
try:
ip_obj = ipaddress.ip_address(ip)
except ValueError:
return True
return bool(ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local)
return bool(
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_reserved
or ip_obj.is_multicast
or ip_obj.is_unspecified
)


def fetch_ap_actor(url: str, *, timeout: int = 10) -> dict[str, Any] | None:
Expand All @@ -43,9 +55,15 @@ def fetch_ap_actor(url: str, *, timeout: int = 10) -> dict[str, Any] | None:
The parsed actor dict on a 200 response, or None on any failure.
"""
import httpx
from django.conf import settings

parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
# https is mandatory in production; http is only tolerated where explicitly
# allowed (dev/test) via AP_ALLOW_INSECURE_HTTP. Plain http otherwise lets a
# peer downgrade the fetch and strip TLS auth of the actor document.
allow_http = getattr(settings, "AP_ALLOW_INSECURE_HTTP", False)
allowed_schemes = ("http", "https") if allow_http else ("https",)
if parsed.scheme not in allowed_schemes:
return None

hostname = parsed.hostname
Expand Down Expand Up @@ -81,7 +99,9 @@ def fetch_ap_actor(url: str, *, timeout: int = 10) -> dict[str, Any] | None:
extensions["sni_hostname"] = hostname

try:
with httpx.Client(timeout=timeout) as client:
# follow_redirects=False (explicit): a redirect would re-resolve to an
# unvalidated host and reopen the SSRF window we just closed.
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
resp = client.get(request_url, headers=headers, extensions=extensions)
if resp.status_code == 200:
return resp.json() # type: ignore[no-any-return]
Expand Down
6 changes: 5 additions & 1 deletion suddenly/activitypub/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ def verify_signature(request: HttpRequest) -> tuple[bool, str | None]:
if not key_id or not signature_b64:
return False, "Invalid Signature header"

if algorithm != "rsa-sha256":
# Accept both rsa-sha256 and hs2019. With an RSA key, hs2019
# (draft-cavage v12 / RFC 9421) denotes the same RSA-PKCS1v15/SHA-256
# signature — it is Mastodon's default advertised algorithm — so we verify
# both identically below. Rejecting hs2019 would drop legitimate peers.
if algorithm not in ("rsa-sha256", "hs2019"):
return False, f"Unsupported algorithm: {algorithm}"

signed_headers = headers_str.split()
Expand Down
35 changes: 35 additions & 0 deletions tests/activitypub/test_quick_wins.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,41 @@ def test_loopback_returns_none_without_network(self, mocker: Any) -> None:
assert result is None
mock_client.assert_not_called()

@pytest.mark.parametrize(
"ip",
[
"240.0.0.1", # reserved (240.0.0.0/4)
"224.0.0.1", # multicast
"0.0.0.0", # unspecified
"10.0.0.5", # RFC1918 private
"169.254.1.1", # link-local
],
)
def test_reserved_multicast_unspecified_blocked(self, mocker: Any, ip: str) -> None:
"""Reserved, multicast, unspecified and private IPs are blocked pre-network."""
mocker.patch(
"suddenly.activitypub._http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (ip, 443))],
)
mock_client = mocker.patch("httpx.Client")

result = fetch_ap_actor("https://evil.example/actor")

assert result is None
mock_client.assert_not_called()

def test_plain_http_rejected_when_insecure_disabled(
self, mocker: Any, settings: Any
) -> None:
"""Outside dev (AP_ALLOW_INSECURE_HTTP off), http:// is refused up front."""
settings.AP_ALLOW_INSECURE_HTTP = False
mock_client = mocker.patch("httpx.Client")

result = fetch_ap_actor("http://remote.example/actor")

assert result is None
mock_client.assert_not_called()


class TestDateSkewReject:
"""verify_signature must reject a stale Date header (replay protection)."""
Expand Down
56 changes: 55 additions & 1 deletion tests/activitypub/test_signature_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _sign(
date_str: str | None,
digest_value: str | None,
include_digest_header: bool = True,
algorithm: str = "rsa-sha256",
) -> Any:
"""Build a POST request signed over exactly ``signed_headers``.

Expand Down Expand Up @@ -74,7 +75,7 @@ def _sign(

sig_header = (
f'keyId="{KEY_ID}",'
f'algorithm="rsa-sha256",'
f'algorithm="{algorithm}",'
f'headers="{" ".join(signed_headers)}",'
f'signature="{sig}"'
)
Expand Down Expand Up @@ -201,3 +202,56 @@ def test_request_target_not_signed_rejected(
assert reason is not None
assert "Unsigned required headers" in reason
assert "(request-target)" in reason


class TestAcceptedAlgorithms:
"""Both rsa-sha256 and hs2019 map to RSA-PKCS1v15/SHA-256 and verify."""

def test_rsa_sha256_accepted(self, keys: tuple[str, str], mocker: Any) -> None:
private_pem, public_pem = keys
_mock_key(mocker, public_pem)
body = json.dumps({"type": "Follow"})
request = _sign(
private_pem,
body=body,
signed_headers=["(request-target)", "host", "date", "digest"],
date_str=None,
digest_value=_digest_of(body),
algorithm="rsa-sha256",
)
is_valid, result = verify_signature(request)
assert is_valid is True
assert result == KEY_ID

def test_hs2019_accepted(self, keys: tuple[str, str], mocker: Any) -> None:
"""hs2019 over an RSA key is the same signature Mastodon advertises."""
private_pem, public_pem = keys
_mock_key(mocker, public_pem)
body = json.dumps({"type": "Follow"})
request = _sign(
private_pem,
body=body,
signed_headers=["(request-target)", "host", "date", "digest"],
date_str=None,
digest_value=_digest_of(body),
algorithm="hs2019",
)
is_valid, result = verify_signature(request)
assert is_valid is True
assert result == KEY_ID

def test_unknown_algorithm_rejected(self, keys: tuple[str, str], mocker: Any) -> None:
private_pem, public_pem = keys
_mock_key(mocker, public_pem)
body = json.dumps({"type": "Follow"})
request = _sign(
private_pem,
body=body,
signed_headers=["(request-target)", "host", "date", "digest"],
date_str=None,
digest_value=_digest_of(body),
algorithm="hmac-sha256",
)
is_valid, reason = verify_signature(request)
assert is_valid is False
assert reason == "Unsupported algorithm: hmac-sha256"
Loading
Loading