agentggagentgg
Back to all findings
CRITICALconfirmedfile-upload-validationzip-slip-and-unsafe-filename3426b0a2baf7

ZIP entries extracted to disk using attacker-controlled paths (weak zip-slip guard)

handleZipFileUpload writes each archive entry to `uploads/complaints/<entry.path>` after a containment check that compares `absolutePath.includes(path.resolve('.'))`, which is bypassable and still allows writing anywhere under the project root.

Fileroutes/fileUpload.ts
Lines2756
Confidence
85%
File statusvalidated
Details
const fileName = entry.path
const absolutePath = path.resolve('uploads/complaints/' + fileName)
challengeUtils.solveIf(challenges.fileWriteChallenge, () => { return absolutePath === path.resolve('ftp/legal.md') })
if (absolutePath.includes(path.resolve('.'))) {
  entry.pipe(fs.createWriteStream('uploads/complaints/' + fileName).on('error', function (err) { next(err) }))
} else {
  entry.autodrain()
}

Problems:

  1. The containment check uses String.prototype.includes on the resolved absolute path. path.resolve('.') returns the current working directory (e.g. /app). An entry such as ../../../app/ftp/legal.md resolves to /app/ftp/legal.md, which contains /app and therefore passes the check — confirmed by the explicit fileWriteChallenge solver that detects exactly this overwrite of ftp/legal.md. The check is not equivalent to absolutePath.startsWith(path.resolve('uploads/complaints/') + path.sep).
  2. The original ZIP filename (file.originalname.toLowerCase()) is also used to name the tmp file in os.tmpdir() without sanitisation — a crafted originalname containing path separators can be written outside the temp directory on some platforms.
  3. There is no size limit on the uploaded ZIP, no entry-count limit, no compressed/uncompressed-ratio check — zip-bomb DoS is unmitigated.
  4. The middleware does not authenticate the caller.
Proof of concept

Create archive evil.zip containing one entry whose path is ../../../app/ftp/legal.md (or any sibling directory of uploads/complaints/). POST it to the upload endpoint. The containment check resolves to /app/ftp/legal.md, which still .includes('/app'), so the stream writes attacker content to ftp/legal.md, overwriting the served legal notice file. (This is the very behaviour the fileWriteChallenge solver in this code rewards.)

Impact

Unauthenticated attackers can overwrite arbitrary files inside the application root via zip-slip, leading to defacement or, when overwriting a file that is later executed/required, remote code execution. Also enables disk-fill DoS via zip bombs since no size or extraction-ratio limits exist.

Validation
confirmed

The check absolutePath.includes(path.resolve('.')) is a substring test against the cwd, so any path that resolves under the project root (e.g. ../../../app/ftp/legal.md/app/ftp/legal.md) passes and is then written via fs.createWriteStream('uploads/complaints/' + fileName), escaping the uploads directory. The in-code solver absolutePath === path.resolve('ftp/legal.md') confirms the exact zip-slip target. A proper guard would be absolutePath.startsWith(path.resolve('uploads/complaints/') + path.sep). Exploit chain is reachable from an unauthenticated multipart upload of a crafted zip.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Base score: 9.8 · CRITICAL

The handleZipFileUpload middleware is reachable over HTTP with no authentication check in this file (PR:N, AV:N, UI:N), and exploitation only requires crafting a ZIP with a ../../../app/... entry — the bypassable absolutePath.includes(path.resolve('.')) guard makes this trivial (AC:L), as confirmed by the built-in fileWriteChallenge solver that rewards overwriting ftp/legal.md. Worst plausible exploitation: writing attacker-controlled content to any file under the project root (e.g., a required JS module, config, or static asset) yields code execution within the same Node.js process, giving full read/write of application data and process disruption (C:H/I:H/A:H); the lack of size, entry-count, and ratio limits independently enables zip-bomb DoS reinforcing A:H. Scope stays Unchanged because the impact remains within the Node.js application's own security authority — there is no sandbox or cross-origin boundary being crossed by the file write itself.

References