Every CVE Search/ Get API key

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

6.5 MediumFix available

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

0.31%Up 0.1 points since Aug 18, 2026
90%50%10%1%

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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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 MediumCVSS: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

Affected packages
PackageAffectedFixed
apostrophe
npm
through 4.31.0
4.32.0
apostrophe
npm
before 4.32.0
4.32.0

History

History
RecordedChangedSources
first indexedCVE 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

This page doesn't list everything

Left out here: the rest of 2 source descriptions. The JSON record has them.

MoveEnter Open