Skip to content

Path traversal in dev server static file serving can expose files in sibling prefix-matching directories #2016

Closed

Description

@Dremig

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 as testem-root and testem-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:

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.1

In another terminal:

curl --path-as-is -i \
  http://127.0.0.1:7357/../testem-root-secret/secret.txt

Observed response:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 6

SECRET

Expected 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

  1. added a commit that references this issue on Oct 26, 2026
    609a4e4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions