Repository navigation
Description
Summary
Testem's development server can serve files outside the configured working directory via a path traversal request when the resolved target path still string-matches the project root prefix.
In [email protected], the catch-all static file route reconstructs the request path and passes it to serveStaticFile(). The file is resolved against config.cwd(), but the containment check uses a substring test:
var filePath = path.resolve(config.resolvePath(uri));
var isPathPermitted = filePath.indexOf(path.resolve(config.cwd())) !== -1;This can be bypassed when the target path is outside cwd but still contains the cwd string as a prefix, for example:
/tmp/testem-root
/tmp/testem-root-secret
A request for /../testem-root-secret/secret.txt resolves to /tmp/testem-root-secret/secret.txt, and the substring check still treats it as permitted because /tmp/testem-root-secret/... starts with /tmp/testem-root.
This is not an unrestricted arbitrary file read such as reading any absolute path on the host. For example, with cwd=/tmp/testem-root, a request resolving to /etc/passwd is rejected because that path does not contain the project root string. The issue is a project-root escape caused by an unsafe prefix/substring containment check.
Security boundary
The affected boundary is the Testem working directory (config.cwd()), not the whole filesystem.
This report does not claim that an attacker can read every arbitrary file on the host. The bypass works when all of the following are true:
- The attacker can send HTTP requests to the Testem dev server.
- The requested path contains traversal such as
../. - The resolved target is outside
config.cwd(). - The resolved target still shares the same string prefix as
config.cwd(), such astestem-rootandtestem-root-secret.
This is still a path traversal issue because user-controlled path components can escape the intended root directory. The pattern is similar to previously reported sibling-prefix path traversal issues such as Rack's Rack::Directory vulnerability (CVE-2026-22860 / GHSA-mxw3-3hh2-x2mh) and Jupyter Server's root directory sibling escape (CVE-2026-35397 / GHSA-5789-5fc7-67v3).
Affected version
Verified on:
[email protected]- Node.js
24.10.0 - macOS
3.20.1 is currently the latest npm version.
Steps to reproduce
rm -rf /tmp/testem-root /tmp/testem-root-secret
mkdir -p /tmp/testem-root /tmp/testem-root-secret
printf SECRET >/tmp/testem-root-secret/secret.txt
cd /tmp/testem-root
npx -y [email protected] server --port 7357 --host 127.0.0.1In another terminal:
curl --path-as-is -i \
http://127.0.0.1:7357/../testem-root-secret/secret.txtObserved response:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 6
SECRETExpected behavior
Requests resolving outside config.cwd() should be rejected with 403 Forbidden, unless unsafe_file_serving is explicitly enabled or a configured route intentionally points outside the project root.
Actual behavior
The request is allowed and res.sendFile(filePath) serves a file from a sibling directory outside the Testem working directory.
Root cause
Relevant code in lib/server/index.js:
- The catch-all route serves any unmatched path as a static file.
serveStaticFile()resolves the requested path.- The permission check uses
filePath.indexOf(path.resolve(config.cwd())) !== -1.
This is not a safe directory containment check. A sibling directory with the same prefix can bypass it.
lib/config.js also resolves relative request paths against cwd:
resolvePath(filepath) {
if (filepath[0] === '/') {
return filepath;
}
return path.resolve(this.cwd(), filepath);
}For the PoC request, the final path resolves outside cwd, but the prefix-style indexOf check still passes.
Impact
An unauthenticated HTTP client that can reach the Testem server may read files outside the intended project root if the resolved path can be arranged to remain string-prefixed by config.cwd().
This is most relevant when the Testem dev server is reachable from a local network, a shared development environment, or CI/browser test infrastructure. Even in local development, dev servers often run next to sibling directories such as project-secrets, project-backup, or generated artifacts that share the same path prefix.
Suggested CVSS v3.1, depending on deployment assumptions:
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - 5.9
If maintainers consider the dev server strictly localhost-only, the environmental severity may be lower. If the server is intentionally exposed to a network or a predictable sibling prefix contains secrets, the practical impact may be higher.
Suggested fix
Replace the substring check with a real containment check, for example:
const root = path.resolve(config.cwd());
const candidate = path.resolve(config.resolvePath(uri));
const relative = path.relative(root, candidate);
const isPathPermitted =
relative === '' ||
(!relative.startsWith('..' + path.sep) &&
relative !== '..' &&
!path.isAbsolute(relative));If symlink traversal is in scope, consider applying the same check after resolving real paths with fs.realpath().
Activity
fix(server): stop serving siblings that share the cwd prefix (testem#…