Ghost
Webhook Delivery Fires Against Internal Network Addresses, Exposing Cloud Metadata
Ghost’s webhook delivery path used a plain HTTP client with no SSRF protections. An authenticated admin could register a webhook pointing at internal network addresses, including AWS/GCP/Azure instance metadata endpoints, and trigger it by publishing a post. Ghost already maintained a hardened request library (request-external.js) used everywhere else in the codebase; the webhook path simply never called it.
Ghost maintains two HTTP clients. @tryghost/request is a plain got wrapper with no URL validation. request-external.js is a hardened wrapper that blocks all RFC-1918 and link-local ranges, handles octal/hex notation and IPv4-mapped IPv6, and prevents DNS rebinding via a custom lookup hook. Ghost uses request-external.js for oEmbed fetches, webmention processing, recommendation metadata, and external media inlining, but webhook-trigger.js used the unprotected client.
Step 1: Create an integration
Step 2: Register a webhook targeting AWS metadata
Step 3: Trigger by publishing a post
Step 4: Read the observable signal
This is a blind SSRF, response bodies are not returned. However, the HTTP status code and Node.js error string stored in last_triggered_status / last_triggered_error are sufficient for precise internal port scanning, cloud metadata probing (confirming whether IMDSv1 or IMDSv2 is active), and reaching internal HTTP endpoints that change state on a request. Ghost retries failed deliveries up to 5 times, so a single webhook registration produces up to 6 requests per trigger event.
Internal targets reachable via this vector: AWS EC2 metadata (169.254.169.254), GCP metadata service (metadata.google.internal), Azure IMDS, Docker host gateway (172.17.0.1), internal databases, Kubernetes API server, and Prometheus metrics endpoints.
Replace the unprotected HTTP client in webhook delivery with request-external.js, the hardened library Ghost already uses everywhere else. This is a one-line change in webhook-trigger.js:
As defence-in-depth, validate target_url at webhook creation time: enforce http/https scheme and optionally block private hostname patterns in the data schema.
Patched in 815962d ↗, Ghost now routes webhook delivery through request-external.js.