Summary
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
Advisory details
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:
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():
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:
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:
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:
// 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
updateManyto 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.
# 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:
References
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-63735
SurrealDB: Custom API route lets authenticated callers override namespace/database scope via URL path
- HIGHCVE-2026-81892
EasyAdmin custom-action dispatcher bypasses access_control on other routes
- MEDIUMCVE-2026-54746
Hatchet allows cross-tenant write/DoS to other tenants' workers via Dispatcher gRPC UpsertWorkerLabels and Unsubscribe
- MEDIUMCVE-2026-61663
django CMS: Missing authorization in `render_object_structure` discloses non-PageContent placeholder structure to low-privileged staff
- MEDIUMCVE-2026-63003
django CMS: Broken access control in page *Duplicate* allows reading the content of any page (cross-site / restriction bypass)
- MEDIUMCVE-2026-59992
Tina: Broken Access Control: arbitrary bucket-key write/delete in `next-tinacms-s3` (and sibling production media adapters)