Details
### Summary
A WEBHOOK alert channel stores a user-supplied `url`. When an alert fires (deployment/run failure, error groups), the webapp server (`alertsWorker` -> `DeliverAlertService`) POSTs the HMAC-signed alert payload to that URL via `fetch(webhook.url, ...)`. The URL is never validated against a host allowlist or private-IP/metadata blocklist (a repo-wide search for `169.254`, `isPrivate`, `isLoopback`, `net.isIP`, `ssrf` returns ZERO hits), and the API route's URL field is just `z.string().optional()` (no syntax check at all). So an authenticated tenant can point the webhook at internal infrastructure or `169.254.169.254` and the multi-tenant server fetches it.
### Affected
`apps/webapp`, HEAD `5d99457` (current main).
### Root cause
- Source (API route): `app/presenters/v3/ApiAlertChannelPresenter.server.ts` `ApiAlertChannelData.url = z.string().optional()` (no host validation); route `app/routes/api.v1.projects.$projectRef.alertChannels.ts` (PAT-auth).
- Store: `app/v3/services/alerts/createAlertChannel.server.ts` persists `{url, secret, version}` verbatim.
- Sink: `app/v3/services/alerts/deliverAlert.server.ts:973` `fetch(webhook.url, {method:"POST", headers:{"x-trigger-signature-hmacsha256":...}, body:rawPayload})`; identical at `deliverErrorGroupAlert.server.ts:258`. Runs inside the webapp process; `fetch` follows redirects (IMDSv1-via-redirect). No egress guard anywhere.
### Runtime PoC (proven on self-host)
Seeded a project + STAGING env + a WEBHOOK channel with `url=http://host.docker.internal:7766/...` (an internal address from the server's POV) + a DEPLOYING deployment. Triggered via the public API `POST /api/v1/deployments/deployment_ssrfpoc/fail` -> 200 -> FAILED -> alertsWorker -> the server fetched the listener. Captured:
```
POST /ssrf-via-webhook HTTP/1.1
host: host.docker.internal:7766
x-trigger-signature-hmacsha256: <redacted>
user-agent: node
{"type":"alert.deployment.failed", ...}
```
The webapp server (user-agent: node) made an outbound POST to the attacker-chosen internal URL; 169.254.169.254 / any internal host works identically.
### Severity
`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N` ~ Medium. Blind (response not reflected) + POST-only (fixed body = signed alert payload), so it enables internal port/host scanning (delivery success/timing oracle), state-changing POSTs to internal services, and IMDSv1-via-redirect — not arbitrary GET exfiltration. PR:L (authenticated tenant). Egress private-IP/metadata blocking on server-issued webhooks is industry standard; its absence is the defect.
### Suggested fix
Validate the webhook URL on create (require http(s), resolve host, reject private/link-local/loopback/metadata ranges) AND re-check at fetch time (re-resolve after redirects or disable redirects / pin to resolved public IP). A shared `assertPublicUrl(url)` used by `createAlertChannel` + the two delivery sinks.
### Dup
Distinct CWE-918 class from the IDOR advisories. FRESH. (The Grav maintainer independently hardened the same webhook-URL-SSRF class in grav-plugin-api commit dfcc947 on 2026-06-26 — corroborating the class is real and fixable.)