Skip to content

Commit 4ac42b6

Browse files
authored
fix(security): block SSRF to internal/metadata hosts in webhook URLs (#656) (#706)
* fix(security): block SSRF to internal/metadata hosts in webhook URLs (#656) Webhook URL validation checked the scheme only. An authenticated user could point the outbound webhook at http://169.254.169.254 (cloud IMDS), http://localhost, or any RFC1918 address; the /notifications/test endpoint fires it immediately and returns the HTTP status — a blind-to-semi-blind SSRF primitive. - _validate_webhook_url now resolves the host and rejects loopback, link-local, RFC1918/private, reserved, multicast and unspecified addresses (IPv4, IPv6, and IPv4-mapped IPv6). Covers both save (PUT) and test (POST). - Opt-out via CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 for self-hosted operators with legitimate internal webhook targets. - send_event no longer follows redirects (allow_redirects=False) — closes the public-target-302-to-metadata bypass. - Host resolution runs off the event loop (run_in_threadpool) so a slow resolver can't stall request handling. Known limitation: resolve-and-check at save/test time, not a request-time pinned connector, so DNS rebinding (public at save, private at fire) is out of scope; redirect-blocking mitigates the most practical variant. * test(security): harden #656 SSRF tests; reword docstring Address advisory review feedback: - Add IPv4-mapped IPv6 metadata test (::ffff:169.254.169.254) — the trickiest case, previously only exercised in the demo. - Make the localhost test hermetic by mocking socket.getaddrinfo instead of depending on CI-image resolution of 'localhost'. - test_accepts_http uses a global IP literal (8.8.8.8) — no live DNS; TEST-NET ranges count as private under ipaddress so they can't serve as 'public'. - Opt-in test also covers an RFC1918 host, not just loopback. - Reword 'ponytail:' docstring note to 'Known limitation:'. * docs: document CODEFRAME_ALLOW_PRIVATE_WEBHOOKS env var (#656)
1 parent 371489b commit 4ac42b6

5 files changed

Lines changed: 194 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ REDIS_URL=redis://localhost:6379
304304

305305
CODEFRAME_API_KEY_SECRET=<secret> # API key hashing
306306

307+
# Outbound webhook SSRF guard (#656) — default OFF (block)
308+
CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 # Allow webhook URLs whose host resolves to
309+
# private/loopback/link-local/metadata IPs.
310+
# Off by default: such hosts are rejected by
311+
# the notifications save + test endpoints to
312+
# prevent SSRF (e.g. 169.254.169.254 IMDS).
313+
# Set for self-hosted ops with legit internal
314+
# webhook targets (localhost, RFC1918).
315+
307316
# Telemetry (default: off — must be explicitly opted in)
308317
CODEFRAME_TELEMETRY=on|off # Force telemetry on or off; overrides ~/.codeframe/telemetry.json
309318
CODEFRAME_TELEMETRY_ENDPOINT=<url> # Override collector URL (default: https://telemetry.codeframe.dev/v1/events)

codeframe/notifications/webhook.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ async def send_event(
260260
target_url,
261261
json=payload,
262262
timeout=aiohttp.ClientTimeout(total=self.timeout),
263+
# SSRF (#656): the URL host is validated before saving, but
264+
# a public target could 302 → 169.254.169.254 / localhost.
265+
# Webhook delivery never needs to follow redirects, so don't.
266+
allow_redirects=False,
263267
) as response:
264268
ok = 200 <= response.status < 300
265269
if not ok:

codeframe/ui/routers/settings_v2.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
config is per-workspace and persisted under .codeframe/notifications_config.json.
1717
"""
1818

19+
import ipaddress
1920
import logging
21+
import os
22+
import socket
2023

2124
from typing import Optional, cast
2225

@@ -447,13 +450,85 @@ async def get_notification_settings(
447450
_ALLOWED_WEBHOOK_SCHEMES = frozenset({"http", "https"})
448451

449452

453+
def _allow_private_webhook_hosts() -> bool:
454+
"""Opt-out for the SSRF host check.
455+
456+
Off by default (block private/internal targets). Self-hosted operators who
457+
legitimately point webhooks at ``localhost`` or an internal service set
458+
``CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1`` — read at request time so it can be
459+
toggled without a restart.
460+
"""
461+
return os.getenv("CODEFRAME_ALLOW_PRIVATE_WEBHOOKS", "").strip().lower() in {
462+
"1",
463+
"true",
464+
"yes",
465+
"on",
466+
}
467+
468+
469+
def _assert_webhook_host_is_public(hostname: str) -> None:
470+
"""Reject webhook hosts that resolve to internal/metadata/private IPs.
471+
472+
Blocks the SSRF vector where an authenticated user points the webhook at
473+
``169.254.169.254`` (cloud IMDS), ``127.0.0.1``, or an RFC1918 address —
474+
the ``/notifications/test`` endpoint fires the URL immediately and returns
475+
the HTTP status, a blind-to-semi-blind SSRF primitive. IP literals are
476+
checked directly; DNS names are resolved and every returned address is
477+
checked. A host that cannot be resolved is allowed through — it isn't
478+
reachable right now, and we don't want to block saving a not-yet-live URL.
479+
480+
Known limitation: resolve-and-check, not a request-time pinned connector,
481+
so DNS rebinding (public at save, private at fire) is out of scope.
482+
Upgrade path: pin the resolved IP through a custom aiohttp connector in
483+
``send_event``.
484+
"""
485+
if _allow_private_webhook_hosts():
486+
return
487+
488+
try:
489+
candidates = [ipaddress.ip_address(hostname)]
490+
except ValueError:
491+
try:
492+
candidates = [
493+
ipaddress.ip_address(info[4][0])
494+
for info in socket.getaddrinfo(
495+
hostname, None, proto=socket.IPPROTO_TCP
496+
)
497+
]
498+
except socket.gaierror:
499+
return # unresolvable now → not reachable now; don't block the save
500+
501+
for ip in candidates:
502+
# ::ffff:169.254.169.254 — judge by the embedded IPv4 address.
503+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
504+
ip = ip.ipv4_mapped
505+
if (
506+
ip.is_private
507+
or ip.is_loopback
508+
or ip.is_link_local
509+
or ip.is_reserved
510+
or ip.is_multicast
511+
or ip.is_unspecified
512+
):
513+
raise HTTPException(
514+
status_code=400,
515+
detail=api_error(
516+
f"Webhook host resolves to a private/internal address "
517+
f"({ip}); refusing to avoid SSRF. Set "
518+
f"CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 to allow internal targets.",
519+
ErrorCodes.VALIDATION_ERROR,
520+
),
521+
)
522+
523+
450524
def _validate_webhook_url(url: Optional[str]) -> Optional[str]:
451525
"""Normalize and validate a user-supplied webhook URL.
452526
453527
Returns the trimmed URL on success, or ``None`` if empty. Raises
454528
``HTTPException(400)`` for non-``http(s)`` schemes — without this guard,
455529
a user could enter ``file:///...`` or ``ftp://...`` and aiohttp would
456-
happily attempt the request (SSRF on local resources).
530+
happily attempt the request (SSRF on local resources) — and for hosts that
531+
resolve to private/internal/metadata IPs (see ``_assert_webhook_host_is_public``).
457532
"""
458533
from urllib.parse import urlparse
459534

@@ -477,6 +552,8 @@ def _validate_webhook_url(url: Optional[str]) -> Optional[str]:
477552
ErrorCodes.VALIDATION_ERROR,
478553
),
479554
)
555+
if parsed.hostname:
556+
_assert_webhook_host_is_public(parsed.hostname)
480557
return trimmed
481558

482559

@@ -494,7 +571,8 @@ async def update_notification_settings(
494571
the saved URL. The URL is validated for ``http(s)`` scheme to avoid
495572
``file://`` / ``ftp://`` SSRF on local resources.
496573
"""
497-
url = _validate_webhook_url(body.webhook_url)
574+
# Off the event loop: _validate_webhook_url may do a blocking DNS lookup.
575+
url = await run_in_threadpool(_validate_webhook_url, body.webhook_url)
498576
try:
499577
save_notifications_config(
500578
workspace,
@@ -553,7 +631,8 @@ async def test_notification_webhook(
553631
# We discard the return value: ``_validate_webhook_url`` raises
554632
# ``HTTPException(400)`` on a bad scheme/host, which is the side-effect
555633
# we want here. The trimmed URL it returns is already in ``url``.
556-
_validate_webhook_url(url)
634+
# Off the event loop — it may do a blocking DNS lookup.
635+
await run_in_threadpool(_validate_webhook_url, url)
557636

558637
svc = WebhookNotificationService(webhook_url=url, timeout=5)
559638
result = await svc.send_event(format_test_payload())

tests/notifications/test_webhook_send_event.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ async def test_send_event_returns_not_ok_on_5xx():
5555
assert result.status_code == 500
5656

5757

58+
@pytest.mark.asyncio
59+
async def test_send_event_does_not_follow_redirects():
60+
"""SSRF (#656): redirects must not be followed — a public target could
61+
302 → 169.254.169.254 / localhost and bypass the save-time host check."""
62+
svc = WebhookNotificationService(webhook_url="https://example.com/hook", timeout=5)
63+
session = _mock_post(200)
64+
with patch("aiohttp.ClientSession", return_value=session):
65+
await svc.send_event(format_test_payload())
66+
assert session.post.call_args.kwargs["allow_redirects"] is False
67+
68+
5869
@pytest.mark.asyncio
5970
async def test_send_event_no_url_returns_error():
6071
svc = WebhookNotificationService(webhook_url=None, timeout=5)

tests/ui/test_settings_notifications.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,88 @@ def test_accepts_https(self, client):
136136
assert r.status_code == 200
137137

138138
def test_accepts_http(self, client):
139-
"""Plain http is allowed for local testing / internal endpoints."""
139+
"""Plain http is allowed for public endpoints. Uses a global IP literal
140+
(no DNS lookup) so the test is hermetic — TEST-NET ranges count as
141+
private under ipaddress, so a real routable global IP is used."""
140142
r = client.put(
141143
"/api/v2/settings/notifications",
142-
json={"webhook_url": "http://localhost:9876/h", "webhook_enabled": True},
144+
json={"webhook_url": "http://8.8.8.8:9876/h", "webhook_enabled": True},
143145
)
144146
assert r.status_code == 200
145147

148+
# --- SSRF host validation (#656) ---------------------------------------
149+
150+
def test_rejects_metadata_ip(self, client):
151+
"""Cloud IMDS (169.254.169.254) must be rejected — the core SSRF target."""
152+
r = client.put(
153+
"/api/v2/settings/notifications",
154+
json={
155+
"webhook_url": "http://169.254.169.254/latest/meta-data/",
156+
"webhook_enabled": True,
157+
},
158+
)
159+
assert r.status_code == 400
160+
161+
def test_rejects_localhost(self, client):
162+
"""A DNS name resolving to loopback → blocked. getaddrinfo is mocked so
163+
the test doesn't depend on how the CI image resolves ``localhost``
164+
(some return ::1, some 127.0.0.1, hardened ones not at all)."""
165+
with patch(
166+
"codeframe.ui.routers.settings_v2.socket.getaddrinfo",
167+
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
168+
):
169+
r = client.put(
170+
"/api/v2/settings/notifications",
171+
json={
172+
"webhook_url": "http://localhost:9876/h",
173+
"webhook_enabled": True,
174+
},
175+
)
176+
assert r.status_code == 400
177+
178+
def test_rejects_ipv4_mapped_ipv6_metadata(self, client):
179+
"""::ffff:169.254.169.254 must be unwrapped to its IPv4 and rejected —
180+
the trickiest case, easy to silently break in a refactor."""
181+
r = client.put(
182+
"/api/v2/settings/notifications",
183+
json={
184+
"webhook_url": "http://[::ffff:169.254.169.254]/h",
185+
"webhook_enabled": True,
186+
},
187+
)
188+
assert r.status_code == 400
189+
190+
def test_rejects_loopback_ip(self, client):
191+
r = client.put(
192+
"/api/v2/settings/notifications",
193+
json={"webhook_url": "http://127.0.0.1:8000/h", "webhook_enabled": True},
194+
)
195+
assert r.status_code == 400
196+
197+
def test_rejects_rfc1918_ip(self, client):
198+
r = client.put(
199+
"/api/v2/settings/notifications",
200+
json={"webhook_url": "http://10.0.0.5/h", "webhook_enabled": True},
201+
)
202+
assert r.status_code == 400
203+
204+
def test_rejects_ipv6_loopback(self, client):
205+
r = client.put(
206+
"/api/v2/settings/notifications",
207+
json={"webhook_url": "http://[::1]:8000/h", "webhook_enabled": True},
208+
)
209+
assert r.status_code == 400
210+
211+
def test_allows_private_host_when_opted_in(self, client, monkeypatch):
212+
"""CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 is the documented escape hatch."""
213+
monkeypatch.setenv("CODEFRAME_ALLOW_PRIVATE_WEBHOOKS", "1")
214+
for host in ("http://127.0.0.1:8000/h", "http://10.0.0.5/h"):
215+
r = client.put(
216+
"/api/v2/settings/notifications",
217+
json={"webhook_url": host, "webhook_enabled": True},
218+
)
219+
assert r.status_code == 200, host
220+
146221

147222
class TestNotificationWebhookTest:
148223
def test_returns_400_when_no_url_configured(self, client):
@@ -218,6 +293,17 @@ def test_rejects_unsafe_stored_url_at_test_time(self, client, workspace):
218293
r = client.post("/api/v2/settings/notifications/test")
219294
assert r.status_code == 400
220295

296+
def test_rejects_metadata_ip_at_test_time(self, client, workspace):
297+
"""SSRF (#656): a hand-edited config pointing at IMDS must be refused
298+
by /test, not fired."""
299+
path = workspace.state_dir / "notifications_config.json"
300+
path.write_text(
301+
'{"webhook_url": "http://169.254.169.254/latest/meta-data/", '
302+
'"webhook_enabled": true}'
303+
)
304+
r = client.post("/api/v2/settings/notifications/test")
305+
assert r.status_code == 400
306+
221307
def test_test_works_even_when_enabled_flag_is_false(self, client):
222308
"""The Test button should still work when the user is verifying
223309
a URL before turning notifications on."""

0 commit comments

Comments
 (0)