fix(webhooks): reject internal and metadata addresses in webhook delivery
Fixes: Webhook delivery calls tenant URLs without an SSRF check
Any team member could save a webhook destination on an internal address. The delivery worker sent the signed payload to that address, and the tenant could read the response back from the webhook call record. Once this merges, an internal, loopback, link-local, or metadata destination is no longer usable for a webhook.
flowchart LR
A["Tenant saves a webhook URL"] --> B["createWebhook / updateWebhook"]
B --> C["assertWebhookUrlAllowed"]
C -->|blocked| D["BAD_REQUEST to the tenant"]
C -->|allowed| E["db.webhook.url stored"]
E --> F["Worker: postWebhook"]
F --> G["assertWebhookUrlAllowed"]
G -->|blocked| H["failed call, target not contacted"]
G -->|allowed| I["fetch POST to the public endpoint"]
Detection evidence
Affected: int_13ee3c91c001c2c8xfdj06a9
Why this fix
A unit test drives the real delivery worker at a loopback webhook target, and the worker contacted it, while the repository docs already require a publicly accessible endpoint. The worker now resolves the host and refuses a private, loopback, link-local, or metadata address before it sends, and the save path refuses the same addresses so the tenant gets a clear error instead of a silently failing webhook. The residual risk is a DNS answer that changes between the save check and the send check, because the resolved address is not pinned.
Fix walkthrough
Watch the fix explained (2:33). Recorded for commit 1358dc55696f684d948c8e55b429249404c0dcce.
Out of scope
SesSettingsService.createSesSettingfetches an admin-supplied URL without this guard. Its caller is the platform-admin procedure, a different boundary, and it is recorded on the incident timeline.- Pinning the resolved address against a DNS answer that changes between the check and the request.
Causal chain
- Signal (alert): Tenant-controlled webhook URL fetched server-side without SSRF protection in useSend
- Surfacing site: usesend/useSend at apps/web/src/server/service/webhook-service.ts#postWebhook
- Mechanism: postWebhook calls fetch with the URL stored in db.webhook, and no code checks the scheme, the host, or the resolved address. The URL comes from any team member, not an admin.
- Producer: useSend webhook delivery worker (postWebhook), at usesend/useSend:apps/web/src/server/service/webhook-service.ts#postWebhook
- Trigger: A team member saves a webhook URL with an internal or link-local host, then sends a test event or a real event.
- What happens to the failed unit today: postWebhook sets redirect: "manual", so a redirect to an internal host is not followed. BullMQ retries a failed call up to six times with exponential backoff and disables the webhook after thirty consecutive failures.
- Blast radius: unknown other resource(s), unknown other tenant(s); data at risk: Internal service responses, and any cloud instance metadata that the deployment network exposes to the worker.
- Producer evidence:
- Reproducer in apps/web/src/server/service/webhook-url-guard.unit.test.ts: a webhook URL of http://127.0.0.1:/internal made the captured worker handler contact a loopback server once before the change and zero times after it.
- apps/web/src/server/service/webhook-service.ts#postWebhook calls fetch(params.url, { method: "POST", redirect: "manual", signal }) with no address check.
- apps/web/src/server/api/routers/webhook.ts validates the URL with z.string().url() only, which accepts http://169.254.169.254/latest/meta-data/.
- apps/web/src/server/api/trpc.ts teamProcedure requires team membership only, so any team member can set the URL.
4 files changed (+624/-0)
apps/web/src/server/service/webhook-service.ts: modified, +26/-0apps/web/src/server/service/webhook-service.unit.test.ts: modified, +6/-0apps/web/src/server/service/webhook-url-guard.ts: added, +250/-0apps/web/src/server/service/webhook-url-guard.unit.test.ts: added, +342/-0
Repository lint: pnpm run lint (declared in package.json) fails over the whole repository (the declared target takes no file arguments), so the failure is not attributed to this change and did not block it; run it before merging.
Lint output
r read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
@usesend/ui:lint: $ eslint . --max-warnings 0
usesend-js:lint: cache miss, executing 2b48c7c6e12d1991
usesend-js:lint: [WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
usesend-js:lint: $ eslint . --max-warnings 0
usesend-js:lint: sh: 1: eslint: not found
usesend-js:lint: [ELIFECYCLE] Command failed.
@usesend/ui:lint: [ELIFECYCLE] Command failed.
Tasks: 1 successful, 3 total
Cached: 0 cached, 3 total
Time: 3.988s
Failed: usesend-js#lint
[ELIFECYCLE] Command failed with exit code 1.
[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
$ turbo lint
Attention:
Turborepo now collects completely anonymous telemetry regarding usage.
This information is used to shape the Turborepo roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://turborepo.dev/docs/telemetry
• turbo 2.10.4
usesend-js#lint: ERROR command (/workspace/usesend/useSend/packages/sdk) /root/.local/share/pnpm/.tools/pnpm/11.11.0/bin/pnpm run lint exited (1)
ERROR run failed: command exited (1)
Generated by Polylane. You can ask follow-ups by mentioning @polylane in a comment.
Summary by cubic
Rejects webhook destinations that resolve to internal, loopback, link-local, or cloud metadata addresses so the delivery worker no longer sends signed payloads to tenant controlled internal hosts.
- Validates the webhook URL on create and update, returning a
BAD_REQUESTto the tenant instead of storing a silently failing webhook. - Re-checks the resolved address in the delivery worker right before sending, and fails the call without contacting the target.
- Uses a new
assertWebhookUrlAllowedguard that resolves hostnames and rejects if any returned address is non-public. - The residual risk is a DNS answer that changes after the check, since the resolved address is not pinned.
