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:
- Paths that escape the sandbox via relative traversal (
../../../etc/passwd) - 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 incssAssetTargets. - 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 withpath.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
dsDirargument passed to theimport-design-system.mjsagent, and URL values parsed from CSS files processed bycssAssetTargets - Sink:
path.posix.normalize(path.posix.join(dir, u))at line 96 ofskills/baoyu-design/agents/import-design-system.mjs, whose result is pushed into theoutarray 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.