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.
Prevention & Best Practices
1. Always Canonicalize Before Checking
Never rely on string matching alone to detect traversal sequences. Use path.resolve() to get the absolute, canonical path, then verify it starts with your intended base directory:
const path = require('path');
function isPathSafe(baseDir, userInput) {
const base = path.resolve(baseDir);
const target = path.resolve(baseDir, userInput);
return target.startsWith(base + path.sep) || target === base;
}
2. Treat All Remote Data as Untrusted
FTP server responses, HTTP headers, ZIP file entries, tar archive member names — any filename that originates outside your application boundary must be treated as hostile input. This is especially true for FTP, which has no built-in integrity protection on unencrypted connections.
3. Pin Transitive Dependencies
Use "overrides" (npm 8.3+), "resolutions" (Yarn), or "overrides" (pnpm) to pin the versions of security-sensitive transitive dependencies. Don't rely on semver ranges to automatically pull in security patches — lock the version explicitly.
// npm package.json
{
"overrides": {
"basic-ftp": "5.3.1"
}
}
4. Run Dependency Scanners in CI
Tools like Trivy, Snyk, and npm audit can catch known-vulnerable dependency versions before they reach production. Add them as a required CI step:
# In your CI pipeline
trivy fs --exit-code 1 --severity CRITICAL,HIGH .
5. Relevant Standards
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- OWASP A05:2021: Security Misconfiguration (includes insecure dependency management)
Key Takeaways
basic-ftp5.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 astartsWithcheck on the resolved absolute path.- Updating
package-lock.jsonis not enough — the"overrides"field inpackage.jsonis 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/MLSDcommands), 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-ftpfrom5.0.5to5.3.1inpackage-lock.jsonand added an"overrides"entry inpackage.jsonto 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.