Back to Blog
critical SEVERITY7 min read

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

A path traversal vulnerability in `skills/baoyu-design/agents/import-design-system.mjs` allowed attackers to escape the intended design system directory by supplying absolute paths, bypassing a guard that only checked for `..` prefixes. The fix adds an `isAbsolute()` check alongside the existing relative-path guard, closing the bypass with a single targeted change. This matters because the `dsDir` argument is user-controlled, meaning any caller of the script could redirect file operations to sen

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a path traversal vulnerability (CWE-22) in a Node.js `.mjs` agent (`import-design-system.mjs`). The `cssAssetTargets` function only checked whether normalized paths started with `..`, but an attacker could bypass this guard by supplying an absolute path (e.g., `/etc/passwd`). The fix adds `path.posix.isAbsolute(relToDs)` to the guard condition, so both relative escapes and absolute paths are rejected. Any Node.js code that normalizes user-supplied paths must check for both relative traversal sequences and absolute path injection.

Vulnerability at a Glance

cweCWE-22
fixAdded `path.posix.isAbsolute(relToDs)` to the skip condition on line 96
riskAttacker reads or overwrites arbitrary files on the server by controlling the design system source directory
languageJavaScript (Node.js / ESM)
root causePath guard in `cssAssetTargets` only rejected `..`-prefixed paths, not absolute paths
vulnerabilityPath Traversal

The File That Imports Design Systems — and Almost Exported Sensitive Files

The skills/baoyu-design/agents/import-design-system.mjs file is responsible for pulling CSS assets from a design system directory and copying them into a project. It's the kind of utility script that lives quietly in a monorepo, rarely reviewed for security because it "just moves files around." But a subtle flaw in its path-validation logic meant that an attacker who could control the dsDir argument — or craft a malicious CSS asset reference — could point the script at any directory on the filesystem.

Orbis AppSec's multi-agent AI scanner flagged this as a high-severity path traversal (CWE-22) and automatically opened a pull request with a one-line fix. Here's exactly what went wrong, how it could be exploited, and what the fix does.


The Vulnerability Explained

What cssAssetTargets Does

The cssAssetTargets function (around line 93) parses CSS files for referenced assets — fonts, images, icons — and returns the relative paths that need to be copied alongside the CSS. The logic looks roughly like this:

