From e8e31ce960914741c818026870230b5cb0780f9c Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 12:53:26 +0900 Subject: [PATCH 1/4] fix(security): reject symlinked sensitive project paths --- __tests__/security.test.ts | 11 +++++++++++ src/utils.ts | 25 ++++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index fd075828a..b60443587 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -382,6 +382,17 @@ describe('validateProjectPath — sensitive directory blocking', () => { } }); + it.runIf(process.platform !== 'win32')('blocks a symlink whose real path resolves to a sensitive directory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-validate-link-')); + const link = path.join(dir, 'allowed-looking-link'); + try { + fs.symlinkSync(path.join(os.homedir(), '.ssh'), link, 'dir'); + expect(validateProjectPath(link)).toMatch(/sensitive system directory|sensitive directory/i); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are // both blocked. path.resolve is platform-specific, so this only runs on Windows. diff --git a/src/utils.ts b/src/utils.ts index c07e8c7dd..823153d86 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -195,19 +195,26 @@ export function validatePathWithinRoot( */ export function validateProjectPath(dirPath: string): string | null { const resolved = path.resolve(dirPath); - - // Block sensitive system directories - if (SENSITIVE_PATHS.has(resolved) || SENSITIVE_PATHS.has(resolved.toLowerCase())) { - return `Refusing to operate on sensitive system directory: ${resolved}`; + let realResolved = resolved; + try { + realResolved = fs.realpathSync(resolved); + } catch { + // Preserve the existing error handling for paths that do not exist yet. } + const pathsToCheck = realResolved === resolved ? [resolved] : [resolved, realResolved]; - // Also block common sensitive home subdirectories + // Block sensitive system directories 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}`; + for (const candidate of pathsToCheck) { + if (SENSITIVE_PATHS.has(candidate) || SENSITIVE_PATHS.has(candidate.toLowerCase())) { + return `Refusing to operate on sensitive system directory: ${candidate}`; + } + for (const dir of sensitiveHomeDirs) { + const sensitivePath = path.join(homeDir, dir); + if (candidate === sensitivePath || candidate.startsWith(sensitivePath + path.sep)) { + return `Refusing to operate on sensitive directory: ${candidate}`; + } } } From 9f0f0657f638c5cd871029e1dde36f1a124f19ff Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 17:15:06 +0900 Subject: [PATCH 2/4] fix: reject symlinks to canonical system directories --- __tests__/security.test.ts | 2 +- src/utils.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index b60443587..9a31d496a 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -386,7 +386,7 @@ describe('validateProjectPath — sensitive directory blocking', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-validate-link-')); const link = path.join(dir, 'allowed-looking-link'); try { - fs.symlinkSync(path.join(os.homedir(), '.ssh'), link, 'dir'); + fs.symlinkSync('/etc', link, 'dir'); expect(validateProjectPath(link)).toMatch(/sensitive system directory|sensitive directory/i); } finally { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/utils.ts b/src/utils.ts index 823153d86..5df9d11db 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -45,6 +45,9 @@ const SENSITIVE_PATHS = new Set([ '/root', '/boot', '/lib', '/lib64', '/opt', 'c:\\', 'c:\\windows', 'c:\\windows\\system32', ]); +const SENSITIVE_REAL_PATHS = new Set([...SENSITIVE_PATHS].map((entry) => { + try { return fs.realpathSync(entry); } catch { return entry; } +})); /** * Config "languages" whose nodes are pure key/value DATA lifted from a config @@ -207,7 +210,7 @@ export function validateProjectPath(dirPath: string): string | null { const homeDir = require('os').homedir(); const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config']; for (const candidate of pathsToCheck) { - if (SENSITIVE_PATHS.has(candidate) || SENSITIVE_PATHS.has(candidate.toLowerCase())) { + if (SENSITIVE_REAL_PATHS.has(candidate) || SENSITIVE_PATHS.has(candidate.toLowerCase())) { return `Refusing to operate on sensitive system directory: ${candidate}`; } for (const dir of sensitiveHomeDirs) { From 567e4de40de226bb93c19f97477aed7532184251 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 12:40:49 +0900 Subject: [PATCH 3/4] fix(security): resolve symlinked home before path refusal --- __tests__/security.test.ts | 18 ++++++++++++++++++ src/utils.ts | 14 +++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 9a31d496a..7e963480b 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -393,6 +393,24 @@ describe('validateProjectPath — sensitive directory blocking', () => { } }); + it.runIf(process.platform !== 'win32')('blocks sensitive directories when HOME is a symlink', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-home-link-')); + const home = path.join(dir, 'real-home'); + const link = path.join(dir, 'home-link'); + const originalHome = process.env.HOME; + try { + fs.mkdirSync(path.join(home, '.ssh'), { recursive: true }); + fs.symlinkSync(home, link, 'dir'); + process.env.HOME = link; + expect(validateProjectPath(path.join(link, '.ssh'))).toMatch(/sensitive directory/i); + expect(validateProjectPath(path.join(home, '.ssh'))).toMatch(/sensitive directory/i); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are // both blocked. path.resolve is platform-specific, so this only runs on Windows. diff --git a/src/utils.ts b/src/utils.ts index 5df9d11db..d62cbc531 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -208,15 +208,23 @@ export function validateProjectPath(dirPath: string): string | null { // Block sensitive system directories const homeDir = require('os').homedir(); + let realHomeDir = homeDir; + try { + realHomeDir = fs.realpathSync(homeDir); + } catch { + // Keep the lexical home path when it cannot be resolved. + } const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config']; for (const candidate of pathsToCheck) { if (SENSITIVE_REAL_PATHS.has(candidate) || SENSITIVE_PATHS.has(candidate.toLowerCase())) { return `Refusing to operate on sensitive system directory: ${candidate}`; } for (const dir of sensitiveHomeDirs) { - const sensitivePath = path.join(homeDir, dir); - if (candidate === sensitivePath || candidate.startsWith(sensitivePath + path.sep)) { - return `Refusing to operate on sensitive directory: ${candidate}`; + for (const home of [homeDir, realHomeDir]) { + const sensitivePath = path.join(home, dir); + if (candidate === sensitivePath || candidate.startsWith(sensitivePath + path.sep)) { + return `Refusing to operate on sensitive directory: ${candidate}`; + } } } } From 4c8f64415f47228986dcac7e4ad2c7ca6e886797 Mon Sep 17 00:00:00 2001 From: mixxer Date: Sat, 26 Sep 2026 18:51:28 +0900 Subject: [PATCH 4/4] fix(security): resolve symlinked sensitive home directories --- __tests__/security.test.ts | 21 +++++++++++++++++++++ src/utils.ts | 17 +++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 7e963480b..599f1e1d0 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -411,6 +411,27 @@ describe('validateProjectPath — sensitive directory blocking', () => { } }); + it.runIf(process.platform !== 'win32')('blocks a sensitive home directory that is itself a symlink', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ssh-link-')); + const home = path.join(dir, 'home'); + const sshReal = path.join(dir, 'ssh-real'); + const link = path.join(dir, 'project-link'); + const originalHome = process.env.HOME; + try { + fs.mkdirSync(home); + fs.mkdirSync(sshReal); + fs.symlinkSync(sshReal, path.join(home, '.ssh'), 'dir'); + fs.symlinkSync(path.join(home, '.ssh'), link, 'dir'); + process.env.HOME = home; + expect(validateProjectPath(link)).toMatch(/sensitive directory/i); + expect(validateProjectPath(sshReal)).toMatch(/sensitive directory/i); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are // both blocked. path.resolve is platform-specific, so this only runs on Windows. diff --git a/src/utils.ts b/src/utils.ts index d62cbc531..7daba8a2f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -215,16 +215,21 @@ export function validateProjectPath(dirPath: string): string | null { // Keep the lexical home path when it cannot be resolved. } const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config']; + const sensitiveHomePaths = new Set(); + for (const home of [homeDir, realHomeDir]) { + for (const dir of sensitiveHomeDirs) { + const sensitivePath = path.join(home, dir); + sensitiveHomePaths.add(sensitivePath); + try { sensitiveHomePaths.add(fs.realpathSync(sensitivePath)); } catch { /* keep the lexical path */ } + } + } for (const candidate of pathsToCheck) { if (SENSITIVE_REAL_PATHS.has(candidate) || SENSITIVE_PATHS.has(candidate.toLowerCase())) { return `Refusing to operate on sensitive system directory: ${candidate}`; } - for (const dir of sensitiveHomeDirs) { - for (const home of [homeDir, realHomeDir]) { - const sensitivePath = path.join(home, dir); - if (candidate === sensitivePath || candidate.startsWith(sensitivePath + path.sep)) { - return `Refusing to operate on sensitive directory: ${candidate}`; - } + for (const sensitivePath of sensitiveHomePaths) { + if (candidate === sensitivePath || candidate.startsWith(sensitivePath + path.sep)) { + return `Refusing to operate on sensitive directory: ${candidate}`; } } }