GitHub Advisoryadvisory · npm
GHSA-wr5r-wqp2-x4fh ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
MEDIUM
Official page ↗
- Aliases
- CVE-2026-63669
- Ecosystem
- npm
- Packages
- apostrophe
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
- Weaknesses
- CWE-639, CWE-862
- Reviewed
- 2026-09-03
Affected packages
• npm apostrophe: < 4.32.0 → fixed in 4.32.0
Description
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) // 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/apostrophe…
Related
Alias · CVE (1)
- CVE-2026-63669 ApostropheCMS is an open-source Node.js content management system. vulnerability