validatePath logs client-supplied paths and root URIs unescaped to stderr, so a newline forges extra log lines

Author: 0xdhxCreated Sep 24, 2026Updated Sep 26, 2026
Labelsconfirmed

Description of the bug

McpContext.validatePath writes two diagnostic lines to stderr that interpolate values the MCP client supplies, without escaping them:

  1. On a non-ENOENT failure from resolveCanonicalPath (a path through a regular file gives ENOTDIR; EACCES and ELOOP also qualify), it logs `[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`. Here filePath is the tool argument, and errMsg repeats it, because Node's realpath error quotes the path.
  2. When a configured root cannot be resolved, it logs `[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`. Here root.uri comes from the client's roots/list response.

A \n in either value therefore splits one log record into several lines, and everything after each newline starts a line of the client's choosing. Such a line can carry the server's own [MCP Context] prefix. Anything that reads the server's stderr as the server's own diagnostics is exposed: an operator tailing it, a supervisor or journal that greps it, or a wrapper that forwards it.

This is filed as an ordinary logging bug, not a security report. SECURITY.md § Scope places input validation on the client, and it is not a path-validation problem either, since both calls are correctly refused. The only defect is that the log lines quoting the refused values do not escape them. The Error messages returned to the caller also embed the raw path, but they go back to the same client that sent it, so they are left out of this issue.

Source at main (bd4ea27); the same lines are present at tag chrome-devtools-mcp-v1.10.1:

typescript
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err);
      console.error(
        `[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`,
      );
typescript
        console.warn(
          `[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`,
        );

Reproduction

Both cases were run against the published [email protected] over plain stdio (chrome-devtools-mcp --headless --isolated, no proxy or wrapper), using a minimal JSON-RPC client. The client sends initialize, then notifications/initialized, then list_pages (which returns page 1), then the call shown. The same filePath case also reproduces identically on 1.9.0.

Case 1: filePath

json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"take_snapshot","arguments":{"pageId":1,"filePath":"/etc/hostname/x\n[MCP Context] Server shutting down: FORGED-BY-CLIENT"}}}

The tool result is correctly an error (isError: true, Access denied: Cannot resolve base path for …). The server's stderr, shown with cat -A so that $ marks each real line end:

[MCP Context] Error resolving real path for /etc/hostname/x$
[MCP Context] Server shutting down: FORGED-BY-CLIENT: ENOTDIR: not a directory, realpath '/etc/hostname/x$
[MCP Context] Server shutting down: FORGED-BY-CLIENT'$

One console.error call produced three lines, and two of them start with client-chosen text. /etc/hostname is simply a regular file on Linux, so realpath fails with ENOTDIR, and any path that runs through a regular file behaves the same way.

Case 2: root.uri

The client declares the roots capability in initialize and answers the server's roots/list with:

json
{"roots":[{"uri":"file:///nonexistent-root\n[MCP Context] FORGED-ROOT-LINE","name":"r"}]}

Any path-taking call then triggers the root loop. With take_snapshot and "filePath":"/etc/hosts", the tool result is correctly Access denied: path /etc/hosts (canonical: /etc/hosts) is not within any of the configured workspace roots., and stderr shows:

[MCP Context] Could not resolve configured root file:///nonexistent-root$
[MCP Context] FORGED-ROOT-LINE: ENOENT: no such file or directory, realpath '/nonexistent-root[MCP Context] FORGED-ROOT-LINE'$

The WHATWG URL parser inside fileURLToPath strips the newline before realpath runs, so errMsg comes out clean here. The raw root.uri in the log line still carries it.

Expectation

Each console.error / console.warn call stays one line. Client-supplied values appear escaped, so a reader can always tell server text from quoted client input.

A minimal fix encodes the interpolated values, for example with JSON.stringify, which escapes \n, \r, the other C0 control characters and ", and is reversible:

typescript
      console.error(
        `[MCP Context] Error resolving real path for ${JSON.stringify(filePath)}: ${JSON.stringify(errMsg)}`,
      );
typescript
        console.warn(
          `[MCP Context] Could not resolve configured root ${JSON.stringify(root.uri)}: ${JSON.stringify(errMsg)}`,
        );

errMsg needs encoding as well as the path, because Node's realpath error text repeats the path. Stripping newlines instead would lose information, and rejecting control characters in filePath would change tool behaviour, which is out of scope for a logging fix.

A sweep of the other console.* calls in build/src that interpolate values found no further instance fed by client input. The rest log server-side values such as the daemon PID path, versions and CLI arguments.

MCP configuration

Launched directly over stdio: chrome-devtools-mcp --headless --isolated. No other flags, no roots unless noted in Case 2.

Chrome DevTools MCP version

1.10.1 (also reproduced on 1.9.0; code unchanged on main at bd4ea27)

Chrome version

150.0.7871.186

Coding agent version

n/a — driven by a minimal stdio JSON-RPC client, no coding agent involved

Model version

n/a

Chat log

n/a

Node version

v24.14.1

Operating system

Windows Subsystem for Linux (WSL)

Extra checklist

  • I want to provide a PR to fix this bug

Source: ChromeDevTools/chrome-devtools-mcp