mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 14:49:36 +08:00
fix(security): the sensitive-folder guard refuses links, case variants and \?\ paths (#2929)
The guard that keeps ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system folders such as /etc and C:\Windows from being opened as a project compared the path as typed. A junction or symlink to one of them, a different capitalization on Windows or macOS (~/.SSH), or a \\?\ or \\.\ spelling on Windows passed, so an MCP projectPath, `codegraph ui` and the viewer's per-file reads all served what was inside. The MCP gate also skipped the guard for a path that doesn't exist and then walked up from it, so ~/.config/no-such-dir reached an index of ~/.config. validateProjectPath now checks the path as spelled (with a Win32 namespace prefix stripped) and its fs.realpathSync.native form. Both are compared with the system set and the system folders' own real paths (macOS /private/etc), and with the home folders as spelled, under the home's real path and by their own real paths (a linked ~/.ssh). Case is folded on win32 and darwin. A path that can't be resolved keeps the lexical check. It stays at one realpath per call: the system folders' real paths are read once and the home folders' are cached for two seconds. The MCP handler also checks the root its up-walk lands on. Validation on Windows 11, with a private home holding a fake token: - Probe: on main 11 of 16 sensitive spellings were allowed, and the token was served through 6 MCP projectPath shapes and 2 viewer roots. With the fix all are refused. Controls (an ordinary folder, its upper-case and \\?\ spellings, the home itself) are still allowed. - codegraph ui end to end: main served the token for 4 of 6 spellings and started the viewer for a fifth; the fix refuses all 6 at startup. - Tests: the 9 new tests that run on Windows fail on main's source and pass with the fix. The POSIX and macOS cases are gated and were not run here. - Cost: validateProjectPath 48 -> 130 us per call, the viewer's per-file resolveProjectFile 700 -> 780 us (medians of 5 rounds, 3 runs). Reported in PR #1930 by @mixxer; this reimplements the fix independently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7ff2841e37
commit
5e0c42cb63
@@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
- CodeGraph's refusal to use a sensitive folder such as `~/.ssh`, `~/.config`, `/etc` or `C:\Windows` as a project can no longer be sidestepped: a link or junction that leads to one, a differently capitalized name on Windows and macOS, a `\\?\` spelling on Windows, and a path to a missing folder inside one are now refused like the folder itself. Before, any of these let an AI agent's query read the files inside. Thanks @mixxer for finding it.
|
||||
|
||||
### Fixes
|
||||
|
||||
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
|
||||
|
||||
@@ -185,6 +185,22 @@ describe('codegraph ui — refusals', () => {
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('Refusing to operate on sensitive');
|
||||
});
|
||||
|
||||
// #1930: a link (a junction on Windows) to a sensitive folder is refused like
|
||||
// the folder itself, not taken for an ordinary unindexed project.
|
||||
it('refuses a link to a sensitive home folder', () => {
|
||||
const home = path.join(unindexed, 'home');
|
||||
fs.mkdirSync(path.join(home, '.config'), { recursive: true });
|
||||
const link = path.join(unindexed, 'config-link');
|
||||
fs.symlinkSync(path.join(home, '.config'), link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
try {
|
||||
const { code, output } = runCli(['ui', link], { HOME: home, USERPROFILE: home });
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('Refusing to operate on sensitive');
|
||||
} finally {
|
||||
fs.unlinkSync(link);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph ui — serving', () => {
|
||||
|
||||
+187
-1
@@ -14,7 +14,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { FileLock, validateProjectPath, validatePathWithinRoot } from '../src/utils';
|
||||
import CodeGraph from '../src/index';
|
||||
import { ToolHandler, tools } from '../src/mcp/tools';
|
||||
import { ToolHandler, tools, __setLoadCodeGraphForTests } from '../src/mcp/tools';
|
||||
import { scanDirectory, isSourceFile } from '../src/extraction';
|
||||
import { DatabaseConnection, getDatabasePath } from '../src/db';
|
||||
import { QueryBuilder } from '../src/db/queries';
|
||||
@@ -395,6 +395,192 @@ describe('validateProjectPath — sensitive directory blocking', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A directory link any user can make: a junction on Windows (no privilege or
|
||||
* Developer Mode needed), a symlink elsewhere.
|
||||
*/
|
||||
function linkDir(target: string, link: string): void {
|
||||
fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
|
||||
/** Point `os.homedir()` at `home`: it reads USERPROFILE on Windows, HOME elsewhere. */
|
||||
function setHome(home: string | undefined, profile: string | undefined = home): void {
|
||||
if (home === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = home;
|
||||
if (profile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp folder holding a private home with its own `.ssh` and `.config`, and
|
||||
* the links a case makes. Links are removed before the tree, so the cleanup
|
||||
* can never walk into a link's target.
|
||||
*/
|
||||
function sensitiveFixture() {
|
||||
const state = {
|
||||
tmp: '',
|
||||
home: '',
|
||||
links: [] as string[],
|
||||
savedHome: undefined as string | undefined,
|
||||
savedProfile: undefined as string | undefined,
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sensitive-')));
|
||||
state.home = path.join(state.tmp, 'home');
|
||||
fs.mkdirSync(path.join(state.home, '.ssh'), { recursive: true });
|
||||
fs.mkdirSync(path.join(state.home, '.config', 'nvim'), { recursive: true });
|
||||
fs.mkdirSync(path.join(state.tmp, 'project'));
|
||||
fs.mkdirSync(path.join(state.tmp, 'elsewhere'));
|
||||
state.links = [];
|
||||
state.savedHome = process.env.HOME;
|
||||
state.savedProfile = process.env.USERPROFILE;
|
||||
setHome(state.home);
|
||||
});
|
||||
afterEach(() => {
|
||||
setHome(state.savedHome, state.savedProfile);
|
||||
for (const link of state.links.reverse()) fs.unlinkSync(link);
|
||||
fs.rmSync(state.tmp, { recursive: true, force: true });
|
||||
});
|
||||
const link = (target: string, at: string): string => {
|
||||
linkDir(target, at);
|
||||
state.links.push(at);
|
||||
return at;
|
||||
};
|
||||
return { state, link };
|
||||
}
|
||||
|
||||
// #1930: the guard compared the path as typed, so a link to a sensitive folder,
|
||||
// a case variant of one (Windows, macOS) or a `\\?\` spelling (Windows) passed.
|
||||
describe('validateProjectPath — the folder a path really names (#1930)', () => {
|
||||
const { state, link } = sensitiveFixture();
|
||||
|
||||
it('refuses a link to a sensitive home folder, and the folders under it', () => {
|
||||
const elsewhere = path.join(state.tmp, 'elsewhere');
|
||||
const ssh = link(path.join(state.home, '.ssh'), path.join(elsewhere, 'ssh'));
|
||||
const config = link(path.join(state.home, '.config'), path.join(elsewhere, 'config'));
|
||||
|
||||
expect(validateProjectPath(ssh)).toMatch(/sensitive directory/i);
|
||||
expect(validateProjectPath(config)).toMatch(/sensitive directory/i);
|
||||
expect(validateProjectPath(path.join(config, 'nvim'))).toMatch(/sensitive directory/i);
|
||||
// The refusal names the folder the link leads to.
|
||||
expect(validateProjectPath(ssh)).toContain(path.join(state.home, '.ssh'));
|
||||
// A link to an ordinary folder is still a fine project root.
|
||||
const project = link(path.join(state.tmp, 'project'), path.join(elsewhere, 'project'));
|
||||
expect(validateProjectPath(project)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a sensitive home folder that is itself a link, and links to it', () => {
|
||||
// ~/.ssh kept in a dotfiles checkout and linked into the home.
|
||||
const dotfiles = path.join(state.tmp, 'dotfiles', 'ssh');
|
||||
fs.mkdirSync(dotfiles, { recursive: true });
|
||||
fs.rmdirSync(path.join(state.home, '.ssh'));
|
||||
link(dotfiles, path.join(state.home, '.ssh'));
|
||||
|
||||
expect(validateProjectPath(dotfiles)).toMatch(/sensitive directory/i);
|
||||
const viaLink = link(path.join(state.home, '.ssh'), path.join(state.tmp, 'elsewhere', 'ssh'));
|
||||
expect(validateProjectPath(viaLink)).toMatch(/sensitive directory/i);
|
||||
});
|
||||
|
||||
it("refuses a linked home's sensitive folders by their real path", () => {
|
||||
const realHome = path.join(state.tmp, 'real-home');
|
||||
fs.mkdirSync(path.join(realHome, '.aws'), { recursive: true });
|
||||
const linkedHome = link(realHome, path.join(state.tmp, 'linked-home'));
|
||||
setHome(linkedHome);
|
||||
|
||||
expect(validateProjectPath(path.join(realHome, '.aws'))).toMatch(/sensitive directory/i);
|
||||
expect(validateProjectPath(path.join(linkedHome, '.aws'))).toMatch(/sensitive directory/i);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32' || process.platform === 'darwin')(
|
||||
'refuses a case variant of a sensitive home folder (case-insensitive file systems)',
|
||||
() => {
|
||||
expect(validateProjectPath(path.join(state.home, '.SSH'))).toMatch(/sensitive directory/i);
|
||||
expect(validateProjectPath(path.join(state.home, '.Config', 'nvim'))).toMatch(/sensitive directory/i);
|
||||
expect(validateProjectPath(path.join(state.home.toUpperCase(), '.config'))).toMatch(
|
||||
/sensitive directory/i
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.runIf(process.platform === 'win32')('refuses \\\\?\\ and \\\\.\\ spellings of a sensitive folder', () => {
|
||||
for (const spelled of [
|
||||
`\\\\?\\${path.join(state.home, '.ssh')}`,
|
||||
`\\\\.\\${path.join(state.home, '.config')}`,
|
||||
`//?/${path.join(state.home, '.ssh').replace(/\\/g, '/')}`,
|
||||
]) {
|
||||
expect(validateProjectPath(spelled), spelled).toMatch(/sensitive directory/i);
|
||||
}
|
||||
for (const spelled of ['\\\\?\\C:\\Windows', '\\\\.\\C:\\Windows', '\\\\?\\c:\\windows\\system32', '\\\\?\\C:\\']) {
|
||||
expect(validateProjectPath(spelled), spelled).toMatch(/sensitive system directory/i);
|
||||
}
|
||||
// The prefix on an ordinary folder changes nothing.
|
||||
expect(validateProjectPath(`\\\\?\\${path.join(state.tmp, 'project')}`)).toBeNull();
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32' && fs.existsSync('C:\\Windows'))(
|
||||
'refuses a junction to a Windows system folder',
|
||||
() => {
|
||||
const windows = link('C:\\Windows', path.join(state.tmp, 'elsewhere', 'windows'));
|
||||
expect(validateProjectPath(windows)).toMatch(/sensitive system directory/i);
|
||||
}
|
||||
);
|
||||
|
||||
// On macOS `/etc` is itself a link to `/private/etc`, so this also checks that
|
||||
// the system folders are compared by their own real paths.
|
||||
it.runIf(process.platform !== 'win32')('refuses a symlink to a system folder', () => {
|
||||
const etc = link('/etc', path.join(state.tmp, 'elsewhere', 'etc'));
|
||||
expect(validateProjectPath(etc)).toMatch(/sensitive system directory/i);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'darwin')('refuses /private/etc, the folder /etc names on macOS', () => {
|
||||
expect(validateProjectPath('/private/etc')).toMatch(/sensitive system directory/i);
|
||||
});
|
||||
});
|
||||
|
||||
// #1930 through the MCP entry point, against a real index of the folder.
|
||||
describe('MCP projectPath into a sensitive folder (#1930)', () => {
|
||||
const { state, link } = sensitiveFixture();
|
||||
let handler: ToolHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = path.join(state.home, '.config');
|
||||
fs.mkdirSync(path.join(config, 'tool'));
|
||||
fs.writeFileSync(
|
||||
path.join(config, 'tool', 'secrets.ts'),
|
||||
"export function readToken(): string { return 'FAKE-TOKEN-1930'; }\n"
|
||||
);
|
||||
const indexed = await CodeGraph.init(config, { index: true });
|
||||
indexed.close();
|
||||
// Without the seam an unrefused path fails to load the engine here instead
|
||||
// of opening the folder's index, which would hide a leak.
|
||||
__setLoadCodeGraphForTests(CodeGraph);
|
||||
handler = new ToolHandler(null);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handler.closeAll();
|
||||
__setLoadCodeGraphForTests(null);
|
||||
});
|
||||
|
||||
it('refuses a link to the folder, and a missing folder under it', async () => {
|
||||
const config = path.join(state.home, '.config');
|
||||
const viaLink = link(config, path.join(state.tmp, 'elsewhere', 'config'));
|
||||
// A path that doesn't exist skips the up-front check and walks up to the
|
||||
// folder's own index, so the root the walk lands on is checked too.
|
||||
for (const projectPath of [viaLink, path.join(config, 'no-such-dir'), path.join(viaLink, 'no-such-dir')]) {
|
||||
const result = await handler.execute('codegraph_node', {
|
||||
symbol: 'readToken',
|
||||
includeCode: true,
|
||||
projectPath,
|
||||
});
|
||||
const text = result.content.map((c) => c.text).join('\n');
|
||||
expect(result.isError, projectPath).toBe(true);
|
||||
expect(text, projectPath).toMatch(/sensitive directory/i);
|
||||
expect(text, projectPath).not.toContain('FAKE-TOKEN-1930');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Input Validation', () => {
|
||||
let testDir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
@@ -521,6 +521,28 @@ describe('resolveProjectFile — the source read chokepoint', () => {
|
||||
expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
|
||||
});
|
||||
|
||||
// #1930: the root is checked as the folder it really names, so a root that
|
||||
// is a link (a junction on Windows) to a sensitive folder is refused too.
|
||||
it('refuses a root that is a link to a sensitive home folder', () => {
|
||||
const home = path.join(tempDir, 'home');
|
||||
fs.mkdirSync(path.join(home, '.ssh'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, '.ssh', 'id_ed25519'), 'FAKE-KEY\n');
|
||||
const root = path.join(tempDir, 'ssh-link');
|
||||
fs.symlinkSync(path.join(home, '.ssh'), root, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
try {
|
||||
expect(() => resolveProjectFile(root, 'id_ed25519')).toThrow(/sensitive directory/i);
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
fs.unlinkSync(root);
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
|
||||
const link = path.join(projectRoot, 'src', 'escape.ts');
|
||||
fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
|
||||
|
||||
@@ -217,6 +217,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
|
||||
|
||||
- **Saved trails stay inside your project.** When `.codegraph/ui` was a link to a folder somewhere else, saving a trail wrote it into that folder, even though the save then reported an error, and a trail file that was itself a link to a file outside the project was listed and opened like one of yours. Both are now refused, and links that stay inside the project keep working. Thanks @danusha2345.
|
||||
|
||||
- **`codegraph ui` won't open a sensitive folder by another name.** A link or junction to a folder like `~/.ssh` or `~/.config`, a differently capitalized name on Windows and macOS, or a `\\?\` spelling on Windows got past the check that keeps such a folder from being opened, and the viewer then showed the files inside. They are now refused like the folder itself. Thanks @mixxer for finding it.
|
||||
|
||||
- **Saved trails record the right author when one process serves several projects.** An app embedding the viewer for more than one project wrote the first project's git user name on every trail it saved; each project now uses its own. Thanks @danusha2345.
|
||||
|
||||
- **`codegraph ui` on Windows moves on from a port another program holds.** Windows refuses a port that another program keeps for itself, or one inside a range it reserves, with a different error than a port that is simply in use, so the viewer gave up at the first such port instead of trying the next one, and blamed ports below 1024 needing elevated privileges — a rule Windows doesn't have. It now moves on to the next free port, and when none is left, or the port you pinned with `--port` is refused, it says what Windows did and how to list the reserved ranges. Thanks @ijbranch for the report and @sx4im. (#2299)
|
||||
|
||||
@@ -2382,6 +2382,14 @@ export class ToolHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// The root the walk landed on gets the same refusal. A path that doesn't
|
||||
// exist skips the check above, so `~/.config/no-such-dir` would otherwise
|
||||
// walk up into an index of `~/.config` (#1930).
|
||||
const rootError = validateProjectPath(resolvedRoot);
|
||||
if (rootError) {
|
||||
throw new PathRefusalError(rootError);
|
||||
}
|
||||
|
||||
const cg = this.openProjectRoot(resolvedRoot, canonicalRoot);
|
||||
// The walk above crosses git boundaries. A nested repository the ancestor
|
||||
// index leaves out (typically gitignored) would otherwise be answered from
|
||||
|
||||
+131
-8
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// ============================================================
|
||||
@@ -46,6 +47,22 @@ const SENSITIVE_PATHS = new Set([
|
||||
'c:\\', 'c:\\windows', 'c:\\windows\\system32',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Credential and config folders in the home directory. Unlike the system set,
|
||||
* everything under them is refused too.
|
||||
*/
|
||||
const SENSITIVE_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.config'];
|
||||
|
||||
/**
|
||||
* How long the real paths of the sensitive home folders are reused. The viewer
|
||||
* re-checks its root on every file it reads, so they are not re-read per check,
|
||||
* but a folder linked somewhere new while the process runs is noticed soon.
|
||||
*/
|
||||
const SENSITIVE_HOME_TTL_MS = 2_000;
|
||||
|
||||
let sensitiveHomeCache: { home: string; expires: number; trees: string[] } | undefined;
|
||||
let sensitiveSystemRealPaths: Set<string> | undefined;
|
||||
|
||||
/**
|
||||
* Config "languages" whose nodes are pure key/value DATA lifted from a config
|
||||
* file (e.g. Spring `application.{yml,properties}`), not source code.
|
||||
@@ -183,6 +200,94 @@ export function validatePathWithinRoot(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path in the form the sensitive-folder check compares: lower-cased where the
|
||||
* file system ignores case by default (NTFS, APFS), so `~/.SSH` is `~/.ssh`.
|
||||
*/
|
||||
function foldPathCase(p: string): string {
|
||||
return process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p;
|
||||
}
|
||||
|
||||
/** Whether `child` is `parent` or lies under it. Both are already folded. */
|
||||
function isSameOrUnder(child: string, parent: string): boolean {
|
||||
if (child === parent) return true;
|
||||
// A root (`C:\`, `/`) already ends with its separator.
|
||||
return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain spelling of a Win32 namespace path: `\\?\C:\x` and `\\.\C:\x` are
|
||||
* `C:\x`, and `\\?\UNC\host\share` is `\\host\share`. `path.resolve` keeps the
|
||||
* prefix, so a string comparison would never see the folder behind it. Other
|
||||
* namespace paths (a `\\?\Volume{…}\` name, a device) are returned unchanged;
|
||||
* the real path the caller also checks names their folder.
|
||||
*/
|
||||
function stripWin32NamespacePrefix(p: string): string {
|
||||
if (process.platform !== 'win32') return p;
|
||||
const prefix = /^[\\/]{2}[?.][\\/](UNC[\\/])?/i.exec(p);
|
||||
if (!prefix) return p;
|
||||
const rest = p.slice(prefix[0].length);
|
||||
if (prefix[1]) return `\\\\${rest}`;
|
||||
return /^[a-z]:[\\/]/i.test(rest) ? rest : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder a path really names: links (symlinks, junctions, a `subst` drive)
|
||||
* followed, and the case and long names stored on disk. Null when it can't be
|
||||
* resolved, most often because it doesn't exist; the lexical check still runs.
|
||||
*/
|
||||
function nativeRealPath(p: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync.native(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The system set's own real paths, folded. macOS keeps `/etc`, `/tmp` and
|
||||
* `/var` under `/private`, so a project whose real path is `/private/etc` is
|
||||
* `/etc`. Read once: these folders don't move.
|
||||
*/
|
||||
function sensitiveSystemPaths(): Set<string> {
|
||||
if (!sensitiveSystemRealPaths) {
|
||||
sensitiveSystemRealPaths = new Set();
|
||||
for (const entry of SENSITIVE_PATHS) {
|
||||
// Only this platform's entries: on Windows `/` would resolve to the root
|
||||
// of the current drive and refuse every project at a drive root.
|
||||
const native = process.platform === 'win32' ? /^[a-z]:\\/.test(entry) : entry.startsWith('/');
|
||||
if (!native) continue;
|
||||
const real = nativeRealPath(entry);
|
||||
if (real) sensitiveSystemRealPaths.add(foldPathCase(real));
|
||||
}
|
||||
}
|
||||
return sensitiveSystemRealPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sensitive home folders, folded: as spelled under the home directory,
|
||||
* under the home's real path (a linked home), and each folder's own real path
|
||||
* (a `~/.ssh` that is itself a link into a dotfiles checkout).
|
||||
*/
|
||||
function sensitiveHomeTrees(): string[] {
|
||||
const home = os.homedir();
|
||||
const now = Date.now();
|
||||
if (sensitiveHomeCache && sensitiveHomeCache.home === home && now < sensitiveHomeCache.expires) {
|
||||
return sensitiveHomeCache.trees;
|
||||
}
|
||||
const trees = new Set<string>();
|
||||
const realHome = nativeRealPath(home);
|
||||
for (const dir of SENSITIVE_HOME_DIRS) {
|
||||
const spelled = path.join(home, dir);
|
||||
trees.add(foldPathCase(spelled));
|
||||
if (realHome) trees.add(foldPathCase(path.join(realHome, dir)));
|
||||
const real = nativeRealPath(spelled);
|
||||
if (real) trees.add(foldPathCase(real));
|
||||
}
|
||||
sensitiveHomeCache = { home, expires: now + SENSITIVE_HOME_TTL_MS, trees: [...trees] };
|
||||
return sensitiveHomeCache.trees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is a safe project root directory.
|
||||
*
|
||||
@@ -190,24 +295,42 @@ export function validatePathWithinRoot(
|
||||
* a real, existing directory. Used at MCP and API entry points
|
||||
* to prevent arbitrary directory access.
|
||||
*
|
||||
* The path is checked both as spelled and as the folder it really names, so a
|
||||
* link to `~/.ssh`, a case variant (`~/.SSH`) or a `\\?\` spelling is refused
|
||||
* like the folder itself (#1930). It is kept to one realpath per call, since the
|
||||
* viewer calls this for every file it reads: the sensitive folders' own real
|
||||
* paths are cached and re-read every couple of seconds.
|
||||
*
|
||||
* @param dirPath - The path to validate
|
||||
* @returns An error message if invalid, or null if valid
|
||||
*/
|
||||
export function validateProjectPath(dirPath: string): string | null {
|
||||
const resolved = path.resolve(dirPath);
|
||||
const lexical = path.resolve(stripWin32NamespacePrefix(dirPath));
|
||||
const real = nativeRealPath(lexical);
|
||||
const candidates = [lexical];
|
||||
if (real && real !== lexical) candidates.push(real);
|
||||
const named = (candidate: string): string =>
|
||||
candidate === resolved ? resolved : `${resolved} (resolves to ${candidate})`;
|
||||
|
||||
// Block sensitive system directories
|
||||
if (SENSITIVE_PATHS.has(resolved) || SENSITIVE_PATHS.has(resolved.toLowerCase())) {
|
||||
return `Refusing to operate on sensitive system directory: ${resolved}`;
|
||||
const systemPaths = sensitiveSystemPaths();
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
SENSITIVE_PATHS.has(candidate) ||
|
||||
SENSITIVE_PATHS.has(candidate.toLowerCase()) ||
|
||||
systemPaths.has(foldPathCase(candidate))
|
||||
) {
|
||||
return `Refusing to operate on sensitive system directory: ${named(candidate)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Also block common sensitive home subdirectories
|
||||
const homeDir = require('os').homedir();
|
||||
const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config'];
|
||||
for (const dir of sensitiveHomeDirs) {
|
||||
const sensitivePath = path.join(homeDir, dir);
|
||||
if (resolved === sensitivePath || resolved.startsWith(sensitivePath + path.sep)) {
|
||||
return `Refusing to operate on sensitive directory: ${resolved}`;
|
||||
const homeTrees = sensitiveHomeTrees();
|
||||
for (const candidate of candidates) {
|
||||
const folded = foldPathCase(candidate);
|
||||
if (homeTrees.some((tree) => isSameOrUnder(folded, tree))) {
|
||||
return `Refusing to operate on sensitive directory: ${named(candidate)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user