fix(security): SSRF guard at webhook delivery time + shared predicate (LC-152) #186

Merged
nrupard merged 1 commit from feat/lc-152-webhook-delivery-ssrf into main 2026-05-22 20:33:10 +02:00
Owner

What

Closes the webhook delivery-time SSRF gap from the LC-148 audit (findings S6 + S7).

Problem

Outgoing-webhook and slash-command webhook URLs were IP-validated only at creation, as a string parse: it rejects IP literals + localhost but lets any hostname through ("admin-trusted; full DNS checks out of scope for v1"). At delivery time the host was fetched with no re-validation, so a URL whose hostname resolves to an internal / cloud-metadata address (169.254.169.254) was fetched on every matching event with HMAC-signed payloads. The slash IPv6 guard also missed ULA fc00::/7, and neither client disabled redirect-following (a 3xx to an internal host bypassed the check).

Fix

  • Shared predicate. Extracted the unfurl SSRF guard (is_globally_routable + host_resolves_public) into a new crate::ssrf module so the unfurler and both webhook deliverers enforce one policy. The unit tests moved with it; unfurl keeps its per-hop redirect re-validation, now calling the shared helper.
  • Outgoing delivery (run_delivery_tick): resolves t.url and rejects non-http(s) schemes + any host resolving to a non-public address before connecting; a blocked delivery is marked failed terminally (not retried - it won't become public later).
  • Slash delivery (run_webhook): resolves the host and rejects the same; webhook_url_ok's IP-literal branch now defers to the shared predicate, so it gains fc00::/7 + CGNAT + benchmark coverage.
  • Both webhook reqwest clients set redirect::Policy::none().

Test

  • SSRF predicate unit tests (in ssrf.rs): v4/v6 accept/reject matrix + host_resolves_public rejecting private/loopback/metadata IP-literal URLs (offline-safe).
  • New outgoing_webhooks::delivery_blocks_non_public_url_and_is_terminal: a webhook with a loopback URL is blocked at delivery and not retried.
  • run_delivery_tick gains a #[doc(hidden)] run_delivery_tick_unchecked test seam so the existing delivery-path tests can target their loopback receiver (which the guard, correctly for production, now rejects).

just check / just test / just test-saas green (the routes_uploads upload-pipeline flake under concurrent-binary load is pre-existing and passes in isolation; this PR touches no upload code).

Part of the LC-159 post-audit story.

🤖 Generated with Claude Code

## What Closes the webhook delivery-time SSRF gap from the LC-148 audit (findings S6 + S7). ## Problem Outgoing-webhook and slash-command webhook URLs were IP-validated only at **creation**, as a string parse: it rejects IP literals + `localhost` but lets any hostname through ("admin-trusted; full DNS checks out of scope for v1"). At **delivery** time the host was fetched with no re-validation, so a URL whose hostname resolves to an internal / cloud-metadata address (`169.254.169.254`) was fetched on every matching event with HMAC-signed payloads. The slash IPv6 guard also missed ULA `fc00::/7`, and neither client disabled redirect-following (a 3xx to an internal host bypassed the check). ## Fix - **Shared predicate.** Extracted the unfurl SSRF guard (`is_globally_routable` + `host_resolves_public`) into a new `crate::ssrf` module so the unfurler and both webhook deliverers enforce one policy. The unit tests moved with it; `unfurl` keeps its per-hop redirect re-validation, now calling the shared helper. - **Outgoing delivery** (`run_delivery_tick`): resolves `t.url` and rejects non-http(s) schemes + any host resolving to a non-public address before connecting; a blocked delivery is marked **failed terminally** (not retried - it won't become public later). - **Slash delivery** (`run_webhook`): resolves the host and rejects the same; `webhook_url_ok`'s IP-literal branch now defers to the shared predicate, so it gains `fc00::/7` + CGNAT + benchmark coverage. - Both webhook reqwest clients set `redirect::Policy::none()`. ## Test - SSRF predicate unit tests (in `ssrf.rs`): v4/v6 accept/reject matrix + `host_resolves_public` rejecting private/loopback/metadata IP-literal URLs (offline-safe). - New `outgoing_webhooks::delivery_blocks_non_public_url_and_is_terminal`: a webhook with a loopback URL is blocked at delivery and not retried. - `run_delivery_tick` gains a `#[doc(hidden)] run_delivery_tick_unchecked` test seam so the existing delivery-path tests can target their loopback receiver (which the guard, correctly for production, now rejects). `just check` / `just test` / `just test-saas` green (the `routes_uploads` upload-pipeline flake under concurrent-binary load is pre-existing and passes in isolation; this PR touches no upload code). Part of the LC-159 post-audit story. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(security): SSRF guard at webhook delivery time + shared predicate (LC-152)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 1m20s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 3s
Create release / Create release from merged PR (pull_request) Has been skipped
46aa6dc3db
Outgoing and slash-command webhook URLs were IP-validated only at creation, as a string parse that rejects IP literals + localhost but lets any hostname through ("admin-trusted; full DNS checks out of scope"). At delivery time the host was fetched with no re-validation, so a URL whose hostname resolves to an internal or cloud-metadata address (169.254.169.254) was fetched on every matching event with signed payloads (audit S6). The slash IPv6 check also missed ULA fc00::/7, and neither client disabled redirect-following, so a 3xx to an internal host bypassed the check (S7).

Extracts the unfurl SSRF predicate (is_globally_routable + host_resolves_public) into a shared crate::ssrf module so the unfurler and both webhook deliverers enforce one policy. Adds, at delivery time:
- outgoing::run_delivery_tick resolves t.url and rejects non-http(s) schemes + any host resolving to a non-public address before connecting; a blocked delivery is marked failed terminally (not retried).
- slash::run_webhook resolves the host and rejects the same; webhook_url_ok's IP-literal branch now defers to the shared predicate (so it gains fc00::/7 + CGNAT + benchmark coverage).
- Both webhook reqwest clients set redirect::Policy::none() so a redirect cannot escape the resolved-host check.

unfurl keeps its per-hop redirect re-validation, now calling the shared host_resolves_public. The SSRF predicate unit tests moved to ssrf.rs.

run_delivery_tick gains a #[doc(hidden)] run_delivery_tick_unchecked test seam so the existing delivery-path tests can target their loopback receiver (which the guard, correctly for production, now rejects). New test: a webhook with a loopback URL is blocked at delivery and not retried.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-152-webhook-delivery-ssrf 2026-05-22 20:33:10 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!186
No description provided.