TL;DR
- What: In Ghost, the code that decides whether a staff user may change another user's role skipped its permission check whenever the new role was the same as the caller's own. Editors (and Super Editors) may only give out the Author and Contributor roles, but an Editor could set an Author's role to Editor. In my local test on v6.62.0, an Editor's request to promote an Author to Editor returned 200 and the change was saved; the same Editor asking for Administrator got 403.
- Impact: per the advisory, Editors and Super Editors could assign their own role to Author and Contributor users. The advisory lists every version from 0.5.0 up to 6.64.0 as affected. Rated Moderate 4.3 (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N). CWE-269 and CWE-863. - Fixed in 6.64.0. Advisory GHSA-4pvx-fwjj-8gpc thanks four reporters: SecBuddyF of Tencent KeenLab, Pedro Pinho, Nhat Anh Vu and me. The flaw has two CVE IDs: CVE-2026-105678 (assigned by GitHub, linked from the advisory) and CVE-2026-104412 (assigned by VulnCheck).
Why you should care
Ghost is an open-source Node.js publishing platform. Its staff roles form a ladder: Contributor, Author, Editor, Administrator and Owner, and recent versions add a Super Editor. Ghost's docs say Editors "can invite, manage and edit authors and contributors". Editors can't make anyone else an Editor.
Ghost treats every staff user as trusted, so this is not an outsider attack. It is a gap in the ladder: an Editor could invite an Author, which is allowed, and then promote that account to Editor, which is not.
The setup
Two places in Ghost already enforce the rule. When an Editor assigns a role, Role.permissible('assign') in role.js allows only two names, lines 103-105 at v6.62.0:
} else if (isEitherEditor) { checkAgainst = ['Author', 'Contributor']; } The invite model has the same list for new staff invites, invite.js lines 118-119.
The bug
Changing an existing user's role goes through User.permissible('edit') in user.js. The branch that calls the assign check above sits behind a condition, line 1040:
} else if (roleId !== contextRoleId) { // CASE: you are trying to change a role, but you are not owner // @NOTE: your role is not the same than the role you try to change (!) // e.g. admin can assign admin role to a user, but not owner return permissions .canThis(context) .assign.role(role) contextRoleId is the caller's own role, line 996. When the requested role equals the caller's role, the assign check never runs. The code falls through to line 1061, which allows the change if hasUserPermission is set. For an Editor, that flag was set earlier, at lines 953-955, from the target alone: true if the user being edited is an Author or Contributor.
The "aha"
The shortcut's own comment gives the reason for it: an Administrator can assign Administrator. That is true for Administrators. It is not true for Editors.
Proof of concept (benign)
I cloned Ghost at v6.62.0 and booted the real Admin API in-process with Ghost's own end-to-end test harness, on SQLite, with Ghost's synthetic fixture users: an Owner, an Editor and an Author. The model files ran as shipped. One test-harness helper, the database reset, was swapped for a SQLite-compatible one; nothing under core/server/models changed.
The request, sent as the Editor:
PUT /ghost/api/admin/users/<authorId>/ {"users":[{"id":"<authorId>","roles":[{"id":"<editorRoleId>"}]}]} The results, from my test notes:
# target user starts as Author Editor -> set target role to Editor -> 200, role now Editor Owner -> set target role to Editor -> 200, role now Editor Editor -> set target role to Administrator -> 403 NoPermissionError, role still Author The change was saved: reading the user back from the database showed the Editor role. To confirm the cause, I removed the shortcut so the assign check ran for every role change by a non-Owner, and re-ran: the Editor's request returned 403 and the target stayed an Author.
Honest boundary: I tested an Editor promoting an Author that already existed as a test user; I didn't run the invite step. I did not test a Super Editor or a Contributor target. The maintainers' tests in the fix cover a Contributor target, and one unit test covers a Super Editor. I ran Ghost's test harness on SQLite, not a production install on MySQL. I did not re-run my test on 6.64.0.
The fix
Ghost fixed it in 6.64.0, released 2026-09-15, in commit 99c5642, "Fixed user role edit validation". The condition now reads, at v6.64.0 line 1039:
} else if (roleId !== contextRoleId || editedUserId !== context.user) { For anyone other than the Owner, who has a separate branch earlier in the function, assigning a role to another user now always goes through the assign check, your own role included. The one exception left is a user editing their own profile with their role unchanged. My test change was broader: it had no exception. Reading the code, it would also have refused an Editor saving their own profile when the request echoes back their role. The commit adds tests for Editors promoting Authors and Contributors, a Super Editor promoting an Author, and that self-edit case.
Takeaways
- A shortcut keyed on "same role as mine" assumes I'm allowed to give out my own role. True for an Administrator, false for an Editor. If a permission check has a fast path, write down which roles it holds for.
- When one rule lives in several places, test all of them against one table.
role.jsandinvite.jsboth said Editors assign Author and Contributor only. The edit path agreed only when the role differed from the caller's. A test that tries every caller role against every target role, through invite and through edit, shows the gap. - Keep the exception as narrow as the case that needs it. On the non-Owner path, the fix skips the check only for a self-edit that leaves the role unchanged.
Disclosure timeline (UTC)
- 2026-09-05: reproduced locally on
v6.62.0(synthetic data). - 2026-09-08: I reported it privately through GitHub, one of four reporters the advisory thanks.
- 2026-09-14: fix merged (
99c5642). - 2026-09-15: Ghost 6.64.0 released.
- 2026-09-17: advisory GHSA-4pvx-fwjj-8gpc published, thanking four reporters.
- 2026-10-02: CVE-2026-104412 published (VulnCheck).
- 2026-10-05: CVE-2026-105678 published (GitHub).
Credit / CTA
If you run Ghost, upgrade to 6.64.0 or later (the latest is 6.75.0).
If you review role-based access in a CMS or admin panel, try this: for each role, list the roles it may grant, then try granting each one through every path that can change a role. Any path that answers differently from the others is worth a look.
Ghost's security policy asks for reports at security@ghost.org. The advisory thanks four reporters: SecBuddyF of Tencent KeenLab, Pedro Pinho, Nhat Anh Vu and me. Comments and corrections welcome.
References
- Ghost security advisory GHSA-4pvx-fwjj-8gpc, published 2026-09-17. https://github.com/TryGhost/Ghost/security/advisories/GHSA-4pvx-fwjj-8gpc
- CVE-2026-105678, assigned by GitHub. https://www.cve.org/CVERecord?id=CVE-2026-105678
- CVE-2026-104412, assigned by VulnCheck. https://www.cve.org/CVERecord?id=CVE-2026-104412
- Fix commit
99c5642, "Fixed user role edit validation (#30764)". https://github.com/TryGhost/Ghost/commit/99c5642b9bb481df4811a004a6f19d6143ed9aaf -
user.jsatv6.62.0(before the fix). https://github.com/TryGhost/Ghost/blob/v6.62.0/ghost/core/core/server/models/user.js -
user.jsatv6.64.0(after the fix). https://github.com/TryGhost/Ghost/blob/v6.64.0/ghost/core/core/server/models/user.js - Ghost docs, Staff users: roles and permissions. https://docs.ghost.org/staff
- MITRE CWE-269, Improper Privilege Management. https://cwe.mitre.org/data/definitions/269.html
Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala

