Skip to content

Fixed Security Vulnerability: Notification HTML reaching Ghost Admin unsanitised - #29746

Closed
pptx704 wants to merge 1 commit into
TryGhost:mainfrom
pptx704:security/notification-html-sanitization
Closed

Fixed Security Vulnerability: Notification HTML reaching Ghost Admin unsanitised#29746
pptx704 wants to merge 1 commit into
TryGhost:mainfrom
pptx704:security/notification-html-sanitization

Conversation

@pptx704

@pptx704 pptx704 commented Aug 4, 2026

Copy link
Copy Markdown

Fixed notification HTML reaching Ghost Admin unsanitised

⚠️ Unpatched at time of writing

This describes a live vulnerability in current Ghost, with a working proof of concept. It has not been reported to Ghost Security or assigned a CVE. Treat this PR as sensitive until a patch ships upstream — see Disclosure below.

Classification

Class Stored (persistent) cross-site scripting leading to horizontal-to-vertical privilege escalation
CWE-79 Improper neutralization of input during web page generation — notification bodies are persisted and served without sanitisation
CWE-116 Improper encoding or escaping of output — both admin clients explicitly mark server notifications as trusted HTML (htmlSafe, dangerouslySetInnerHTML)
CWE-269 Improper privilege management — Add notifications is granted to the Editor and Super Editor roles, making the sink reachable below the administrator boundary
CWE-1188 Insecure default — the status field defaults to 'alert', which is precisely the value that triggers the HTML-trusted render path
Severity High — CVSS 3.1 base 8.7 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). Scoring availability as A:H, on the basis that a compromised Owner session can destroy site content, yields 9.0 (Critical). Unofficial, self-assessed.
Vector Remote, authenticated. Requires only an Editor-tier account — the lowest role that holds the permission.
Interaction Minimal. The payload renders in the admin alert bar on any admin page load; the victim need not click attacker-controlled content.
Affected Confirmed exploitable end-to-end against a live 6.21 instance. The vulnerable code paths are unchanged in main as of this branch. No claim is made about the full affected range — the pattern predates 6.21.
Fixed in This PR.

Why this is an escalation, not self-XSS

Editors are a semi-trusted role on multi-author publications. The primitive lets an Editor execute JavaScript in the admin origin under the session of every Owner, Administrator and Editor who opens Ghost Admin — crossing a role boundary Ghost otherwise enforces server-side. From there the attacker inherits the victim's ambient session against the Admin API.

Aggravating factor: no in-product remediation

DELETE /notifications/:id does not delete. destroy() only marks the notification seen for the calling user; destroyAll is not exposed over HTTP, and notifications is a core-group setting with no settings-API write path. An injected payload therefore cannot be removed through the admin UI, and deleting the attacker's account does not clear it. Eviction otherwise requires direct database access — which is why the fix sanitises on read as well as on write, so upgrading alone neutralises already-stored payloads.

Summary

Ghost stores notification bodies without sanitising them, and both admin clients render those bodies as trusted HTML. Because the Add notifications permission is granted to the Editor and Super Editor roles, a user holding an editor-tier account can persist arbitrary HTML — including script — that executes in the admin-origin session of every Owner, Administrator and Editor who loads Ghost Admin.

This is a privilege escalation across a staff role boundary, not merely self-XSS.

The vulnerability

The chain has four links, all in current main:

  1. No sanitisation on write. services/notifications/notifications.js add() merges caller input straight into the notifications setting. message is never sanitised. The status field defaults to 'alert'.
  2. The endpoint is reachable by editors. fixtures.json grants notification: "all" to Editor and Super Editor, which includes the Add notifications permission. POST /ghost/api/admin/notifications/ therefore succeeds for an Editor.
  3. The client routes it to the alert path. apps/ember-admin/app/services/session.jsloadServerNotifications():
    e.top || e.custom ? this.notifications.handleNotification(e) : this.upgradeStatus.handleUpgradeNotification(e)
  4. The client marks it HTML-trusted. apps/ember-admin/app/services/notifications.jshandleNotification():
    // If this is an alert message from the server, treat it as html safe
    if (message.constructor.modelName === 'notification' && message.status === 'alert') {
        message.message = htmlSafe(message.message);
    }
    gh-alert.hbs then renders {{@message.message}}. The double-stache would normally escape, but htmlSafe() overrides that.

apps/ember-admin/app/services/upgrade-status.js applies htmlSafe() on the same data for the other branch, and admin-x-settings' About modal rendered it via dangerouslySetInnerHTML.

There is no Content-Security-Policy on /ghost/, so nothing contains the payload once it lands.

Impact

An Editor obtains code execution in the Owner's admin origin. The alert bar renders on every admin page load, so no interaction beyond opening Ghost Admin is required, and the payload persists in the notifications setting until explicitly deleted.

Verified end-to-end against a live Ghost 6.21 instance with an account whose only role was Editor.

Proof of concept

Minimal, non-destructive. Steps 1–2 as an Editor; step 3 observed as Owner.

# 1. Authenticate as an Editor (relay the emailed 2FA code if prompted)
curl -c j.txt -X POST https://SITE/ghost/api/admin/session/ \
  -H 'Content-Type: application/json' -H 'Origin: https://SITE' \
  -d '{"username":"editor@example.com","password":"..."}'

# 2. Store markup. Note: role is Editor, response is 201, markup returned verbatim
curl -b j.txt -X POST https://SITE/ghost/api/admin/notifications/ \
  -H 'Content-Type: application/json' -H 'Origin: https://SITE' \
  -d '{"notifications":[{"custom":true,"status":"alert","message":"<b>XSSPROOF</b>"}]}'

# 3. Load https://SITE/ghost/ as Owner.
#    Vulnerable: renders as bold "XSSPROOF".
#    Patched:    renders as the literal text <b>XSSPROOF</b>.

Swapping the <b> for <img src=x onerror=...> yields script execution in the Owner's session; that variant was confirmed but is omitted here deliberately.

Cleanup is not straightforward, which is part of the finding. DELETE /ghost/api/admin/notifications/<id>/ does not delete — destroy() only sets seen and appends the caller to seenBy:

// @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service.
allNotifications[i].seen = true;
allNotifications[i].seenBy.push(user.id);

The record stays in the notifications setting and continues to render for every staff user who has not individually dismissed it. destroyAll exists but is explicitly not exposed over HTTP, and notifications is a core-group setting, so there is no supported API path to purge an injected payload. Removing one requires dismissing as each affected user, or editing the setting directly in the database.

This materially raises severity: the payload is not just persistent, it is not removable through the admin UI, and deleting the attacker's account does not clear it. Note that the read-side sanitisation in this PR neutralises already-stored payloads on upgrade, without operator intervention — that is the main reason it is not write-side only.

Higher-severity variants

Not demonstrated, but reachable from the same primitive once script runs in an Owner session:

  • Durable backdoor. The payload can call the Admin API with the Owner's ambient session — create an Administrator user, mint an integration Admin API key, or add a webhook. Access then survives removal of the original editor account and the notification itself.
  • Self-reinstatement. The payload can re-POST itself before being dismissed. Combined with destroy() being dismiss-only and destroyAll not being exposed over HTTP, an injected notification cannot be removed through the admin UI at all — eviction requires direct database access.
  • Member data exfiltration. Admin sessions can browse the full member list — email addresses, Stripe subscription state, labels — for silent bulk exfiltration.
  • Content and distribution abuse. Posts can be created or edited and newsletters dispatched to the entire member list under the site's sending identity.
  • Fleet-wide variant via the upstream feed. The same unsanitised sink is fed by updates.ghost.org through update-check-service.js (message: message.content). A compromise of that feed would deliver HTML into the admin of every install that polls it. Notably the email rendering of this exact content was already sanitised, with a code comment naming a compromised feed as the threat — the in-app path simply lacked the equivalent guard.
  • Residual phishing after sanitisation. Even with script stripped, an attacker-controlled banner in official admin chrome can carry an allowlisted <a> to an external site. That is why this PR also removes the permission rather than relying on sanitisation alone.

The fix

  • Sanitise on write and on read (notifications.js). Write-side stops new unsafe HTML being persisted. Read-side matters independently: installs upgrading from an affected version may already hold unsafe HTML in the notifications setting, and this covers them without a data migration.
  • Reuse the existing sanitiser, renamed sanitize-email-html.tssanitize-notification-html.ts since it is no longer email-specific. The allowlist is unchanged: links and formatting are preserved; script, event handlers and non-http(s) schemes are stripped. Release notifications render exactly as before.
  • Defence in depth in the clients. The Ember notification and upgrade-status services now run DOMPurify.sanitize() before htmlSafe(). dompurify was already a dependency there. The React About modal's dangerouslySetInnerHTML is replaced with text rendering — nothing in the repo currently supplies that prop, so there is no markup to preserve and no new dependency was added.
  • Least privilege. Add notifications is dropped from Editor and Super Editor in fixtures.json, with a migration for existing installs. browse and destroy are retained so editors can still see and dismiss notifications. Nothing in Ghost Admin posts notifications — the endpoint exists for the update-check service and integrations — so this removes no working functionality.

Testing

  • test/unit/server/services/notifications/notifications.test.js — 12 passed, including three new cases covering write-side sanitisation, read-side sanitisation of already-stored HTML, and non-string message passthrough.
  • sanitize-notification-html.test.ts — 1 passed (snapshot renamed and updated).
  • notification-email.test.ts — 3 passed.
  • test/unit/server/services/update-check — 14 passed.
  • ESLint clean across ghost/core, apps/ember-admin and apps/admin-x-settings.

Not run: the Ember acceptance suite, and a live migration against a seeded database. Both are worth exercising in CI.

Notes for reviewers

  • The clients still render notification HTML unescaped by design, so release notes can carry links. The server sanitiser is now the enforced boundary; the client DOMPurify calls are a second layer, not the primary control.
  • Separately worth considering, both out of scope here: there is no Content-Security-Policy on /ghost/, and the admin session cookie is SameSite=None (deliberate, for the comments-ui auth iframe), which leaves the Origin check as the sole CSRF barrier on these endpoints.

Disclosure

This issue was automatically identified, tested, and fixed by Warden (in development), an autonomous penetration testing agent using DeepSeek V4 Flash.

Notification bodies reach Ghost Admin as rendered HTML - the clients
mark server notifications htmlSafe so release notes can carry links.
Nothing sanitised them, and "Add notifications" was granted to Editor
and Super Editor, so an editor-tier account could store script that
runs in an Owner's admin session on their next admin page load.

- sanitise on write and on read, so installs already holding unsafe
  HTML in the notifications setting are covered without a data fix
- purify the Ember render paths as a second layer, and drop the
  never-populated dangerouslySetInnerHTML in the React About modal
- drop "Add notifications" from Editor and Super Editor; browse and
  destroy stay so they can still see and dismiss notifications

The sanitiser keeps links and formatting and strips script, event
handlers and non-http(s) schemes, so release notifications render
unchanged.

Authored-by: Warden Harness <contact@omukk.dev>
@github-actions github-actions Bot added the migration [pull request] Includes migration for review label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

It looks like this PR contains a migration 👀
Here's the checklist for reviewing migrations:

General requirements

  • ⚠️ Tested performance on staging database servers, as performance on local machines is not comparable to a production environment
  • Satisfies idempotency requirement (both up() and down())
  • Does not reference models
  • Filename is in the correct format (and correctly ordered)
  • Targets the next minor version
  • All code paths have appropriate log messages
  • Uses the correct utils
  • Contains a minimal changeset
  • Does not mix DDL/DML operations
  • Tested in MySQL and SQLite

Schema changes

  • Both schema change and related migration have been implemented
  • For index changes: has been performance tested for large tables
  • For new tables/columns: fields use the appropriate predefined field lengths
  • For new tables/columns: field names follow the appropriate conventions
  • Does not drop a non-alpha table outside of a major version

Data changes

  • Mass updates/inserts are batched appropriately
  • Does not loop over large tables/datasets
  • Defends against missing or invalid data
  • For settings updates: follows the appropriate guidelines

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Notification messages are sanitized before persistence, retrieval, email rendering, and client handling. The settings upgrade notification now renders escaped text. The notification sanitizer export is renamed to sanitizeNotificationHtml, with updated documentation and tests. A migration removes notification creation permission from Editor and Super Editor roles. Fixtures retain only browse and destroy notification permissions.

Possibly related PRs

Suggested reviewers: mike182uk

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary security fix involving unsanitised notification HTML reaching Ghost Admin.
Description check ✅ Passed The description directly explains the vulnerability, affected paths, remediation, permissions change, migration, and testing.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/ember-admin/app/services/upgrade-status.js`:
- Around line 18-20: Keep upgradeStatus.message as a plain sanitized string by
removing htmlSafe() from the assignment in the upgrade status service and
storing DOMPurify.sanitize(message ?? '') directly. In
apps/ember-admin/app/services/upgrade-status.js lines 18-20, update the message
assignment; in apps/admin-x-settings/src/components/settings/general/about.tsx
lines 70-74, add a regression test verifying a populated upgrade message renders
in the About modal.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f185be2-edcb-40b4-a722-3bdad52ca834

📥 Commits

Reviewing files that changed from the base of the PR and between 9e6fe92 and 8159da8.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/unit/server/services/notifications/__snapshots__/sanitize-notification-html.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (10)
  • apps/admin-x-settings/src/components/settings/general/about.tsx
  • apps/ember-admin/app/services/notifications.js
  • apps/ember-admin/app/services/upgrade-status.js
  • ghost/core/core/server/data/migrations/versions/6.56/2026-08-04-10-00-00-remove-add-notifications-permission-from-editors.js
  • ghost/core/core/server/data/schema/fixtures/fixtures.json
  • ghost/core/core/server/services/notifications/notification-email.ts
  • ghost/core/core/server/services/notifications/notifications.js
  • ghost/core/core/server/services/notifications/sanitize-notification-html.ts
  • ghost/core/test/unit/server/services/notifications/notifications.test.js
  • ghost/core/test/unit/server/services/notifications/sanitize-notification-html.test.ts

Comment on lines +18 to +20
// Rendered unescaped by the About modal, so purify before trusting it.
// The server sanitises this too - this is the second layer, not the only one.
set(this, 'message', htmlSafe(DOMPurify.sanitize(message ?? '')));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'htmlSafe\(DOMPurify\.sanitize|upgradeStatus\.message' \
  apps/ember-admin/app/services/upgrade-status.js \
  apps/admin-x-settings/src/components/settings/general/about.tsx

fd -t f -i '.*(upgrade-status|about).*(test|spec).*' apps

Repository: TryGhost/Ghost

Length of output: 2196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## candidate file outlines"
ast-grep outline apps/ember-admin/app/services/upgrade-status.js || true
ast-grep outline apps/admin-x-settings/src/components/settings/general/about.tsx || true

echo "## relevant service/service tests"
for f in $(git ls-files | rg 'upgrade-status|notification.*upgrade|upgrade.*notification'); do
  if [[ "$f" =~ (test|spec|fixture) ]]; then
    echo "--- $f"
    sed -n '1,220p' "$f" | nl -ba
  fi
done

echo "## upgrade status imports and message usages"
rg -n -C 3 "UpgradeStatusService|upgrade-status|Upgrade status|message:|message\??" apps/ember-admin apps/admin-x-settings packages/admin-x-shared || true

echo "## check package ember deps"
fd -a 'package.json|ember-cli-build.js|config/targets.js|index.ts|index.html' apps/ember-admin . | sed 's#^\./##' | head -100
rg -n '"([^"]*)ember[^"]*"|htmlSafe|SafeString|react-dom|react' package.json apps/ember-admin package.json apps/admin-x-settings/package.json 2>/dev/null || true

Repository: TryGhost/Ghost

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## upgrade-status.js"
sed -n '1,120p' apps/ember-admin/app/services/upgrade-status.js | nl -ba

echo "## exact usages of UpgradeStatusService/message"
rg -n -C 5 "UpgradeStatusService|`@service`.*UpgradeStatus|service\\.UpgradeStatus|upgradeStatus|\\.message" apps/ember-admin/app apps/admin-x-settings/src 2>/dev/null | sed -n '1,240p'

echo "## files with upgrade-status in acceptance/unit tests"
rg -l -C 4 "UpgradeStatusService|upgrade/status|upgradeStatus|handleUpgradeNotification|available upgrade|message:" apps/ember-admin/tests | sed -n '1,120p'

echo "## inspect admin-x about component"
sed -n '1,130p' apps/admin-x-settings/src/components/settings/general/about.tsx | nl -ba

echo "## package deps (focused)"
rg -n '"(ember-source|ember-cli-htmlbars|`@ember/ember-test-selectors`|dompurify|react|react-dom)"|htmlSafe|SafeString' apps/ember-admin/package.json apps/admin-x-settings/package.json package.json apps/ember-admin/ember-cli-build.js 2>/dev/null || true

Repository: TryGhost/Ghost

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## upgrade-status.js"
sed -n '1,120p' apps/ember-admin/app/services/upgrade-status.js | awk '{printf "%5d\t%s\n", NR, $0}'

echo "## exact usages of UpgradeStatusService/message"
rg -n -C 5 "UpgradeStatusService|`@service`.*UpgradeStatus|service\\.UpgradeStatus|upgradeStatus|\\.message" apps/ember-admin/app apps/admin-x-settings/src 2>/dev/null | sed -n '1,240p'

echo "## files with upgrade-status in acceptance/unit tests"
rg -l -C 4 "UpgradeStatusService|upgrade/status|upgradeStatus|handleUpgradeNotification|available upgrade|message:" apps/ember-admin/tests | sed -n '1,120p'

echo "## inspect admin-x about component"
sed -n '1,130p' apps/admin-x-settings/src/components/settings/general/about.tsx | awk '{printf "%5d\t%s\n", NR, $0}'

echo "## package deps (focused)"
rg -n '"(ember-source|ember-cli-htmlbars|`@ember/ember-test-selectors`|dompurify|react|react-dom)"|htmlSafe|SafeString' apps/ember-admin/package.json apps/admin-x-settings/package.json package.json apps/ember-admin/ember-cli-build.js 2>/dev/null || true

Repository: TryGhost/Ghost

Length of output: 30203


🌐 Web query:

Ember SafeString htmlSafe constructor string property @ember/template SafeString

💡 Result:

In Ember.js, the SafeString class is not intended to be user-constructible [1][2][3]. The only public and supported API for creating a SafeString is by using the htmlSafe function, which is imported from @ember/template [4][1][2]. Attempting to instantiate SafeString directly is discouraged as it is an internal implementation detail, not a public API [1][2]. Key points regarding htmlSafe and SafeString: 1. Purpose: htmlSafe is used to mark a string as trusted, indicating to Ember's rendering layer that the string should not be HTML-escaped when rendered in a template [4][1][2]. 2. Security Warning: Marking a string as "safe" does not actually sanitize it [1][2]. If the string contains user-provided input, you must manually sanitize it before passing it to htmlSafe to prevent Cross-Site Scripting (XSS) vulnerabilities [1][2]. 3. Usage: import { htmlSafe } from '@ember/template'; const safeString = htmlSafe('

Trusted HTML content
'); 4. Detection: You can check if a value is a SafeString by using the isHTMLSafe function, also available from @ember/template [5]. Historically, htmlSafe was associated with @ember/string in older versions of Ember, but it has long been part of @ember/template [6][7][8]. Always rely on the current official API documentation for your version of Ember [4][2].

Citations:


🌐 Web query:

Ebay nice-modal React SafeString object as JSX child React TypeError warning

💡 Result:

The issue you are encountering with a "SafeString" object (or similar unexpected object types) being passed as a JSX child in nice-modal-react typically stems from an incompatibility between how React renders children and how the modal state or props are being managed [1][2]. In React, JSX children must be valid React nodes (strings, numbers, elements, or arrays of these); passing a complex object—such as a internal library SafeString or a state object—directly into the JSX tree triggers the "Objects are not valid as a React child" TypeError [1][3]. While nice-modal-react is designed to manage modal state and props globally via context, it does not automatically sanitize or serialize objects passed as props or children [2][3]. If you are experiencing this error, consider the following troubleshooting steps: 1. Check your Modal Component definition: Ensure that the modal component created with NiceModal.create or used as a functional component does not attempt to render an object property directly [3][4]. For example, if you pass a complex object (like a SafeString object from a third-party library) as a prop, ensure it is being accessed via a primitive property (e.g., props.myObj.toString() or props.myObj.value) before being rendered [3][4]. 2. Validate NiceModal.show arguments: If you are calling NiceModal.show(id, args), ensure the args object contains only serializable data [2][3]. If an object is passed as an argument, it becomes part of the modal's internal state [3]. If your modal component attempts to render that object as a child, React will throw the error [3]. 3. Verify Component Props: Ensure the modal component is not accidentally receiving the SafeString object as a direct child or as a prop that is subsequently injected into the DOM/JSX tree without conversion to a primitive [3]. If the SafeString is coming from an external utility (often used for HTML-safe strings), you must explicitly convert it to a string or a valid React-renderable format before passing it into your modal or using it inside the modal's return JSX [2][3]. For further debugging, inspect the component receiving the SafeString and locate where the object is being used in the render path. If this is a type-related issue during development, ensure your TypeScript definitions correctly reflect that the prop is an object rather than a renderable React node [5][6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files | rg 'admin-x-settings/app|admin-x-admin|lib/monolith|ember-admin/ember-cli-build|package.json' | sed -n '1,160p'

echo "## inspect admin-x settings provider files"
for f in $(git ls-files apps/admin-x-settings/src/components/providers apps/admin-x-settings/src/app.tsx | rg 'settings-app-provider|global-data-provider|app.tsx|index.tsx'); do
  echo "--- $f"
  sed -n '1,180p' "$f"
done

echo "## inspect admin-x package react/dom deps"
sed -n '1,120p' apps/admin-x-settings/package.json | awk '{printf "%5d\t%s\n", NR, $0}'

echo "## search migration/embed code for upgradeStatus prop"
rg -n -C 5 "upgradeStatus|UpgradeStatusType|settings-app-provider|`@tryghost/admin-x-settings`|Embed|admin-x" apps ember-admin app lib packages 2>/dev/null | sed -n '1,260p'

Repository: TryGhost/Ghost

Length of output: 36640


Keep upgradeStatus.message as a plain string.

htmlSafe() returns an Ember SafeString, and upgradeStatus.message is consumed directly by React as a JSX child. React does not render SafeString objects as text, so a populated upgrade message stops the About modal from rendering.

  • apps/ember-admin/app/services/upgrade-status.js#L20: store DOMPurify.sanitize(message ?? '') directly.
  • apps/admin-x-settings/src/components/settings/general/about.tsx#L70-74: add a regression test with a populated upgrade message.
📍 Affects 2 files
  • apps/ember-admin/app/services/upgrade-status.js#L18-L20 (this comment)
  • apps/admin-x-settings/src/components/settings/general/about.tsx#L70-L74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ember-admin/app/services/upgrade-status.js` around lines 18 - 20, Keep
upgradeStatus.message as a plain sanitized string by removing htmlSafe() from
the assignment in the upgrade status service and storing
DOMPurify.sanitize(message ?? '') directly. In
apps/ember-admin/app/services/upgrade-status.js lines 18-20, update the message
assignment; in apps/admin-x-settings/src/components/settings/general/about.tsx
lines 70-74, add a regression test verifying a populated upgrade message renders
in the About modal.

Source: MCP tools

@9larsons

9larsons commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@pptx704 Hey, thanks for the submission and report. In the future, these are ideally submitted through security@ghost.org so we can handle them securely, though this one only impacts Admin users.

While your PR is pretty close, I want to separate out the concerns - the migration and unnecessary permissions + the sanitization - so I'm going to PR this and credit you in those commits.

@9larsons 9larsons closed this Aug 4, 2026
9larsons added a commit that referenced this pull request Aug 4, 2026
ref #29746
- removed `notification:add` from the Editor and Super Editor roles
- retained `notification:browse` and `notification:destroy` so those
roles can still view and dismiss notifications
- added a migration for existing installs and aligned production/test
fixtures and integrity expectations
- added API regression coverage for Administrator, Editor, and Super
Editor notification creation permissions

Notifications are a system-wide administrative channel. Editor-tier
roles need to view and dismiss them, but do not need to create them.

Thanks to @pptx704 for reporting this permission gap in #29746.
9larsons added a commit that referenced this pull request Aug 4, 2026
ref #29746

Notification messages are rendered as trusted HTML in Admin. Sanitising on read and write protects both new and previously stored records while preserving semantic update-service content and links.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants