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()orrequire()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 (
.jsin this case) — either check alone leaves a gap the other closes. - CLI scripts that take file paths from
process.argvare 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 intotargetFileat 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.jsextension 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.