Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions __tests__/cli-ui-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
188 changes: 187 additions & 1 deletion __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions __tests__/ui-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions docs/viewer-launch-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading