Skip to content

Webhook URLs are unvalidated: arbitrary http(s) targets enable SSRF from the API #512

Description

@Xhristin3

Problem

POST /webhooks accepts any URL matching /^https?:\/\/.+/ (api/src/webhooks/dto/create-webhook.dto.ts), and WebhooksService.attemptDelivery() (api/src/webhooks/webhooks.service.ts) POSTs the signed payload to it. Nothing restricts the target to public addresses. A caller can register a webhook pointing at internal infrastructure — http://localhost:3001/..., http://169.254.169.254/... (cloud metadata), http://postgres:5432/..., or http://redis:6379/... — and the API will issue an outbound request to it and store the response body (up to MAX_STORED_RESPONSE_BODY_LENGTH = 4,000 chars) in webhook_deliveries.last_response_body.

The delivery log endpoint GET /webhooks/:id/deliveries then lets the attacker read whatever the internal service replied, turning the webhook subsystem into an unauthenticated-to-internal SSRF oracle: probe an internal port, then read the response via the deliveries log. Retries make this worse — each failed attempt re-hits the internal target with the same payload up to 6 times over 24 hours (MAX_RETRIES in webhooks.service.ts), and the retry sweep re-attempts every pending delivery every minute.

The fetch uses AbortSignal.timeout(WEBHOOK_DELIVERY_TIMEOUT_MS) (10s), so slow internal endpoints tie up API workers, and the sweep is unbounded per pass against a slow target.

Root cause

// api/src/webhooks/dto/create-webhook.dto.ts
@Matches(/^https?:\/\/.+/, {
  message: "url must be a valid absolute URL",
})
url!: string                                  // ← any host, any port, any scheme

Why this is architecturally hard

  1. A naive "block localhost" blocklist is bypassable (DNS rebinding, decimal/hex IP encodings, http://127.0.0.1.nip.io). The robust fix is to resolve and validate the target IP at delivery time, or pin the allowed target space to a configured allowlist, and decide where that validation lives (DTO, service, or a shared outbound-request guard).
  2. There is no existing outbound-HTTP policy in the API to reuse — the only other outbound calls are to the database. This introduces a new trust boundary and needs a decision on how operators configure it (env allowlist, e.g. WEBHOOK_ALLOWED_HOSTS, vs always-block-private).
  3. attemptDelivery() runs fire-and-forget from dispatchStreamEvent and from the @Interval retry sweep; SSRF protection must be applied in both paths (or once in a shared attemptDelivery), and a blocked target must produce a terminal, visible delivery state rather than infinite retries.

Acceptance criteria

Service

  • Webhook registration rejects URLs that resolve to loopback, link-local, private, or unspecified IP ranges, or that resolve to a hostname whose A/AAAA records land in those ranges.
  • If the target cannot be resolved at registration, delivery-time resolution applies the same check and marks the delivery failed with a clear last_error, without further retries.
  • IPv4-mapped IPv6 (::ffff:127.0.0.1) and decimal/hex-encoded IP forms are rejected.

Tests

  • api/src/webhooks/webhooks.service.spec.ts (or a new spec) covers: loopback URL, cloud-metadata IP (169.254.169.254), private ranges (10/8, 172.16/12, 192.168/16), localhost, and a public URL that must be accepted.
  • A delivery against a blocked target records exactly one attempt and a failed status with attemptCount not exceeding 1.
  • The controller-level spec (api/src/webhooks/webhooks.controller.spec.ts) asserts registration returns 400 for a blocked URL.

Documentation

  • The CreateWebhookDto JSDoc and Swagger description document the URL restriction and how operators can widen/narrow it.

Out of scope

Webhook payload signing changes, delivery-rate limiting, and TLS verification options. This issue is limited to SSRF protection on outbound webhook delivery.

Getting started

Real files in scope: api/src/webhooks/dto/create-webhook.dto.ts, api/src/webhooks/webhooks.service.ts (attemptDelivery), api/src/webhooks/webhooks.controller.spec.ts, api/src/webhooks/webhooks.service.spec.ts, api/src/config/env.ts (for the allowlist env var).

Verify with:

cd api && npm run typecheck && npm test

Good first files to read: api/src/webhooks/webhooks.service.ts (the attemptDelivery method), api/src/webhooks/dto/create-webhook.dto.ts, api/src/config/env.ts.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignapiREST API design and endpointsbugSomething isn't workingsecuritySecurity related issues

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions