Back to Blog
critical SEVERITY8 min read

How Path Traversal happens in Node.js FTP clients and how to fix it

A critical path traversal vulnerability (CVE-2026-27699) in the `basic-ftp` npm package (version 5.0.5) allowed attackers to overwrite arbitrary files on the host system by crafting malicious FTP server responses containing directory traversal sequences. The fix upgrades `basic-ftp` to version 5.3.1 and pins the dependency via a `package.json` override to ensure the patched version is used throughout the dependency tree.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

CVE-2026-27699 is a critical path traversal vulnerability (CWE-22) in the `basic-ftp` npm package versions prior to 5.2.0, affecting Node.js applications that perform FTP downloads. A malicious FTP server can return filenames containing `../` sequences, causing `basic-ftp` to write files outside the intended download directory and overwrite arbitrary files on the host. The fix is to upgrade `basic-ftp` to version 5.3.1 (or at minimum 5.2.0) and add a `package.json` override to pin the patched version across the entire dependency tree.

Vulnerability at a Glance

cweCWE-22
fixUpgraded basic-ftp from 5.0.5 to 5.3.1 and pinned the version with a package.json override
riskArbitrary file overwrite on the host system via a malicious FTP server response
languageJavaScript / Node.js
root causebasic-ftp 5.0.5 did not sanitize server-supplied filenames before writing files to the local filesystem
vulnerabilityPath Traversal / File Overwrite

How Path Traversal Happens in Node.js FTP Clients and How to Fix It

The Incident: A Malicious FTP Server Can Overwrite Your Files

In the browserbase project, the automated security scanner Trivy flagged a critical vulnerability in package-lock.json: the project was depending on basic-ftp version 5.0.5, which is affected by CVE-2026-27699 — a file overwrite vulnerability caused by insufficient path sanitization during FTP downloads.

This is not a theoretical edge case. Any application that uses basic-ftp to download files from an FTP server it does not fully control is potentially exposed. The fix — upgrading to 5.3.1 and adding a package.json override — was straightforward, but understanding why the old version was dangerous is essential for any Node.js developer working with file I/O or remote data sources.


The Vulnerability Explained

What Is Path Traversal?

Path traversal (CWE-22) happens when an application constructs a filesystem path using data it received from an external source — a user, a server, a network response — without first sanitizing that data. The classic attack payload is the ../ sequence, which instructs the operating system to step up one directory level. Chain enough of them together and you can escape any intended directory.

In the context of an FTP client, the "external source" is the FTP server itself. When a client downloads a directory listing or a file, the server supplies the filenames. If the client trusts those filenames verbatim and writes them to disk, a malicious server can supply a name like:

../../../etc/cron.d/backdoor

And the client will dutifully write the file there — potentially overwriting a critical system file.

The Vulnerable Version: basic-ftp 5.0.5

The vulnerable dependency was locked at version 5.0.5 in package-lock.json:

// BEFORE (vulnerable)
"node_modules/basic-ftp": {
  "version": "5.0.5",
  "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
  "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
  "license": "MIT",
  "engines": {
    "node": ">=10.0.0"
  }
}

In basic-ftp 5.0.5, when downloading a remote directory recursively, the library constructs local file paths by joining the target download directory with the filename returned by the FTP server's LIST or MLSD command. The critical flaw is that the server-supplied filename was not checked for traversal sequences before being passed to the filesystem write operation.

Conceptually, the vulnerable pattern looked like this:

// Pseudocode representing the vulnerable behavior in basic-ftp 5.0.5
async function downloadFile(remoteFilename, localDir) {
  // remoteFilename comes directly from the FTP server's directory listing
  const localPath = path.join(localDir, remoteFilename); // ⚠️ NOT sanitized
  await fs.writeFile(localPath, fileContents);           // ⚠️ writes to attacker-controlled path
}

The path.join() call does collapse some traversal attempts, but it does not prevent all of them — and critically, it does not verify that the resolved path remains within localDir. A filename like ../../../../home/user/.ssh/authorized_keys will be resolved by path.join to a path that escapes the intended directory entirely.

A Concrete Attack Scenario

Imagine the browserbase application uses basic-ftp to connect to an external FTP server and download configuration files or assets. An attacker who controls that FTP server (or who can perform a man-in-the-middle attack on an unencrypted FTP connection) crafts a directory listing response containing:

-rw-r--r-- 1 ftp ftp 512 Jan 01 00:00 ../../../app/server.js

When basic-ftp 5.0.5 processes this listing and downloads the "file," it writes attacker-controlled content to ../../../app/server.js — potentially replacing the application's own server code with a backdoored version. On the next application restart, the attacker's code runs with full application privileges.

The severity rating of CRITICAL is well-earned: this is an unauthenticated, remote file overwrite primitive.


The Fix

Upgrading to basic-ftp 5.3.1

The fix involved two coordinated changes: updating the resolved package version in package-lock.json and adding a version override in package.json to ensure the pinned version propagates throughout the entire dependency tree.

package-lock.json — before and after:

// BEFORE (vulnerable)
"node_modules/basic-ftp": {
  "version": "5.0.5",
  "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
  "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="
}

// AFTER (patched)
"node_modules/basic-ftp": {
  "version": "5.3.1",
  "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
  "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="
}

package.json — the override addition:

// BEFORE
{
  "dependencies": {
    "@hyperbrowser/sdk": "^0.78.0",
    "dotenv": "^16.4.7",
    "puppeteer-core": "^24.31.0"
  }
}

// AFTER
{
  "dependencies": {
    "@hyperbrowser/sdk": "^0.78.0",
    "dotenv": "^16.4.7",
    "puppeteer-core": "^24.31.0"
  },
  "overrides": {
    "basic-ftp": "5.3.1"
  }
}

Why Two Files Had to Change

The package-lock.json change updates the resolved version that npm ci will actually install. But package-lock.json alone is not enough — if another dependency in the tree declares basic-ftp as a transitive dependency with a range that resolves to 5.0.5, npm could re-lock it to the vulnerable version on the next npm install. The "overrides" field in package.json is a hard instruction to npm: regardless of what any transitive dependency requests, always use basic-ftp 5.3.1. This two-file approach closes both the immediate and the future exposure.

What Changed Inside basic-ftp

In the patched versions (5.2.0+), basic-ftp validates that every server-supplied filename, after path resolution, remains within the intended local download directory. The fix introduces a check equivalent to:

// Representative of the fix introduced in basic-ftp 5.2.0+
function safePath(localDir, remoteFilename) {
  const resolved = path.resolve(localDir, remoteFilename);
  if (!resolved.startsWith(path.resolve(localDir) + path.sep)) {
    throw new Error(`Path traversal detected: ${remoteFilename}`);
  }
  return resolved;
}

This pattern — resolve first, then prefix-check — is the canonical defense against path traversal. It handles all forms of traversal including ../, URL-encoded variants, and null byte injection.


Key Takeaways

  • basic-ftp 5.0.5 trusted server-supplied filenames verbatim — any application downloading files from an untrusted FTP server was vulnerable to arbitrary file overwrite.
  • path.join() is not a security control — it normalizes paths but does not prevent traversal out of a base directory. Always follow with a startsWith check on the resolved absolute path.
  • Updating package-lock.json is not enough — the "overrides" field in package.json is required to prevent npm from re-resolving the vulnerable version through transitive dependencies.
  • FTP is an inherently untrusted protocol — treat every piece of data returned by an FTP server, including filenames in directory listings, as potentially hostile input.
  • Trivy caught this before it reached production — static dependency scanning in CI is a cost-effective first line of defense against known-CVE supply chain risk.

How Orbis AppSec Detected This

  • Source: Filenames returned by a remote FTP server in directory listing responses (LIST/MLSD commands), which are entirely server-controlled and received over the network.
  • Sink: The filesystem write operation inside basic-ftp's recursive download logic, where the server-supplied filename was joined with the local target directory and passed directly to Node.js file I/O APIs.
  • Missing control: No validation that the resolved local path remained within the intended download directory — specifically, no path.resolve() + startsWith(baseDir) guard before writing.
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • Fix: Upgraded basic-ftp from 5.0.5 to 5.3.1 in package-lock.json and added an "overrides" entry in package.json to pin the patched version across the full dependency tree.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

CVE-2026-27699 is a reminder that security vulnerabilities don't always live in the code you write — they hide in the dependencies you pull in and trust implicitly. A single unsanitized filename in an FTP client library became a critical file overwrite primitive that could compromise an entire host. The fix was a one-line version bump plus a dependency override, but the underlying lesson is durable: never trust externally supplied filenames, always canonicalize paths before writing, and keep your dependency scanner running in CI. Catching this class of issue automatically, before it ships, is exactly what supply chain security tooling is designed to do.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #8

Related Articles

high

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.