Skip to content

🐛 Fixed stored XSS in structured data via unescaped tag names#28957

Closed
swithek wants to merge 1 commit into
TryGhost:mainfrom
swithek:fix/escape-tag-name-xss
Closed

🐛 Fixed stored XSS in structured data via unescaped tag names#28957
swithek wants to merge 1 commit into
TryGhost:mainfrom
swithek:fix/escape-tag-name-xss

Conversation

@swithek

@swithek swithek commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Tag names, post keywords, and the site title were written into the inline JSON-LD <script type="application/ld+json"> block via JSON.stringify, which doesn't escape </script>. An Editor-controlled tag name like foo</script><script>alert(document.domain)</script> broke out of the script element and executed arbitrary JS for any anonymous visitor on tag and post pages.

Fix

Escape the three affected fields with escapeExpression in getTagSchema, getPostSchema, and getHomeSchema. This is consistent with every other text field in the file (headline, description, author/publisher name), which were already escaped.

Testing

Added regression tests asserting a </script> payload is HTML-escaped in tag, post-keywords, and home schema output.
Updated the existing ghost_head snapshot that was silently capturing the previously-unescaped post keywords.

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

@swithek
swithek force-pushed the fix/escape-tag-name-xss branch from 10d59b7 to 4ac9b64 Compare June 28, 2026 20:26
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6008203e-bd62-4ff1-876c-c483c2173f19

📥 Commits

Reviewing files that changed from the base of the PR and between 6702db4 and 493fdab.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/unit/frontend/helpers/__snapshots__/ghost-head.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • ghost/core/core/frontend/meta/schema.js
  • ghost/core/test/unit/frontend/meta/schema.test.js
✅ Files skipped from review due to trivial changes (1)
  • ghost/core/core/frontend/meta/schema.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/frontend/meta/schema.test.js

Walkthrough

escapeExpression is applied to three user-controlled schema fields in schema.js: the joined metaData.keywords value in getPostSchema, metaData.site.title in getHomeSchema, and data.tag.name in getTagSchema. New unit tests in schema.test.js verify that </script> payloads are HTML-escaped in the generated JSON-LD name and keywords fields.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the stored XSS fix in structured data escaping, which is the main change in this PR.
Description check ✅ Passed The description is directly related to the changeset and correctly explains the escaping fix, tests, and affected fields.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@9larsons
9larsons force-pushed the fix/escape-tag-name-xss branch from 4ac9b64 to 6702db4 Compare July 1, 2026 14:26
no ref

- tag names, post keywords and site title were written into the inline JSON-LD <script> via JSON.stringify with no HTML-escaping, letting an editor-set tag name break out of the script and run JS for any visitor
- escaped those fields in getTagSchema/getPostSchema/getHomeSchema
@9larsons

9larsons commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@swithek Thanks for this! While correct, this is still playing a bit of whack-a-mole and sets us up for adding new fields that aren't properly escaped. I've reworked this in #29013.

@9larsons 9larsons closed this Jul 1, 2026
9larsons added a commit that referenced this pull request Jul 1, 2026
ref #28957

- tag names, post keywords, and the site title were serialized raw into the
  inline <script type="application/ld+json"> block, letting an Editor-controlled
  value like `foo</script><script>...` break out of the script element and run
  arbitrary JS for anonymous visitors on tag and post pages
- escapes the breakout-relevant characters (< > U+2028 U+2029) as JSON \u escapes
  at the single serialization boundary in ghost_head, so every field is covered
  at once instead of relying on per-field escaping that is easy to forget
- removed the per-field escapeExpression calls from schema.js: HTML-entity
  escaping is the wrong layer here — JSON-LD consumers (Google et al.) parse the
  block as JSON and never HTML-decode, so it silently corrupted structured data
  (e.g. `Tom & Jerry` was indexed as `Tom &amp; Jerry`). JSON \u escapes are both
  safe and lossless, so legitimate `& ' "` now round-trip correctly
- added a regression test proving breakout is neutralised while data round-trips,
  and updated the snapshot/assertions that were capturing the old corruption
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants