Skip to content

fix(security): add outbound URL guard for stream webhooks - #1864

Open
devogechukwu wants to merge 1 commit into
Streampay-Org:mainfrom
devogechukwu:fix/webhook-ssrf-guard
Open

devogechukwu wants to merge 1 commit into
Streampay-Org:mainfrom
devogechukwu:fix/webhook-ssrf-guard

Conversation

@devogechukwu

Copy link
Copy Markdown

Closes #1727

Summary of Changes

Adds a shared outbound URL guard and applies it to POST /api/streams/[id]/webhooks/test and PATCH /api/v2/streams/[id] to block SSRF and port scanning attacks.

  1. Shared Outbound URL Guard (app/lib/outbound-url.ts):

    • Enforces https: in production (http: is permitted only in local/dev environments when not resolving to private or restricted addresses).
    • Rejects URLs containing userinfo/credentials (user:pass@).
    • Restricts ports to default or allowlisted ports (443, 8443, and 80 in development).
    • Full IP range validation blocking private, loopback, link-local, multicast, CGNAT, documentation, and cloud metadata addresses.
    • Resolves DNS hostnames (dns.promises.lookup with { all: true }) and verifies that every resolved IP is public; returns the pinned IP address for the delivery client to mitigate DNS rebinding.
  2. Blocked IP Ranges (with RFC References):

    • 0.0.0.0/8 — RFC 1122 (Current network)
    • 10.0.0.0/8 — RFC 1918 (Private address space)
    • 100.64.0.0/10 — RFC 6598 (Shared Address Space / CGNAT)
    • 127.0.0.0/8 — RFC 1122 (Loopback)
    • 169.254.0.0/16 — RFC 3927 (Link-Local / Cloud Metadata)
    • 172.16.0.0/12 — RFC 1918 (Private address space)
    • 192.0.0.0/24 — RFC 6890 (IETF Protocol Assignments)
    • 192.0.2.0/24 — RFC 5737 (Documentation TEST-NET-1)
    • 192.88.99.0/24 — RFC 7526 (6to4 Relay Anycast)
    • 192.168.0.0/16 — RFC 1918 (Private address space)
    • 198.18.0.0/15 — RFC 2544 (Benchmarking)
    • 198.51.100.0/24 — RFC 5737 (Documentation TEST-NET-2)
    • 203.0.113.0/24 — RFC 5737 (Documentation TEST-NET-3)
    • 224.0.0.0/4 — RFC 5771 (Multicast)
    • 240.0.0.0/4 — RFC 1112 (Reserved / Class E & Broadcast)
    • 255.255.255.255 — Broadcast
    • IPv6: ::/128, ::1/128 (RFC 4291 Loopback), fe80::/10 (RFC 4291 Link-Local), fc00::/7 (RFC 4193 ULA), ff00::/8 (RFC 4291 Multicast), ::ffff:0:0/96 (IPv4-mapped IPv6), and 64:ff9b::/96 (RFC 6052 Translation)
  3. Route Protections (POST /api/streams/[id]/webhooks/test):

    • Rejects unsafe URLs with 400 UNSAFE_ENDPOINT_URL prior to dispatch.
    • Enforces authentication: caller must be stream sender, owning organization member, or admin (401 if unauthenticated, 403 if unauthorized).
    • Pins connection directly to the validated IP to defeat DNS rebinding.
    • Disables redirect following (redirect: "manual"), categorizing redirects as non_2xx without following hops.
    • Coarse error reporting: returns only success, status_code, and sanitized categories (timeout, non_2xx, connection_failed) without raw internal error texts.
  4. Persistence Protection (PATCH /api/v2/streams/[id]):

    • validatePatchStreamBody asynchronously validates webhook_url against assertSafeOutboundUrl, returning 422 VALIDATION_ERROR with field-level details on unsafe hosts.
  5. Test Suite & Security Documentation:

    • Un-quarantined app/api/streams/[id]/webhooks/test/route.test.ts from jest.config.js.
    • Added unit test suite app/lib/outbound-url.test.ts covering all scenarios (IP ranges, DNS mocking, redirect handling, port restrictions, credentials rejection, production enforcement).
    • Added outbound webhook security documentation to SECURITY.md.

Test Plan Coverage

  • Scenario 1: http://169.254.169.254/ -> Rejected
  • Scenario 2: http://127.0.0.1:8080, http://[::1]/, http://[::ffff:10.0.0.1]/ -> Rejected
  • Scenario 3: https://hooks.example.com resolving to 10.1.2.3 -> Rejected
  • Scenario 4: https://hooks.example.com resolving to public IP -> Accepted, pinned address returned
  • Scenario 5: https://user:pw@hooks.example.com -> Rejected
  • Scenario 6: http:// in a production env -> Rejected
  • Scenario 7: Redirect from a public host to a private one -> Not followed; reported as non_2xx
  • Scenario 8: Test route with an unsafe URL -> 400 UNSAFE_ENDPOINT_URL; delivery client not called
  • Scenario 9: PATCH webhook_url to a private host -> 422 field error
  • Scenario 10: Test route response on a connection failure -> No raw error text in response body
  • Scenario 11: Unauthenticated caller -> `401 UNAUTHORIZED``

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.

Block SSRF in POST /api/streams/[id]/webhooks/test and stream webhook_url (private, loopback and metadata hosts)

1 participant