feat: support --allow-unrestricted-paths configuration - #2296
Conversation
|
Thanks for the PR. I am not sure it is the best path forward: we might make the MCP server unusable for clients that do not implement the roots feature but still operate on trusted content (i.e., a local app the user owns). Setting the roots for the temp only will make it difficult for such a client to know which roots are supported (since the client did not specify any). Several features of chrome-devtools-mcp offer file outputs. Is there any reason not to configure roots on the client if the file access restrictions are relevant for the client? |
|
Thanks @OrKoN, appreciate the response. To answer your question directly: the problem with relying on the client to configure roots is that when a client skips it, the failure is completely silent. validatePath() just returns early with no restriction, no log, no warning. The client author gets zero signal that they have effectively disabled the only path-traversal guard on This has been reported and triaged as a valid security issue. I also confirmed it locally: a client that declares no capabilities can write a real file to an arbitrary path with no error at all. On the usability side, trusted local clients are a completely valid use case and I am not trying to break that. The issue is that doing nothing and intentionally opting out of restrictions look identical to the server right now. If the preferred direction is to keep the current default behavior, I am fully happy to update this PR to something less invasive, for example emitting a console.warn to stderr when validatePath is skipped because roots was never negotiated. That way operators at least have a visible signal that file system guards are inactive, without any behavior change for existing clients. Alternatively, an explicit opt-in flag like --allow-unrestricted-paths would also work. Just let me know which direction works best for the project and I will update the PR accordingly. |
Where has this happened? Do you have links? |
|
The report was submitted through a private security disclosure channel, so I cannot link to it directly. All the technical evidence is already in the PR description: the full PoC script, exact reproduction steps, confirmed output showing a real 64KB PNG written to an arbitrary path outside the repo and outside the OS temp directory, and a negative control showing the same payload is correctly rejected when the client does declare roots. The two runs differ only in whether the client declares the roots capability. If you want to verify it independently, the PoC is self-contained and runs against the current unpatched HEAD with just npm install and node poc_arbitrary_write.mjs. Happy to clarify any part of it. |
Was it submitted via https://bughunters.google.com/open-source-security? Note that our security.md clearly documents that path restrictions only apply if the client configures the MCP roots so this is documented and expected behavior. |
I think this would be a nice addition: a console.warn + --allow-unrestricted-paths (default: false) so that existing client could set it to true if needed. |
|
Yes, it was submitted through bughunters.google.com. I have read the security.md carefully. A couple of things stand out: Second, the documentation says "if the client specifies roots, the server will check them." A reasonable operator reading that would understand that without roots, checking is skipped. What they would not necessarily infer is that the skip is a silent, unconditional On the usability concern: I am happy to narrow the change. Instead of defaulting to temp-only, I can update the PR to keep the exact current behavior but add a single console.warn when validatePath skips validation because roots was never negotiated. Three-line change, no behavior regression, and operators get a clear signal that the guard is off. I can have that ready within the hour. The security.md also says the project welcomes feedback on making it easier to build a more secure user experience. Even treated as a feature request, this seems worth landing.. |
|
Yeah, I think what you propose about having a warning would be a nice usability improvement (also see #2296 (comment)) although clients do not always surface warnings output. Thanks! |
|
Updated in 76f9207. Added console.warn on oninitialized when the client skips the roots capability fires once per connection, goes to stderr, so it shows up in log files even when the client doesn't surface it to the user. Message points to --allow-unrestricted-paths so operators know how to opt out if needed. The flag itself defaults to false (no behavior change for existing setups). If a trusted local client needs unrestricted access, set --allow-unrestricted-paths and the warn is suppressed along with the restriction. Both hooks go through McpContextOptions so the flag is injectable at the unit level. Updated tests/cli.test.ts to include the new field in the parsed-args snapshot - 18/18 CLI tests passing locally. |
OrKoN
left a comment
There was a problem hiding this comment.
Thanks, this looks good to me. But I think some tests have to be updated.
5f6993d to
6296cef
Compare
validatePath() returned immediately, with no restriction at all, whenever roots() returned undefined. roots() only returns undefined when the connecting MCP client never negotiates the optional roots capability during initialize, which any minimal client can trigger simply by omitting it from its declared capabilities. Since roots() already always appends the OS temp directory to whatever explicit roots are configured, make it return that same default (temp directory only) instead of undefined when no roots have been set. This removes the early return in validatePath() entirely, so path validation now runs unconditionally rather than being conditional on whether the client happened to negotiate a capability it was never required to declare. Added a test covering the previously unset roots() state directly, since the existing tests always call setRoots() (even with an empty array) before validating, and none of them exercised the actual default state a client reaches by doing nothing.
…g when roots unset When the connecting MCP client does not negotiate the optional roots capability, emit a console.warn at connection time so operators are aware that file-writing tools are restricted to the OS temp directory. Add --allow-unrestricted-paths (default: false) so existing clients that do not implement MCP roots can opt into the previous permissive behavior explicitly rather than being silently denied. Default behaviour (allowUnrestrictedPaths=false): - Validation runs against the OS temp directory only (unchanged from the previous commit). - A console.warn is emitted once per connection to alert the operator. With --allow-unrestricted-paths: - validatePath() returns early when roots were never negotiated, restoring the pre-fix behaviour for trusted local clients.
- Update the legacy 'validatePath allows all paths if roots are undefined' test to explicitly pass allowUnrestrictedPaths: true, matching the new behavior where the old permissive default requires an opt-in flag. - Add a new test verifying that paths outside tmpdir are denied by default when the client never negotiates roots (and the flag is not set). - Expose allowUnrestrictedPaths in withMcpContext() test helper. - Run npm run gen to update README and telemetry metrics for the new flag.
6296cef to
e52757b
Compare
|
Resolved conflict in flag_usage_metrics.json (rebased onto latest main). Both the upstream experimental_data_format entries and the new allow_unrestricted_paths entries are now included. Branch is up to date. |
…compat On macOS, os.tmpdir() returns /var/folders/... (not /tmp). With the new validatePath behavior, paths are now validated against os.tmpdir(), so hardcoded /tmp/req.txt and /tmp/res.txt are rejected on macOS CI. Use os.tmpdir() for the paths passed to attachNetworkRequest while keeping fixed strings in toJSONDetailed() so the snapshot remains stable across platforms.
|
Fixed in 7f77641. Root cause of macOS/Windows failures: the test "should include file paths in structured content when saving to file" used hardcoded /tmp/req.txt and /tmp/res.txt. On macOS, os.tmpdir() returns /var/folders/... (not /tmp), so with the new validatePath behavior those paths were correctly rejected as outside the tmpdir — causing the test to fail. Fix: use path.join(os.tmpdir(), 'req.txt') for the paths passed to attachNetworkRequest. The toJSONDetailed() stub still returns fixed strings so the snapshot stays stable across platforms. |
The tests 'allows file access if roots capability is missing' and 'when dialog is open and tool is blocked' used hardcoded /tmp/test.png. On macOS, os.tmpdir() returns /var/folders/... (not /tmp), so with the new validatePath behavior those paths are correctly rejected as outside tmpdir before the test assertion runs. Replace with path.join(os.tmpdir(), 'test.png') so the path is valid on all platforms.
|
Fixed in 396e69f. Root cause: index.test.ts had two hardcoded /tmp/test.png paths in "allows file access if roots capability is missing" (line 250) and "when dialog is open and tool is blocked" (line 308). On macOS, os.tmpdir() returns /var/folders/... not /tmp, so with the new validatePath behavior /tmp is outside the allowed root and both tests hit "Access denied" before reaching their actual assertion. Ubuntu passed because os.tmpdir() == /tmp there. Fix: replaced both with path.join(os.tmpdir(), 'test.png'). The dialog test still reaches the dialog-blocked error as expected; the "allows file access" test confirms that writes to tmpdir succeed without roots capability. |
|
@OrKoN all 15 checks are now passing (including the format and docs checks that were failing earlier). The outstanding test issues from your review have been resolved:
Would you be able to approve so this can merge? Happy to address any remaining concerns. :) |
6e56c02
🤖 I have created a release *beep* *boop* --- ## [1.6.0](ChromeDevTools/chrome-devtools-mcp@chrome-devtools-mcp-v1.5.0...chrome-devtools-mcp-v1.6.0) (2026-07-14) ### 🎉 Features * add experimentalGcfFormat flag for GCF-encoded tool responses ([ChromeDevTools#2235](ChromeDevTools#2235)) ([3d21389](ChromeDevTools@3d21389)) * Print object count and total sizes in get_heapsnapshot_details ([ChromeDevTools#2325](ChromeDevTools#2325)) ([15a6b78](ChromeDevTools@15a6b78)) * support --allow-unrestricted-paths configuration ([ChromeDevTools#2296](ChromeDevTools#2296)) ([6e56c02](ChromeDevTools@6e56c02)) * Support filter with heap snapshots aggregates ([ChromeDevTools#2323](ChromeDevTools#2323)) ([2812902](ChromeDevTools@2812902)) * update Lighthouse to 13.4.0 ([ChromeDevTools#2317](ChromeDevTools#2317)) ([ffc6060](ChromeDevTools@ffc6060)) ### 🛠️ Fixes * enforce .gz instead of json.gz in performance tools ([ChromeDevTools#2305](ChromeDevTools#2305)) ([b06e39b](ChromeDevTools@b06e39b)) * keep a still-open selected page instead of falling back to the first page ([ChromeDevTools#2328](ChromeDevTools#2328)) ([c645eee](ChromeDevTools@c645eee)), closes [ChromeDevTools#2304](ChromeDevTools#2304) * keep page ids unique across browser reconnects ([ChromeDevTools#2345](ChromeDevTools#2345)) ([3e8d922](ChromeDevTools@3e8d922)) * paginate page 0 in list_network_requests and list_console_messages ([ChromeDevTools#2359](ChromeDevTools#2359)) ([d0025b3](ChromeDevTools@d0025b3)) * release held modifiers when press_key key event fails ([ChromeDevTools#2347](ChromeDevTools#2347)) ([78ccb19](ChromeDevTools@78ccb19)) * report when the selected page was auto-replaced by the fallback ([ChromeDevTools#2308](ChromeDevTools#2308)) ([2c16ac3](ChromeDevTools@2c16ac3)), closes [ChromeDevTools#2304](ChromeDevTools#2304) * resolve page ids only among listed pages ([ChromeDevTools#2332](ChromeDevTools#2332)) ([eb04951](ChromeDevTools@eb04951)), closes [ChromeDevTools#2304](ChromeDevTools#2304) * **snapshot:** resolve element ids on the correct snapshot ([ChromeDevTools#2295](ChromeDevTools#2295)) ([b703f2c](ChromeDevTools@b703f2c)) * **telemetry:** resolve enum values through nested schema wrappers ([ChromeDevTools#2315](ChromeDevTools#2315)) ([c065fd9](ChromeDevTools@c065fd9)) * Wait until daemon is started ([ChromeDevTools#2327](ChromeDevTools#2327)) ([ed7e95d](ChromeDevTools@ed7e95d)) ### 📄 Documentation * add Grok Build CLI configuration section ([ChromeDevTools#2294](ChromeDevTools#2294)) ([aa4be07](ChromeDevTools@aa4be07)) * update memory leak debugging skill ([ChromeDevTools#2330](ChromeDevTools#2330)) ([c1736a0](ChromeDevTools@c1736a0)) ### ⚡ Performance * concurrent I/O in Root Path Resolution ([ChromeDevTools#2279](ChromeDevTools#2279)) ([b2c63e6](ChromeDevTools@b2c63e6)) ### 🏗️ Refactor * clean up McpContext getters ([ChromeDevTools#2340](ChromeDevTools#2340)) ([5b33deb](ChromeDevTools@5b33deb)) * clean up more of the context interface ([ChromeDevTools#2335](ChromeDevTools#2335)) ([9cd734b](ChromeDevTools@9cd734b)) * clean up page management ([ChromeDevTools#2333](ChromeDevTools#2333)) ([16db01f](ChromeDevTools@16db01f)) * clean up page snapshot generation ([ChromeDevTools#2348](ChromeDevTools#2348)) ([68cfce2](ChromeDevTools@68cfce2)) * make collectors work per page ([ChromeDevTools#2324](ChromeDevTools#2324)) ([9bc61b4](ChromeDevTools@9bc61b4)) * move and rename files ([ChromeDevTools#2355](ChromeDevTools#2355)) ([9c3542b](ChromeDevTools@9c3542b)) * move DevTools universe to McpPage ([ChromeDevTools#2341](ChromeDevTools#2341)) ([c006c9b](ChromeDevTools@c006c9b)) * move remaining McpContext getters ([ChromeDevTools#2342](ChromeDevTools#2342)) ([58ba174](ChromeDevTools@58ba174)) * remove isolated context getter ([ChromeDevTools#2336](ChromeDevTools#2336)) ([8a4ddb3](ChromeDevTools@8a4ddb3)) * Use array instead of Map for idToClassKey ([ChromeDevTools#2321](ChromeDevTools#2321)) ([ff53b7b](ChromeDevTools@ff53b7b)) * use helper for Dialog handle ([ChromeDevTools#2334](ChromeDevTools#2334)) ([64005f9](ChromeDevTools@64005f9)) * use response page in formatting ([ChromeDevTools#2349](ChromeDevTools#2349)) ([c53c1ec](ChromeDevTools@c53c1ec)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Summary
validatePath() in McpContext returned immediately, with no restriction at all, whenever roots() returned undefined. roots() only returns undefined when the connecting MCP client never negotiates the optional roots capability during initialize, which any minimal client can trigger simply by omitting it from its declared capabilities.
Since roots() already always appends the OS temp directory to whatever explicit roots are configured, this change makes it return that same default (temp directory only) instead of undefined when no roots have been set. This removes the early return in validatePath() entirely, so path validation now runs unconditionally rather than being conditional on whether the connecting client happened to negotiate a capability it was never required to declare per the MCP spec.
Any filePath-accepting tool (take_screenshot, saveFile, and the performance/Lighthouse export tools that route through the same check) had its only path-traversal guard silently disabled for the lifetime of a connection whenever the client omitted the optional roots capability. Since this server is designed to let an LLM drive a browser, and browsed page content is not trusted input, this meant a client that simply doesn't implement roots (a plausible, non-adversarial default for lightweight or custom MCP clients) removed the only boundary preventing the connected agent from writing to any path the process can reach.
Added a test that exercises the actual default state of roots (never calling setRoots()) directly, since the existing tests always call setRoots(), even with an empty array, before validating. Verified locally with a minimal MCP client that declares no capabilities: before this change, take_screenshot with a filePath outside any root wrote a real file to an arbitrary path with no error; after this change, the same call is rejected with the existing Access denied error. Also verified that a client that does declare roots is unaffected, and that writes to the OS temp directory continue to succeed with no roots negotiated, matching prior behavior for that path.