fix(core): close sibling-prefix bypass in get_internal_docs path guard - #29249
fix(core): close sibling-prefix bypass in get_internal_docs path guard#29249ranjan-del wants to merge 1 commit into
Conversation
The path traversal guard in the get_internal_docs tool compares with
startsWith():
const resolvedPath = path.resolve(docsRoot, this.params.path);
if (!resolvedPath.startsWith(docsRoot)) { ... }
A prefix comparison has no path-component boundary, so it also accepts
any sibling directory whose name begins with the docs directory name.
With a docsRoot of `<repo>/docs`, a request for
`../docs-private/secret.md` resolves to `<repo>/docs-private/secret.md`,
which still starts with `<repo>/docs`, so the guard passes and the file
is read and returned to the model.
The existing traversal test only covered `../package.json`, which
resolves outside the prefix and so was already blocked. It did not
exercise the sibling-prefix case.
Switched to isSubpath() from utils/paths.js, which the repository
already uses for this purpose in services/sandboxedFileSystemService.ts.
It compares with path.relative() rather than a string prefix, so the
component boundary is respected, and it handles the case-insensitive
comparison needed on Windows and macOS.
Added a regression test that creates a real sibling of the docs
directory, asserts the request is denied, and asserts the file's
contents are not returned.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a security vulnerability in the Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
📊 PR Size: size/S
|
🛑 Action Required: Evaluation ApprovalSteering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged. Maintainers:
Once approved, the evaluation results will be posted here automatically. |
There was a problem hiding this comment.
Code Review
This pull request improves path traversal protection in the GetInternalDocs tool by replacing a simple startsWith check with the isSubpath utility, preventing access to sibling directories sharing a prefix. A corresponding integration test has also been added. The review comments suggest further strengthening this security check by resolving symbolic links using resolveToRealPath to prevent symlink-based path traversal vulnerabilities.
| import { ToolErrorType } from './tool-error.js'; | ||
| import { GET_INTERNAL_DOCS_DEFINITION } from './definitions/coreTools.js'; | ||
| import { resolveToolDeclaration } from './definitions/resolver.js'; | ||
| import { isSubpath } from '../utils/paths.js'; |
There was a problem hiding this comment.
Import resolveToRealPath to resolve symbolic links and prevent symlink-based path traversal vulnerabilities.
| import { isSubpath } from '../utils/paths.js'; | |
| import { isSubpath, resolveToRealPath } from '../utils/paths.js'; |
References
- Ensure consistent path resolution by using a single, robust function (e.g.,
resolveToRealPath) for all related path validations.
| const resolvedPath = path.resolve(docsRoot, this.params.path); | ||
| if (!resolvedPath.startsWith(docsRoot)) { | ||
| if (!isSubpath(docsRoot, resolvedPath)) { |
There was a problem hiding this comment.
The current path traversal guard resolves the path using path.resolve but does not resolve symbolic links. If a symbolic link is created inside the documentation directory pointing to a file outside of it, the guard will permit access because the resolved path still appears to be within docsRoot. To prevent symlink-based path traversal vulnerabilities, resolve both docsRoot and the target path to their real paths using resolveToRealPath before performing the subpath check.
const resolvedPath = resolveToRealPath(path.resolve(docsRoot, this.params.path));
const realDocsRoot = resolveToRealPath(docsRoot);
if (!isSubpath(realDocsRoot, resolvedPath)) {References
- Ensure consistent path resolution by using a single, robust function (e.g.,
resolveToRealPath) for all related path validations. - When requesting file access permissions, resolve symbolic links first to display the actual path being accessed, preventing potential path traversal vulnerabilities.
Summary
The path traversal guard in the
get_internal_docstool uses a string prefixcomparison, which has no path-component boundary. It therefore accepts any
sibling directory whose name begins with the docs directory name, and the tool
reads that file and returns its contents to the model.
packages/core/src/tools/get-internal-docs.ts:With a
docsRootof<repo>/docs, a request for../docs-private/secret.mdresolves to
<repo>/docs-private/secret.md. That still starts with<repo>/docs, so the guard passes.Details
The fix swaps the prefix comparison for
isSubpath()frompackages/core/src/utils/paths.ts, which the repository already uses forexactly this purpose in
services/sandboxedFileSystemService.ts. It comparesusing
path.relative()instead of a string prefix, so the component boundaryis respected, and it also handles the case-insensitive comparison needed on
Windows and macOS, which a raw
startsWithdoes not.Why the existing test did not catch it
The current traversal test requests
../package.json. That resolves to<repo>/package.json, which does not share the<repo>/docsprefix, so it wasalready blocked. The sibling-prefix case was simply never exercised.
The new test creates a real sibling of the docs directory containing a marker
file, asserts the request is denied with
Access denied, and asserts the markercontent is absent from
llmContent. It cleans the directory up in afinallyblock. I confirmed it fails on
mainbefore the fix, with the tool returning noerror at all.
Scope and severity
Deliberately narrow. This tool is read-only and its reachable surface is limited
to paths that share the docs directory prefix, so this is hardening rather than a
broad disclosure path. I kept the change to the one incorrect comparison rather
than reworking the tool.
Worth noting for a follow-up, not addressed here: the guard resolves with
path.resolveand does not resolve symlinks, so a symlink inside the docs treepointing outside it would still be followed.
isSubpathalone does not fix that;it needs
resolveToRealPathas well, which is a slightly larger behaviouralchange and felt like a separate decision.
Related Issues
No existing issue. Found while investigating #29078. Happy to open one first if
you would prefer the discussion to start there.
How to Validate
On
main, the new test fails because the guard lets the sibling path through:With this change:
Full gate:
npm run preflight # exit 0Note for reviewers running preflight locally: if your checkout is not a trusted
workspace, 9 tests in
packages/cli/src/gemini.test.tsxfail withFatalUntrustedWorkspaceErrorregardless of any change. SetGEMINI_CLI_TRUST_WORKSPACE=trueand they pass.Pre-Merge Checklist
user-facing surface change
before the fix
directory behave identically
Validated on macOS with
npm run.isSubpathhas its own platform handling andexisting test coverage, so the Windows and Linux risk here is low, but I could
not exercise those hosts myself.