Ghost: Tag Name Breaks Out of the Structured Data Block

The Author role plants script in a tag name that lands unescaped in the structured data of every post page.

Advisory ID: TP-2026-084
Product: Ghost (publishing platform for blogs and newsletters, Node.js)
Vulnerability type: Stored cross-site scripting (CWE-79)
CVE: CVE-2026-103292
CVSS 3.1: 8.0 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H
Affected versions: >= 0.5.3, < 6.50.0
Vendor advisory: GHSA-mp9q-4q6m-44f5
Fixed in: 6.50.0
Reported: 3 June 2026

Summary

Ghost puts a block of structured data into every page head so search engines can read its title, tags and author. Tag names reach that block unescaped, although every neighbouring field of the same block is escaped. An Author, the lowest role that may write tags, creates a tag whose name carries the sequence that closes the script block and attaches it to a published post. Every anonymous visitor to the tag page, and to any post carrying that tag, then runs the attacker's script, because the public site sends no Content-Security-Policy. When a logged-in administrator opens such a post, the script rides their session into the admin API and mints a persistent admin key.

Root cause

getPostSchema writes a post's tag names into the schema as keywords without passing them through escapeExpression (core/frontend/meta/schema.js:128), and getTagSchema does the same with the tag page's own name (:160), while the other name and description fields in that file are escaped (:30, :89, :114, :123, :175). The ghost_head helper serialises the schema with JSON.stringify into a script block of type application/ld+json (core/frontend/helpers/ghost_head.js:325-328) and returns the result as a SafeString (:430), so Handlebars does not escape it afterwards. JSON.stringify escapes quotes but not angle brackets, so a </script> in the name ends the block and the HTML parser reads what follows as script. Tag name validation forbids only a leading comma (core/server/data/schema/schema.js:272), and models/tag.js:101 derives just the slug from it while storing the name verbatim. The Author role carries the right to create tags out of the box, and the public site sends neither CSP nor X-Frame-Options nor nosniff.

Proof of Concept

# 1. as an Author, create a tag with the name:
News</script><script>alert(document.domain)</script>

# 2. attach it to a published post, then fetch anonymously:
curl -s http://ghost.example/tag/news/    | grep -A2 '"name"'
curl -s http://ghost.example/my-post/     | grep '"keywords"'

# output, the </script> sits raw inside the ld+json block:
"keywords": "News</script><script>alert(document.domain)</script>",

Only the quotes are JSON-escaped, the angle brackets are not. The script block therefore ends early, and with no CSP the browser runs what follows. The keywords variant fires on every post carrying the tag, not just on the tag page.

Impact

  • Script execution in the site's origin for every anonymous visitor of an affected post or tag page.
  • Instance takeover when a logged-in administrator opens such a page: the admin area sits on the same origin, the session cookie is attached automatically, and the origin check that serves as the only CSRF gate is satisfied by same-origin script. A POST to /ghost/api/admin/integrations/ mints a persistent admin key that survives a logout.
  • Privilege escalation from the Author role to owner-level rights, with no post approval needed.
  • Disclosure of member data and modification of settings through the same ridden session.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.