// VULNERABLE — before the fix
function cssAssetTargets(cssRel) {
  // ...
  for (const u of urls) {
    u = u.replace(/[?#].*$/, ''); // strip ?v=… / #iefix
    if (!u) continue;
    const relToDs = path.posix.normalize(path.posix.join(dir, u));
    if (relToDs.startsWith('..')) continue; // outside the DS folder — skip
    out.push(relToDs);
  }
  return out;
}

The intent is clear: normalize the joined path and skip anything that escapes the design system folder via ... This is a reasonable first instinct, but it only covers one of the two ways a path can escape its sandbox.

The Bypass: Absolute Paths

path.posix.normalize has a well-known behavior — it does not strip a leading /. If a URL reference inside a CSS file resolves to an absolute path after normalization (e.g., /etc/fonts/custom.ttf, or if dir itself is absolute and u is crafted to stay absolute), the startsWith('..') guard is completely silent. The path does not start with .., so it passes the check and lands in out.

The second, more critical vector is the dsDir argument itself. The PR description confirms it: dsDir is user-controlled without validation. If an attacker can invoke the script with dsDir=/etc or dsDir=/home/user/.ssh, the entire directory traversal logic operates on a sensitive base directory from the start.

Concrete Attack Scenario

Imagine a CI/CD pipeline or a developer tool that calls:

node import-design-system.mjs --dsDir /path/to/design-system

If the --dsDir flag is sourced from user input (a config file, a UI field, an API parameter), an attacker could substitute:

node import-design-system.mjs --dsDir /etc

Or, within a CSS file that the script processes, include a reference like:

@font-face {
  src: url('/etc/passwd');
}

After path.posix.normalize(path.posix.join(dir, '/etc/passwd')), the result is /etc/passwd — an absolute path that does not start with .. — so the old guard lets it through. The script then attempts to read and copy /etc/passwd as a font asset.

Real-World Impact

This is a Node.js library used in a design tooling context. Downstream consumers who automate design system imports — especially in server-side or CI environments — are at risk of:

  • Arbitrary file read: sensitive configuration files, SSH keys, environment files
  • Potential file overwrite: depending on how the copy destination is constructed
  • Supply-chain risk: if the design system source is fetched from an external or untrusted location, malicious CSS could trigger the traversal automatically

The Fix

The fix is surgical: a single additional condition added to the existing guard on line 96.

Before

if (relToDs.startsWith('..')) continue; // outside the DS folder — skip

After

if (relToDs.startsWith('..') || path.posix.isAbsolute(relToDs)) continue; // outside the DS folder — skip

What changed: path.posix.isAbsolute(relToDs) returns true for any path that begins with /. By short-circuiting on this condition, the function now rejects:

  1. Paths that escape the sandbox via relative traversal (../../../etc/passwd)
  2. Paths that are absolute from the start (/etc/passwd, /var/secrets/key)

The comment is preserved and extended implicitly — the guard now correctly enforces "outside the DS folder" for both escape vectors.

Why This Is the Right Fix

The fix operates on the already-normalized value of relToDs, so there's no risk of encoding tricks slipping through before the check. path.posix.isAbsolute is a stable, well-tested Node.js built-in with no edge cases around URL encoding or platform differences (since path.posix is used explicitly, not path — ensuring consistent /-based behavior regardless of the host OS).


Prevention & Best Practices

1. Always Validate Both Relative and Absolute Escapes

A path guard that only checks startsWith('..') is incomplete. The canonical Node.js pattern for confining paths is:

const safeBase = path.resolve('/allowed/base/dir');
const candidate = path.resolve(safeBase, userInput);

if (!candidate.startsWith(safeBase + path.sep)) {
  throw new Error('Path traversal detected');
}

This approach resolves symlinks and handles both .. sequences and absolute overrides in one check.

2. Validate User-Controlled Root Arguments

The dsDir argument is the true root of this vulnerability. Before using any user-supplied directory as a base path, validate it against an allowlist of permitted roots or confirm it resolves within an expected parent:

const allowedRoot = path.resolve(process.env.DESIGN_SYSTEMS_ROOT || './design-systems');
const resolvedDsDir = path.resolve(dsDir);

if (!resolvedDsDir.startsWith(allowedRoot)) {
  throw new Error(`dsDir must be within ${allowedRoot}`);
}

3. Use OWASP's Path Traversal Guidance

OWASP's File Upload and Path Traversal cheat sheets recommend canonicalizing paths before any comparison and never trusting client-supplied path components. See OWASP Path Traversal.

4. Lint for Unsafe Path Patterns with Semgrep

A Semgrep rule targeting path.join or path.posix.join with user-controlled arguments and missing isAbsolute/startsWith guards can catch this class of issue at code-review time:

# Simplified Semgrep pattern concept
pattern: |
  path.posix.normalize(path.posix.join($DIR, $USER_INPUT))

Search existing rules at: https://semgrep.dev/r?q=path-traversal

5. CWE-22 Reference

This vulnerability is catalogued as CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). Reviewing the CWE entry's observed examples is a useful exercise for anyone writing file-handling utilities.


Key Takeaways

  • Checking startsWith('..') alone is not a complete path traversal guard — absolute paths bypass it entirely, as demonstrated in cssAssetTargets.
  • User-controlled root arguments (dsDir) are high-value attack targets — validate them against an allowlist or a resolved base directory before any file operations.
  • path.posix.isAbsolute() is the missing half of the guard — always pair relative-escape checks with an absolute-path check when working with path.posix.
  • Design tooling scripts are not exempt from security review — utilities that "just move files" can read or overwrite sensitive data if their path logic is flawed.
  • The fix was one line — security improvements don't have to be complex; the key is knowing which check is missing.

How Orbis AppSec Detected This

  • Source: The dsDir argument passed to the import-design-system.mjs agent, and URL values parsed from CSS files processed by cssAssetTargets
  • Sink: path.posix.normalize(path.posix.join(dir, u)) at line 96 of skills/baoyu-design/agents/import-design-system.mjs, whose result is pushed into the out array and used in subsequent file-copy operations
  • Missing control: No check for absolute paths after normalization — path.posix.isAbsolute(relToDs) was absent from the guard condition
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • Fix: Added || path.posix.isAbsolute(relToDs) to the existing skip condition, ensuring absolute paths are rejected alongside relative traversal sequences

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

Path traversal vulnerabilities are deceptively easy to introduce and deceptively easy to under-fix. The guard in cssAssetTargets was well-intentioned — it blocked the most obvious ..-based escape — but missed the equally dangerous absolute-path vector. The lesson from this specific fix is that path confinement requires checking all escape routes, not just the most familiar one.

If your codebase has any utility that joins a user-controlled string with path.join or path.posix.join, take five minutes to audit its guard. Ask: does this check block .. sequences? Does it also block absolute paths? Does it validate the root argument itself? If the answer to any of those is "no," you may have a CWE-22 waiting to be found.


References

Frequently Asked Questions

What is path traversal?

Path traversal (CWE-22) is a vulnerability where insufficient validation of file paths allows an attacker to access files and directories outside the intended scope, often by injecting `../` sequences or absolute paths.

How do you prevent path traversal in Node.js?

Always normalize user-supplied paths with `path.normalize()` or `path.posix.normalize()`, then verify the result neither starts with `..` nor is an absolute path. For extra safety, resolve the full path and confirm it starts with the expected base directory.

What CWE is path traversal?

Path traversal is classified as CWE-22: Improper Limitation of a Pathname to a Restricted Directory.

Is checking for `..` enough to prevent path traversal?

No. Checking only for `..` misses absolute paths (e.g., `/etc/passwd`) and some encoded or platform-specific traversal patterns. Always combine relative-escape checks with an absolute-path check and, ideally, a full base-directory prefix assertion.

Can static analysis detect path traversal?

Yes. Tools like Semgrep, CodeQL, and multi-agent AI scanners (as used here) can trace tainted data from user-controlled inputs to file-system sinks and flag missing validation steps.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #15

Related Articles

critical

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

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr

high

How Path Traversal happens in Python Flask apps and how to fix it

A path traversal vulnerability in `WAVE2SCORE/app.py` allowed attackers to supply a crafted file path that escaped the application's intended working directory, potentially enabling access to arbitrary files on the server. The fix resolves the absolute path and validates it against the expected `WORK_DIR` boundary before any processing occurs. This kind of boundary check is a critical safeguard in any application that processes user-supplied file paths.

high

How Arbitrary File Read happens in Python LangSmith SDK and how to fix it

A high-severity arbitrary server-side file read vulnerability (GHSA-f4xh-w4cj-qxq8) was discovered in LangSmith SDK's `TracingMiddleware`, affecting versions prior to 0.8.18. An attacker able to influence tracing requests could potentially read arbitrary files from the server's filesystem. Upgrading from version 0.8.15 to 0.8.18 in `poetry.lock` and `pyproject.toml` closes the attack surface entirely.

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

high

How Path Traversal happens in PostCSS Source Map Auto-Loading and how to fix it

A high-severity path traversal vulnerability in PostCSS versions before 8.5.18 allowed attackers to manipulate `sourceMappingURL` directives to load arbitrary `.map` files from the filesystem, potentially disclosing sensitive source code and build metadata. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `console/web/package-lock.json`, closing the path traversal vector in the source map auto-loading feature. This change protects applications that process untrusted CSS input through their Post

critical

How Command Injection happens in Node.js shell-quote and how to fix it

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of