can textually pass the /workspace prefix check even when cache/profile is a symlink to:
/home/user/.aws/credentials
Downstream consumers then perform real filesystem operations without O_NOFOLLOW:
src/McpContext.ts:720-738saveFile() uses fs.mkdir({recursive: true}) and fs.writeFile().
src/tools/input.ts:454-497upload_file calls puppeteer.uploadFile(filePath) or fileChooser.accept([filePath]).
Other filePath-writing tools include screenshots, heap snapshots, network response save paths, snapshots, screencasts, Lighthouse output, and performance trace saves.
This is not a TOCTOU/race condition. The symlink exists before validation and the PoC uses a single process. The issue is a canonicalization bypass / improper link resolution.
Preconditions:
The MCP client declares roots and supplies at least one workspace root.
A symlink exists inside the workspace and points outside the workspace.
For the remote prompt-injection chain, the user processes untrusted page content while chrome-devtools-mcp is connected.
A remote attacker does not need local access if a suitable workspace-internal symlink already exists, or if another trusted tool/workflow can create it. Without such a symlink, the issue is a local/workspace-state-dependent boundary bypass.
PoC
Conceptual exploitation with a configured root:
Configured roots:
file:///workspace
Workspace path:
/workspace/project/cache/profile -> /home/user/.aws/credentials
Tool call:
upload_file({
filePath: "/workspace/project/cache/profile",
uid: "<file input element on current page>"
})
Result:
validatePath() accepts the path because it textually starts with /workspace.
Puppeteer follows the symlink and uploads the target file to the page.
Lab-only PoC that replicates the exact validation logic and subsequent write. It writes only inside a fresh temporary directory and touches no system paths:
validatePath() accepts the in-root symlink path.
The subsequent write follows the symlink and modifies the out-of-root target.
I can provide an end-to-end MCP client reproduction if needed. The lab PoC above demonstrates the root cause using the same validation logic as the server.
Impact
Who can exploit:
A local process/user or trusted workflow that can create a symlink inside the workspace.
A remote page/prompt-injection attacker, if a suitable workspace-internal symlink already exists or can be created by another trusted workflow/tool.
Security impact:
Integrity: tools that write to filePath can overwrite files outside the configured workspace root through an in-root symlink.
Confidentiality: upload_file can read a file outside the workspace through an in-root symlink and attach it to a file input on the current page.
Stealth/auditability: the exfiltration path goes through normal page file-upload behavior, and chrome-devtools-mcp does not appear to log the canonical path that was uploaded.
Example sensitive files reachable if symlinked into the workspace:
Cloud credentials such as ~/.aws/credentials, ~/.config/gcloud/..., or ~/.azure/....
SSH private keys or .ssh files readable by the user.
Project secrets such as .env, .npmrc, .netrc, secrets.json, and API tokens.
Out-of-workspace source files or configuration files.
AC:H reflects that a workspace-internal symlink must exist at validation time.
Suggested fix:
Canonicalize paths before comparing against roots. For an existing file, use fs.realpath() on the path. For a new file, resolve the parent directory with fs.realpath() and re-join the basename.
async validatePath(filePath?: string): Promise<void> {
if (filePath === undefined) return;
const roots = this.roots();
if (roots === undefined) return;
const abs = path.resolve(filePath);
let canonical;
try {
canonical = await fs.realpath(abs);
} catch (err) {
if (err.code === 'ENOENT') {
const parent = await fs.realpath(path.dirname(abs));
canonical = path.join(parent, path.basename(abs));
} else {
throw err;
}
}
for (const root of roots) {
const canonicalRoot = await fs.realpath(fileURLToPath(root.uri));
if (
canonical === canonicalRoot ||
canonical.startsWith(canonicalRoot + path.sep)
) {
return;
}
}
throw new Error(
`Access denied: ${filePath} (canonical: ${canonical}) is not within any workspace root.`,
);
}