CVE-2026-63669
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
ApostropheCMS is an open-source Node.js content management system. Prior to 4.32.0, the page module's move() operation fails to enforce the destination parent's _create permission because its oldParent archive condition disables the check for ordinary moves, allowing an authenticated editor or contributor to use _targetId and _position through the page REST update endpoint to move a controlled page into a restricted subtree and make nudgeNewPeers() updateMany re-rank protected sibling pages. This issue is fixed in version 4.32.0.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- Low
- User interaction
- None
What an attacker can affect
- Confidentiality
- None
- Integrity
- High
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
6.5
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
ApostropheCMS is an open-source Node.js content management system. Prior to 4.32.0, the page module's move() operation fails to enforce the destination parent's _create permission because its oldParent archive condition disables the check for ordinary moves, allowing an authenticated editor or contributor to use _targetId and _position through the page REST update endpoint to move a controlled page into a restricted subtree and make nudgeNewPeers() updateMany re-rank protected sibling pages. This issue is fixed in version 4.32.0.
- Record
CVE-2026-63669- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Weakness
- CWE-639, CWE-862
- Updated
- 2026-11-02
EUVDEUVD-2026-60438
6.5
ApostropheCMS is an open-source Node.js content management system. Prior to 4.32.0, the page module's move() operation fails to enforce the destination parent's _create permission because its oldParent archive condition disables the check for ordinary moves, allowing an authenticated editor or contributor to use _targetId and _position through the page REST update endpoint to move a controlled page into a restricted subtree and make nudgeNewPeers() updateMany re-rank protected sibling pages. This issue is fixed in version 4.32.0.
- Record
EUVD-2026-60438- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-wr5r-wqp2-x4fh
6.5
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
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,…
- Record
GHSA-wr5r-wqp2-x4fh- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Weakness
- CWE-639, CWE-862
- Updated
- 2026-11-02
NVD
6.5
ApostropheCMS is an open-source Node.js content management system. Prior to 4.32.0, the page module's move() operation fails to enforce the destination parent's _create permission because its oldParent archive condition disables the check for ordinary moves, allowing an authenticated editor or contributor to use _targetId and _position through the page REST update endpoint to move a controlled page into a restricted subtree and make nudgeNewPeers() updateMany re-rank protected sibling pages. This issue is fixed in version 4.32.0.
- Record
CVE-2026-63669- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Weakness
- CWE-639, CWE-862
- Updated
- 2026-11-02
OSV
6.5
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
ApostropheCMS is an open-source Node.js content management system. Prior to 4.32.0, the page module's move() operation fails to enforce the destination parent's _create permission because its oldParent archive condition disables the check for ordinary moves, allowing an authenticated editor or contributor to use _targetId and _position through the page REST update endpoint to move a controlled page into a restricted subtree and make nudgeNewPeers() updateMany re-rank protected sibling pages. This issue is fixed in version 4.32.0.
- Record
CVE-2026-63669- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Weakness
- CWE-639, CWE-862
- Updated
- 2026-11-02
OSVGHSA-wr5r-wqp2-x4fh
6.5
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
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,…
- Record
GHSA-wr5r-wqp2-x4fh- CVSS 3.1
- 6.5 Medium
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N - Weakness
- CWE-639, CWE-862
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
apostrophenpm |
through 4.31.0 | 4.32.0 |
apostrophenpm |
before 4.32.0 | 4.32.0 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-63669 is a medium vulnerability (CVSS 6.5) in Apostrophecms Apostrophe, published August 17, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.31%, the 24th percentile. Fixed in apostrophe 4.32.0.
References
github.com/apostrophecms/apostrophe/security/advisories/GHSA-wr5r-wqp2-x4fh
github.com/apostrophecms/apostrophe/commit/d50c6ad61b9c1788958752358f1fca714cc8368c
nvd.nist.gov/vuln/detail/CVE-2026-63669
github.com/advisories/GHSA-wr5r-wqp2-x4fh
github.com/CVEProject/cvelistV5/tree/main/cves/2026/63xxx/CVE-2026-63669.json
github.com/apostrophecms/apostrophe
Left out here: the rest of 2 source descriptions. The JSON record has them.