Safe Path Containment on Windows in Node.js: Avoid startsWith Checks
Checking whether a file path stays inside a root directory looks simple:
filePath.startsWith(rootPath)
On Windows, that is not enough.
Windows path rules include drive-relative paths, UNC paths, device paths, case-insensitive drives, and .. traversal. A string prefix check can approve the wrong path or reject a valid one.
The safer pattern
Use path.resolve() and path.relative():
const path = require("path");
function isSafeInRoot(filePath, rootPath) {
if (typeof filePath !== "string" || typeof rootPath !== "string") {
return false;
}
if (/^[a-zA-Z]:[^/\\]/.test(filePath)) {
return false; // drive-relative, e.g. C:foo
}
if (/^\\\\/.test(filePath) || /^\/\//.test(filePath)) {
return false; // UNC or device-style input
}
const resolvedFile = path.resolve(filePath);
const resolvedRoot = path.resolve(rootPath);
const relative = path.relative(resolvedRoot, resolvedFile);
return relative !== "" &&
!relative.startsWith("..") &&
!path.isAbsolute(relative);
}
Depending on your use case, you may want relative === "" to be allowed. For file upload checks, rejecting the root itself is often simpler.
Why startsWith fails
Consider these cases:
<root>\safe
<root>\safe-old
The second path starts with the same string but is not inside the first directory.
Now add Windows-specific forms:
<drive-relative>foo
\\server\share\file.txt
\\?\<root>\safe\file.txt
<root>\safe\..\secret.txt
You need canonical path comparison, not raw string comparison.
Use it at the boundary
Validate paths as close as possible to input:
- HTTP upload target
- CLI argument
- IPC command
- file picker result
- plugin API call
Do not wait until a later fallback branch. If the input is outside the allowed root, reject it before reading or writing.
Summary
For Windows path containment, avoid startsWith.
Resolve both paths, compute the relative path from root to target, and reject anything absolute or starting with ... Also reject drive-relative and UNC/device-style input before resolution.