Back to Blog
critical SEVERITY5 min read

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

O
By Orbis AppSec
•Published September 30, 2026•Reviewed September 30, 2026

Answer Summary

The affected component is a first-party Node.js CLI script that validates expression definitions by dynamically importing a target `.js` file supplied on the command line. An attacker who controls the file path argument could use `../` traversal sequences (or an absolute path like `/etc/shadow`) to make the script's `import()` call load and execute a file outside the intended working directory, achieving arbitrary file read and code execution. The fix constrains the resolved path with `path.relative()` against the current working directory and requires a `.js` extension before the import proceeds; no package version applies since this is first-party code. This maps to CWE-22 (Path Traversal).

Vulnerability at a Glance

cweCWE-22
fixValidate the resolved path stays under process.cwd() and ends in .js before importing it
riskArbitrary file read / code execution via dynamic import() of an attacker-controlled path
languageJavaScript (Node.js)
root causeprocess.argv[2] resolved with path.resolve() and passed to import() without a containment check
vulnerabilityPath Traversal

Summary

A CLI script used to validate expression definitions took a file path directly from process.argv[2] and passed it into a dynamic import() call after resolving it with path.resolve(). Because path.resolve() doesn't stop ../ sequences from escaping the intended directory, and import() executes whatever module it's given, an attacker who controlled that argument could make the script load and run arbitrary .js files — or attempt to read arbitrary files entirely — from anywhere on the filesystem. The fix adds an explicit containment check and an extension allowlist before the import is ever attempted.

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code) — see fix commit described below
Ecosystem npm
CVE / GHSA not assigned
CWE CWE-22 (Path Traversal)

Since this is first-party CLI tooling rather than a published package, there's no version range to check against. The relevant question is whether your copy of the script still contains the unguarded path.resolve() → import() pattern described below.

The Vulnerability Explained

The script's CLI entrypoint reads a target file argument, resolves it to an absolute path, and dynamically imports it to pull out an expression definition for validation:

const absPath = path.resolve(process.cwd(), targetFile);
import(`file://${absPath}`).then(mod => {
  const exp = mod.EXPRESSION_DEFINITION || mod.default || mod;
  const res = validateExpression(exp);

The problem is that targetFile comes straight from process.argv[2] with no validation, and path.resolve() is designed to normalize .. segments into a fully resolved absolute path — it will not refuse to walk outside process.cwd(). Whatever the resolved path points to, import() will load it as an ES module and execute its top-level code.

That means an invocation like:

node validate_expression.js '../../../etc/passwd'

or

node validate_expression.js '/etc/shadow'

resolves to an absolute path far outside the working directory before it's handed to import(). For text files that aren't valid JavaScript, the module loader will typically throw a parse error — but that error can still leak information about file existence and content through stack traces or logging, and if the attacker instead points at a real .js file elsewhere on disk (a script dropped by another process, a temp file, a config bundled with unrelated tooling), the script will execute it with the same privileges as the validator itself. Any wrapper, automation pipeline, or agent tooling that forwards a file path to this CLI without first constraining it inherits that arbitrary-execution risk.

The Fix

The patch introduces a containment check between path resolution and the dynamic import, plus an extension allowlist:

const baseDir = process.cwd();
const absPath = path.resolve(baseDir, targetFile);
const relToBase = path.relative(baseDir, absPath);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase) || !absPath.endsWith('.js')) {
  console.error('❌ Invalid target file: must be a .js file within the current working directory');
  process.exit(1);
}

path.relative(baseDir, absPath) computes how you'd get from the working directory to the resolved target. If that relative path starts with .., the target escaped baseDir through traversal. If it's still absolute, path.relative() couldn't express it as a subpath at all (this happens on Windows when drives differ, for example). Either condition means the target is outside the trusted boundary and the script exits with process.exit(1) before import() is ever reached. The !absPath.endsWith('.js') check closes a secondary gap: even a path that stays inside the working directory shouldn't be importable if it isn't actually a JavaScript module, which limits the blast radius if a non-.js file somehow ends up inside the allowed directory.

Both checks are necessary together — the boundary check alone wouldn't stop someone from targeting an arbitrary non-JS file that happens to live under process.cwd(), and the extension check alone wouldn't stop traversal to a .js file living outside the working directory.

Key Takeaways

  • path.resolve() normalizes ../ sequences into a valid absolute path — it does not enforce any boundary, so resolving a path is not the same as validating it.
  • Piping a resolved path straight into import() or require() means arbitrary code execution, not just arbitrary file read, if the target is a valid JavaScript file.
  • Compare with path.relative(baseDir, absPath) and check for a leading .. or an absolute result to detect traversal after resolution.
  • Pair the path-boundary check with a content-type or extension allowlist (.js in this case) — either check alone leaves a gap the other closes.
  • CLI scripts that take file paths from process.argv are still attacker-facing wherever the invocation itself is automatable, scriptable, or forwarded by another tool.

How Orbis AppSec Detected This

  • Source: process.argv[2], read into targetFile at the CLI entrypoint.
  • Sink: the dynamic import(\file://${absPath}`)` call that loads and executes the resolved module.
  • Missing control: no check that the resolved path stayed within process.cwd(), and no restriction on file extension, before the import executed.
  • CWE: CWE-22 (Path Traversal).
  • Fix: added a path.relative()-based boundary check plus a .js extension requirement, rejecting the target and exiting before any import is attempted.

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

The bug here wasn't exotic — it was the classic gap between "resolved" and "validated." path.resolve() will cheerfully turn ../../../etc/passwd into a clean absolute path, and a dynamic import() will just as cheerfully execute whatever that path points to. The fix closes the gap with two cheap, explicit checks: confirm the resolved path stays under the working directory using path.relative(), and confirm it ends in .js before it's ever passed to import(). Any CLI tool that accepts a file path argument and eventually loads or executes it should apply the same pattern — resolve, then verify containment, then act.

Prevention and further reading

Frequently Asked Questions

Does the fix change how the CLI script's targetFile argument is meant to be used?

No — it still accepts a relative path on `process.argv[2]`, but the path must now resolve to a `.js` file located under `process.cwd()`, or the script exits with an error instead of importing it.

Would passing an absolute path like `/etc/shadow` still work after the fix?

No. `path.relative(baseDir, absPath)` on an absolute path outside the working directory either starts with `..` or remains absolute, both of which now trigger the rejection branch before `import()` is called.

Does the `.js` extension check alone stop traversal, or is the `path.relative()` check load-bearing too?

Both are required — the extension check blocks pointing at non-JS files, but without the `path.relative()`/`startsWith('..')` check an attacker could still traverse to any `.js` file anywhere on disk and have it executed.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2

Related Articles

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.

high

SHACL Viewer Path Traversal in graph3d(): Unvalidated `path`

The `graph3d()` and `graph2d()` request handlers in SHACL Viewer directly concatenated user-supplied `path` parameters into filesystem paths, enabling directory traversal outside the intended `/shapes/` directory. The fix introduces `_resolve_shapes_path()` with `os.path.realpath()` validation to enforce containment within the shapes directory.

critical

Slim CLI Unverified Remote Fetch in Version Check

The Slim CLI's version check command fetched remote package metadata without integrity verification, enabling attackers to serve malicious responses through repository hijacking or man-in-the-middle attacks. The fix adds input validation, HTTP status checking, and response schema verification to ensure only legitimate version data is processed.