Skip to content

fix(security): block SSRF to internal/metadata hosts in webhook URLs (#656)#706

Merged
frankbria merged 3 commits into
mainfrom
fix/656-webhook-ssrf-host-validation
Jun 21, 2026
Merged

fix(security): block SSRF to internal/metadata hosts in webhook URLs (#656)#706
frankbria merged 3 commits into
mainfrom
fix/656-webhook-ssrf-host-validation

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #656.

Problem

Outbound-webhook URL validation checked the scheme only. An authenticated user could set the webhook to http://169.254.169.254/... (cloud IMDS), http://localhost:8000/..., or any RFC1918 address — and /notifications/test fires it immediately and returns the HTTP status, giving a blind-to-semi-blind SSRF primitive against internal services and cloud metadata.

Fix

  • Host validation (_validate_webhook_url, settings_v2.py): after scheme/netloc checks, resolve the host and reject is_private | is_loopback | is_link_local | is_reserved | is_multicast | is_unspecified — IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:169.254.169.254). IP literals are checked directly; DNS names are resolved and every returned address checked. Both the save (PUT) and test (POST) endpoints flow through this, so both are covered.
  • Opt-out: CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 for self-hosted operators with legitimate internal targets (the issue's "unless explicitly opted in"). Off by default.
  • Redirect bypass closed: send_event now sends allow_redirects=False — a public target can no longer 302 → 169.254.169.254.
  • Event loop: host resolution runs via run_in_threadpool so a slow resolver can't stall request handling.

Acceptance criteria

  • Webhook URLs pointing at private/loopback/link-local/metadata hosts are rejected by both save and test endpoints.
  • Tests cover the metadata IP and localhost cases (plus loopback IP, RFC1918, IPv6 loopback, opt-in, redirect-not-followed, and test-time rejection).

Tests

tests/ui/test_settings_notifications.py (save + test endpoints) and tests/notifications/test_webhook_send_event.py (no-redirect). 41 passing locally; ruff clean.

Known limitation

Resolve-and-check at save/test time, not a request-time pinned connector — so DNS rebinding (host resolves public at save, private at fire) is out of scope. Redirect-blocking mitigates the most practical variant. Upgrade path noted in code: pin the resolved IP through a custom aiohttp connector in send_event.

Review

Cross-family review via codex review; both findings (redirect bypass P1, blocking DNS P2) addressed in this PR.

…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.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@frankbria, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d4e5e4ff-4564-4e37-9e63-cf44757c0af6

📥 Commits

Reviewing files that changed from the base of the PR and between 371489b and 30b1ea9.

📒 Files selected for processing (5)
  • CLAUDE.md
  • codeframe/notifications/webhook.py
  • codeframe/ui/routers/settings_v2.py
  • tests/notifications/test_webhook_send_event.py
  • tests/ui/test_settings_notifications.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/656-webhook-ssrf-host-validation

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code Review: fix(security): SSRF host validation for webhook URLs. This is a well-scoped security fix. The threat model is clearly stated, the defense-in-depth approach (host validation + redirect blocking) is correct, and limitations are honestly documented. WHAT IS SOLID: (1) Defense in depth: host validation at save/test time AND allow_redirects=False at fire time — one without the other leaves a gap. (2) IPv4-mapped IPv6 handling: ip.ipv4_mapped unwrap for ::ffff:169.254.169.254 is easy to forget and is correctly included. (3) Blocking DNS off the event loop via run_in_threadpool in both PUT and POST handlers is correct. (4) CODEFRAME_ALLOW_PRIVATE_WEBHOOKS read at request time (no restart needed) is the right ergonomic for self-hosted operators. ISSUES TO ADDRESS: (1) Missing test for IPv4-mapped IPv6 metadata IP. The code explicitly handles ::ffff:169.254.169.254 but there is no test for it. This is the trickiest case and the one most likely to be silently broken by a refactor. Please add test_rejects_ipv4_mapped_ipv6_metadata that PUTs http://[::ffff:169.254.169.254]/ and expects a 400. (2) test_rejects_localhost makes a real DNS lookup. localhost resolution is environment-dependent — some CI images return ::1, others 127.0.0.1, and hardened setups may not resolve it at all (which would allow it through under the current unresolvable-means-not-reachable policy). The IP literal tests already cover loopback. Either remove the localhost test or mock socket.getaddrinfo. (3) The 400 error body advertises CODEFRAME_ALLOW_PRIVATE_WEBHOOKS=1 as the bypass. Fine for self-hosted, but operators who expose this API externally are advertising the escape hatch. Worth a conscious decision — the hint may belong in logs or docs. MINOR / NON-BLOCKING: (4) ponytail: in the docstring is non-standard — suggest Upgrade path: or Known limitation: to match codebase conventions. (5) test_accepts_http now uses example.com which incurs a live DNS lookup in tests — a routable IP literal like 203.0.113.1 (TEST-NET-3) avoids CI network-restriction risk. (6) test_allows_private_host_when_opted_in only covers 127.0.0.1 — a second variant with RFC1918 or link-local would confirm the bypass is general. SUMMARY: Approve with item 1 added. Items 2 and 5 are latent CI flakiness risks. Items 3, 4, 6 are low-priority.

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:'.
@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Follow-up review (continuing from my prior comment)

All three issues I flagged as mandatory or CI-flakiness risks have been addressed in this revision.

Prior issues resolved:

  1. Missing test for IPv4-mapped IPv6 metadata IP (was mandatory) — added as test_rejects_ipv4_mapped_ipv6_metadata
  2. test_rejects_localhost made a real DNS lookup (CI flakiness) — socket.getaddrinfo now mocked; test is hermetic
  3. Error body advertises CODEFRAME_ALLOW_PRIVATE_WEBHOOKS — still present; acceptable conscious choice for self-hosted deployments
  4. Docstring headings — now uses Known limitation / Upgrade path conventions
  5. test_accepts_http used example.com (live DNS) — replaced with 8.8.8.8 IP literal
  6. test_allows_private_host_when_opted_in only covered loopback — now covers both 127.0.0.1 and 10.0.0.5

One small non-blocking observation:

8.8.8.8 is routable and correctly passes the is_private check, so the test works. If you want to make the intent explicit for future readers, a brief comment noting that TEST-NET-3 (203.0.113.x) would be the semantic choice but ipaddress marks it as private, so a real public IP is needed, would help — but the code is correct as-is.

Overall:

The implementation is sound: defense-in-depth (host validation at save and test time, plus redirect blocking at fire time), IPv4-mapped IPv6 unwrapping, blocking DNS lookup moved off the event loop, documented DNS-rebinding limitation, operator escape hatch. The test suite is now hermetic.

Approve. All mandatory findings from the prior review are resolved. Ready to merge.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Third-pass review (continuing from prior comments)

The diff is unchanged from what I reviewed in my second pass — all mandatory items resolved, no new changes since approval.

Status: No new findings. Prior approval stands.

To summarize the review history for this PR:

  • Pass 1: Flagged missing IPv4-mapped IPv6 test, live DNS in localhost test (CI flakiness), and example.com in test_accepts_http.
  • Pass 2: All three resolved — test_rejects_ipv4_mapped_ipv6_metadata added, socket.getaddrinfo mocked in localhost test, 8.8.8.8 literal replaces example.com.
  • Pass 3 (now): No new code changes. No new findings.

The implementation is complete and sound. Ready to merge.

@frankbria frankbria merged commit 4ac42b6 into main Jun 21, 2026
12 checks passed
@frankbria frankbria deleted the fix/656-webhook-ssrf-host-validation branch June 21, 2026 02:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P7.0.2] Webhook URL SSRF: block internal/metadata/private hosts (M2)

1 participant