GitHub Security Advisory: GHSA-cp6g-6699-wx9c
Package: vm2 (NPM)
Severity: HIGH (CVSS 8.5)
CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')
Vulnerable versions: = 3.10.5
Patched in: 3.11.0

Summary: vm2 has a NodeVM require.root bypass via symlink traversal that allows sandbox escape

Details:
## Summary
NodeVM's `require.root` path restriction can be bypassed using filesystem symlinks, allowing sandboxed code to load modules from outside the allowed root directory in host context. Because path validation uses `path.resolve()` (which does not dereference symlinks) but module loading uses Node's native `require()` (which does), an attacker can load arbitrary host-realm modules and achieve remote code execution.

## Severity
**High** (CVSS 3.1: 8.5)

`CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H`

- **Attack Vector:** Network — sandboxed code is typically received from external sources (user-submitted scripts, plugin code)
- **Attack Complexity:** High — requires symlinks inside the allowed root that point outside it; common with pnpm, npm workspaces, and npm link but not guaranteed in all deployments
- **Privileges Required:** Low — attacker needs only the ability to submit code to the sandbox, which is the intended use case
- **User Interaction:** None
- **Scope:** Changed — the vulnerability is in the sandbox boundary; impact is on the host system
- **Confidentiality Impact:** High — arbitrary file read via host command execution
- **Integrity Impact:** High — arbitrary command execution on the host
- **Availability Impact:** High — arbitrary command execution on the host

## Affected Component
- `lib/resolver-compat.js` — `CustomResolver.isPathAllowed()` (line 53-60)
- `lib/resolver-compat.js` — `CustomResolver.loadJS()` (line 62-66)
- `lib/filesystem.js` — `DefaultFileSystem.resolve()` (line 8-10)

## CWE
- **CWE-59**: Improper Link Resolution Before File Access

## Description

### Root Cause: Check/Use Path Discrepancy

The `isPathAllowed` method validates whether a resolved filename falls within the allowed root paths using a string-prefix check:

```js
// lib/resolver-compat.js:53-60
isPathAllowed(filename) {
    return this.rootPaths === undefined || this.rootPaths.some(path => {
        if (!filename.startsWith(path)) return false;
        const len = path.length;
        if (filename.length === len || (len > 0 && this.fs.isSeparator(path[len-1]))) return true;
        return this.fs.isSeparator(filename[len]);
    });
}
```

The filename passed to this check is resolved via `DefaultFileSystem.resolve()`, which uses `path.resolve()`:

```js
// lib/filesystem.js:8-10
resolve(path) {
    return pa.resolve(path);
}
```

`path.resolve()` normalizes the path (resolves `.`, `..`, and makes it absolute) but does **NOT** dereference symlinks. A symlink at `/root/node_modules/safe` pointing to `/outside/root/malicious` resolves to `/root/node_modules/safe` — passing the prefix check.

However, the actual module loading uses Node's native `require()`, which **does** follow symlinks:

```js
// lib/resolver-compat.js:62-66
loadJS(vm, mod, filename) {
    if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(vm, mod, filename);
    const m = this.hostRequire(filename);
    mod.exports = vm.readonly(m);
}
```

### No Symlink