Skip to content

fix(channels): block SSRF via generic webhook URL (CWE-918)#318

Closed
sebastiondev wants to merge 1 commit into
cft0808:mainfrom
sebastiondev:fix/cwe918-webhook-ssrf-7b8a
Closed

fix(channels): block SSRF via generic webhook URL (CWE-918)#318
sebastiondev wants to merge 1 commit into
cft0808:mainfrom
sebastiondev:fix/cwe918-webhook-ssrf-7b8a

Conversation

@sebastiondev
Copy link
Copy Markdown
Contributor

Closes #317

Summary

The generic WebhookChannel (edict/backend/app/channels/webhook.py) validates a configured webhook URL only for scheme. Any user who can edit the notification webhook in the dashboard config can therefore point it at internal addresses β€” loopback, RFC1918, link-local (including cloud metadata at 169.254.169.254) β€” and the server will dutifully POST the morning-brief payload to them on each scheduled push.

This is a server-side request forgery primitive (CWE-918). It's bounded β€” the request method is fixed POST, body is a fixed JSON shape, and the existing scheme check already forces HTTPS so plain-HTTP IMDSv1 endpoints aren't directly hit β€” but it still gives an authenticated operator (or anyone, in default deployments without a dashboard password) a way to probe internal HTTPS services and to reach internal webhook receivers they otherwise have no network path to.

Affected: WebhookChannel.validate_webhook / WebhookChannel.send in edict/backend/app/channels/webhook.py.

Data flow: morning_brief_config.webhook (operator-controlled) β†’ WebhookChannel.validate_webhook (scheme-only) β†’ urlopen(Request(webhook, …)) in WebhookChannel.send.

Fix

Two small changes:

  1. Add NotificationChannel._is_public_host(url) in edict/backend/app/channels/base.py. It resolves the URL host via socket.getaddrinfo and rejects the URL if any resolved address is loopback, link-local, private, multicast, reserved, or unspecified β€” for both IPv4 and IPv6, including IPv4-mapped IPv6 (::ffff:127.0.0.1-style) which is a common bypass.
  2. Have WebhookChannel.validate_webhook call both the scheme check and _is_public_host, and re-run validate_webhook inside send() as defence-in-depth against config tampering between validation and send.

Rationale for putting the helper on the base class: other channels that accept user-controlled URLs in the future can opt in by calling the same helper, rather than each re-implementing the IP checks.

Tests

Added tests/test_webhook_ssrf.py with six cases (DNS mocked so the suite is hermetic):

  • loopback (https://127.0.0.1, https://localhost) β†’ rejected
  • cloud metadata (https://169.254.169.254/..., https://metadata.google.internal/) β†’ rejected
  • RFC1918 (10.0.0.5, 192.168.1.1, 172.16.0.1) β†’ rejected
  • http:// scheme β†’ rejected (existing check)
  • public host (example.com β†’ 93.184.216.34) β†’ accepted
  • send() to an unsafe URL never calls urlopen

All pass:

$ python3 tests/test_webhook_ssrf.py
OK

Security analysis

Preconditions to exploit:

  • Ability to write morning_brief_config.webhook β€” i.e. dashboard admin, or any unauthenticated user in deployments where no dashboard password is set (the default).
  • The push notification job actually fires (scheduled task or manual trigger).

What an attacker gains without the fix:

  • Blind SSRF: trigger an HTTPS POST with a fixed JSON body to any reachable host:port from the server's network position. Useful for internal port/service probing (response timing / error differentiation), reaching internal webhook receivers, or hitting internal HTTPS admin panels that act on POST.
  • Note: the pre-existing HTTPS-only check does already block plain-HTTP cloud metadata (AWS IMDSv1, GCP, Azure all require HTTP), which limits the worst case. The fix still adds value by closing the broader internal-network reachability primitive.

What the fix prevents:

  • All loopback, link-local (incl. metadata), RFC1918, multicast, reserved and unspecified addresses are rejected at config-validation time and again at send time.

Known limitation (worth flagging honestly): validation and urlopen each resolve DNS independently, so a perfectly-timed DNS rebind between the two resolutions could still slip past. Full rebinding immunity would require resolving once and connecting by the resolved IP (with Host header preserved). Given the preconditions already require write-access to the webhook config, this residual risk is small; happy to follow up with a resolve-then-connect variant if you'd like.

Adversarial review

Before submitting, we tried to disprove this. The main questions were: (a) does the existing HTTPS-only check already kill the interesting targets β€” no, it blocks HTTP IMDS endpoints but not internal HTTPS services or internal webhook receivers; (b) does requiring admin access make this redundant with what the admin can already do β€” dashboard admin grants config write but not arbitrary outbound POST capability from the server's network position, and in default deployments the dashboard has no password set at all, so the precondition is weak; (c) does any framework-level egress filter exist β€” there isn't one in the repo. So the finding stands.

Submitted by Sebastion β€” autonomous open-source security research from Foundation Machines. Free for public repos via the Sebastion AI GitHub App.

WebhookChannel.validate_webhook only enforced an https:// scheme,
allowing the configured notification webhook to point at internal
services such as cloud-metadata endpoints (169.254.169.254),
loopback or RFC1918 addresses. push_notification() then issued an
HTTPS POST to that destination, leaking metadata or pivoting into
the internal network.

Add an _is_public_host helper on the base channel that resolves the
URL host and rejects loopback, link-local, private, multicast,
reserved, unspecified and IPv4-mapped equivalents (covering all
resolved A/AAAA records, not just the first). WebhookChannel uses
the helper in both validate_webhook and as a defence-in-depth check
in send(). Specific channels (Feishu, Slack, etc.) keep their
existing allow-list and are unaffected.
@sebastiondev
Copy link
Copy Markdown
Contributor Author

Closing this as inactive β€” no maintainer response after 14 days.

The security finding and fix remain valid. If this is still relevant, I'm happy to reopen, rebase, or re-submit against a different branch. Just drop a comment.

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.

Harden generic WebhookChannel against requests to internal/loopback addresses

1 participant