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
- 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).
- 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).
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
Tests
Documentation
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.
Problem
POST /webhooksaccepts any URL matching/^https?:\/\/.+/(api/src/webhooks/dto/create-webhook.dto.ts), andWebhooksService.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/..., orhttp://redis:6379/...— and the API will issue an outbound request to it and store the response body (up toMAX_STORED_RESPONSE_BODY_LENGTH= 4,000 chars) inwebhook_deliveries.last_response_body.The delivery log endpoint
GET /webhooks/:id/deliveriesthen 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_RETRIESinwebhooks.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
Why this is architecturally hard
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).WEBHOOK_ALLOWED_HOSTS, vs always-block-private).attemptDelivery()runs fire-and-forget fromdispatchStreamEventand from the@Intervalretry sweep; SSRF protection must be applied in both paths (or once in a sharedattemptDelivery), and a blocked target must produce a terminal, visible delivery state rather than infinite retries.Acceptance criteria
Service
failedwith a clearlast_error, without further retries.::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.failedstatus withattemptCountnot exceeding 1.api/src/webhooks/webhooks.controller.spec.ts) asserts registration returns 400 for a blocked URL.Documentation
CreateWebhookDtoJSDoc 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:
Good first files to read:
api/src/webhooks/webhooks.service.ts(theattemptDeliverymethod),api/src/webhooks/dto/create-webhook.dto.ts,api/src/config/env.ts.