Skip to content

fix(security): prevent SSRF via webhook URL validation#454

Merged
AbirAbbas merged 3 commits intomainfrom
fix/ssrf-webhook-url-validation
Apr 14, 2026
Merged

fix(security): prevent SSRF via webhook URL validation#454
AbirAbbas merged 3 commits intomainfrom
fix/ssrf-webhook-url-validation

Conversation

@AbirAbbas
Copy link
Copy Markdown
Contributor

Summary

Fixes #418 — SSRF via unvalidated webhook URLs allows internal network access and cloud credential exfiltration.

  • Registration-time validation: ValidateWebhookURL rejects URLs targeting private/loopback/link-local/RFC-1918 IPs and localhost before they are stored
  • Transport-level enforcement: NewSSRFSafeClient resolves DNS and rejects private IPs at dial time, preventing DNS rebinding attacks
  • Applied to both execution webhooks (normalizeWebhookRequest) and observability webhooks (SetWebhookHandler)

Blocked IP ranges

Range Purpose
127.0.0.0/8, ::1 Loopback
169.254.0.0/16, fe80::/10 Link-local (cloud metadata)
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 RFC-1918 private
fc00::/7 IPv6 ULA
0.0.0.0, :: Unspecified
localhost, *.localhost Localhost hostnames

Test plan

  • TestIsPrivateIP — all private/public IP classifications
  • TestIsPrivateHost — localhost hostname detection
  • TestValidateWebhookURL — URL-level SSRF rejection (12 cases)
  • TestNewSSRFSafeClient_BlocksPrivateIPs — transport blocks loopback httptest server
  • TestNormalizeWebhookRequest/ssrf_* — 8 SSRF cases added to existing handler tests
  • Manually tested against running control plane (all private IPs rejected, public URLs accepted)
  • Existing observability forwarder + webhook dispatcher tests pass (using injectable HTTPClient for test servers)

🤖 Generated with Claude Code

Webhook URLs (execution webhooks and observability webhooks) were only
validated for HTTP/HTTPS scheme, allowing users to target internal
services, cloud metadata endpoints (169.254.169.254), and RFC-1918
private networks through the server acting as an open proxy.

This adds two layers of defense:
- Registration-time validation: ValidateWebhookURL rejects URLs
  pointing to private/loopback/link-local IPs before they are stored.
- Transport-level enforcement: NewSSRFSafeClient uses a custom
  DialContext that resolves DNS and rejects private IPs before the
  TCP connection is established, preventing DNS rebinding attacks.

Closes #418

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@AbirAbbas AbirAbbas requested a review from a team as a code owner April 14, 2026 13:16
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 14, 2026

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 86%, aggregate ≥ 88%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.20% 87.30% ↓ -0.10 pp 🟡
sdk-go 90.70% 90.70% → +0.00 pp 🟢
sdk-python 93.63% 93.63% ↑ +0.00 pp 🟢
sdk-typescript 92.56% 92.56% → +0.00 pp 🟢
web-ui 90.02% 90.01% ↑ +0.01 pp 🟢
aggregate 88.98% 89.01% ↓ -0.03 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 14, 2026

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 175 86.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

AbirAbbas and others added 2 commits April 14, 2026 10:47
The strict SSRF filter rejected legitimate RFC-1918 callbacks inside
Docker/K8s clusters, breaking the functional test that webhooks back
to the test-runner container at an internal bridge IP.

Add an allowlist (hosts, wildcards, CIDRs) that bypasses the private-IP
check for explicitly trusted targets, mirroring the existing
`serverless_discovery_allowed_hosts` pattern:

- `AGENTFIELD_WEBHOOK_ALLOWED_HOSTS` env var / `webhook_allowed_hosts`
  YAML field feeds services.SetWebhookAllowedHosts at server startup.
- Both `ValidateWebhookURL` and `NewSSRFSafeClient`'s DialContext honor
  the allowlist before applying private-IP rejection.
- Functional test docker-compose files set the env to "test-runner" so
  the existing webhook contract test passes without weakening the gate.

Added tests for allowlist parsing, hostname/wildcard/CIDR matching,
bypass behavior in both validators, dialer error paths, and an
end-to-end test that loopback traffic flows when 127.0.0.0/8 is
allowlisted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@AbirAbbas AbirAbbas merged commit 785043f into main Apr 14, 2026
24 checks passed
@AbirAbbas AbirAbbas deleted the fix/ssrf-webhook-url-validation branch April 14, 2026 15:08
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.

SSRF via unvalidated webhook URLs allows internal network access and cloud credential exfiltration

1 participant