From 349db830dfbc1f91b44f287a2aa40426756338a9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 18:13:26 -0400 Subject: [PATCH] fix(settings): confine dashboard settings paths (#1231) --- src/web-server/routes/settings-routes.ts | 121 ++++++++-- ...tings-routes-image-analysis-status.test.ts | 210 +++++++++++++++++- 2 files changed, 316 insertions(+), 15 deletions(-) diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 2faa3f4c..2c739fb9 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -63,13 +63,90 @@ const { logRouteError, respondInternalError } = createRouteErrorHelpers('setting function resolvePathWithin(basePath: string, targetPath: string): string { const resolvedBase = path.resolve(basePath); - const resolvedTarget = path.resolve(targetPath); - if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) { + const resolvedTarget = path.resolve( + path.isAbsolute(targetPath) ? targetPath : path.join(resolvedBase, targetPath) + ); + if (!isPathWithin(resolvedBase, resolvedTarget)) { throw new Error('Invalid settings path'); } return resolvedTarget; } +function normalizePathForComparison(targetPath: string): string { + const resolvedPath = path.resolve(targetPath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function isPathWithin(basePath: string, targetPath: string): boolean { + const normalizedBase = normalizePathForComparison(basePath); + const normalizedTarget = normalizePathForComparison(targetPath); + const relative = path.relative(normalizedBase, normalizedTarget); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function safeRealPath(targetPath: string): string | null { + try { + return fs.realpathSync.native(targetPath); + } catch { + return null; + } +} + +function getRealCcsDir(): string { + return safeRealPath(getCcsDir()) ?? path.resolve(getCcsDir()); +} + +function requirePathWithinRealCcsDir(targetPath: string): void { + if (!isPathWithin(getRealCcsDir(), targetPath)) { + throw new Error('Invalid settings path'); + } +} + +function requireExistingSettingsPathWithinCcs(settingsPath: string): boolean { + const realSettingsPath = safeRealPath(settingsPath); + if (!realSettingsPath) { + return false; + } + + requirePathWithinRealCcsDir(realSettingsPath); + return true; +} + +function findExistingParentRealPath(targetPath: string): string | null { + let currentPath = path.dirname(targetPath); + + while (true) { + const realParentPath = safeRealPath(currentPath); + if (realParentPath) { + return realParentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return null; + } + currentPath = parentPath; + } +} + +function requireWritableSettingsPathWithinCcs(settingsPath: string): void { + const realSettingsPath = safeRealPath(settingsPath); + if (realSettingsPath) { + requirePathWithinRealCcsDir(realSettingsPath); + return; + } + + const realParentPath = findExistingParentRealPath(settingsPath); + if (!realParentPath) { + throw new Error('Invalid settings path'); + } + requirePathWithinRealCcsDir(realParentPath); +} + +function resolveSettingsCandidatePath(ccsDir: string, settingsPath: string): string { + return resolvePathWithin(ccsDir, expandPath(settingsPath)); +} + function requireSensitiveLocalAccess(req: Request, res: Response): boolean { return requireLocalAccessWhenAuthDisabled( req, @@ -114,24 +191,25 @@ function resolveSettingsPath(profileOrVariant: string): string { path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`) ); } - return path.resolve(resolveProviderSettingsPath(directProvider)); + return resolvePathWithin(resolvedCcsDir, resolveProviderSettingsPath(directProvider)); } // Check if this is a variant const variants = listVariants(); const variant = variants[profileOrVariant]; if (variant?.settings) { - return path.resolve(expandPath(variant.settings)); + return resolveSettingsCandidatePath(resolvedCcsDir, variant.settings); } + let configuredSettingsPath: string | undefined; try { - const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant]; - if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) { - return path.resolve(expandPath(configuredSettingsPath)); - } + configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant]; } catch { // Fall back to the conventional ~/.ccs/.settings.json path below. } + if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) { + return resolveSettingsCandidatePath(resolvedCcsDir, configuredSettingsPath); + } // Regular profile settings return resolvePathWithin( @@ -328,13 +406,21 @@ async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, setti } function writeSettingsAtomically(settingsPath: string, settings: Settings): void { + requireWritableSettingsPathWithinCcs(settingsPath); const tempPath = `${settingsPath}.tmp.${process.pid}`; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + requireWritableSettingsPathWithinCcs(tempPath); + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx' }); fs.renameSync(tempPath, settingsPath); } function withSettingsFileLock(settingsPath: string, callback: () => T): T { - const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath); + requireWritableSettingsPathWithinCcs(settingsPath); + const lockTarget = safeRealPath(settingsPath) + ? settingsPath + : findExistingParentRealPath(settingsPath); + if (!lockTarget) { + throw new Error('Invalid settings path'); + } let release: (() => void) | undefined; try { @@ -357,6 +443,10 @@ function loadCanonicalProfileSettings( persist = false, strictPersist = false ): Settings { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { + throw new Error('Settings not found'); + } + const loaded = loadSettings(settingsPath); const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded); @@ -399,7 +489,7 @@ router.get('/:profile', async (req: Request, res: Response): Promise => { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); return; } @@ -435,7 +525,7 @@ router.get('/:profile/raw', async (req: Request, res: Response): Promise = const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.status(404).json({ error: 'Settings not found' }); return; } @@ -556,11 +646,13 @@ router.put('/:profile', (req: Request, res: Response): void => { const existingContent = fs.readFileSync(settingsPath, 'utf8'); if (existingContent !== newContent) { const backupDir = path.join(ccsDir, 'backups'); + requireWritableSettingsPathWithinCcs(path.join(backupDir, '.settings-backup-probe')); if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + requireWritableSettingsPathWithinCcs(backupPath); fs.copyFileSync(settingsPath, backupPath); } } else { @@ -569,7 +661,8 @@ router.put('/:profile', (req: Request, res: Response): void => { } const tempPath = `${settingsPath}.tmp.${process.pid}`; - fs.writeFileSync(tempPath, newContent); + requireWritableSettingsPathWithinCcs(tempPath); + fs.writeFileSync(tempPath, newContent, { flag: 'wx' }); fs.renameSync(tempPath, settingsPath); newMtime = fs.statSync(settingsPath).mtime.getTime(); }); @@ -604,7 +697,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { + if (!requireExistingSettingsPathWithinCcs(settingsPath)) { res.json({ presets: [] }); return; } diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index 2352830c..b97413ec 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -47,6 +47,24 @@ function writeProfileSettings( return settingsPath; } +function createSymlinkIfAvailable( + targetPath: string, + linkPath: string, + type?: fs.symlink.Type +): boolean { + try { + fs.symlinkSync(targetPath, linkPath, type); + return true; + } catch (error) { + const code = (error as { code?: string }).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + console.warn('Skipping symlink escape regression: symlink creation unavailable on Windows.'); + return false; + } + throw error; + } +} + describe('settings-routes image-analysis status', () => { let server: Server; let baseUrl = ''; @@ -199,7 +217,7 @@ describe('settings-routes image-analysis status', () => { }); it('uses the configured custom settings path for status and persistence diagnostics', async () => { - const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + const customSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'foo.bar.settings.json'); writeJson(path.join(tempHome, '.ccs', 'config.json'), { profiles: { 'foo.bar': customSettingsPath, @@ -231,6 +249,196 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.hookInstalled).toBe(true); }); + it('resolves configured profile and variant relative settings paths under the CCS directory', async () => { + const profileSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'relative.settings.json'); + const variantSettingsPath = path.join( + tempHome, + '.ccs', + 'variants', + 'relative-var.settings.json' + ); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + relative: 'profiles/relative.settings.json', + }, + cliproxy: { + 'relative-var': { + provider: 'gemini', + settings: 'variants/relative-var.settings.json', + }, + }, + }); + writeProfileSettings( + tempHome, + 'relative', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'relative-profile-key', + }, + profileSettingsPath + ); + writeProfileSettings( + tempHome, + 'relative-var', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'relative-variant-key', + }, + variantSettingsPath + ); + + const profileResponse = await fetch(`${baseUrl}/api/settings/relative/raw`); + expect(profileResponse.status).toBe(200); + const profileBody = (await profileResponse.json()) as { path: string }; + expect(profileBody.path).toBe(profileSettingsPath); + + const variantResponse = await fetch(`${baseUrl}/api/settings/relative-var/raw`); + expect(variantResponse.status).toBe(200); + const variantBody = (await variantResponse.json()) as { path: string }; + expect(variantBody.path).toBe(variantSettingsPath); + }); + + it('rejects configured profile settings paths outside the CCS directory', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside', 'escaped.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + escaped: outsideSettingsPath, + }, + }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'outside-secret', + }, + }); + + const readResponse = await fetch(`${baseUrl}/api/settings/escaped/raw`); + expect(readResponse.status).toBe(500); + + const writeResponse = await fetch(`${baseUrl}/api/settings/escaped`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'overwritten-secret', + }, + }, + }), + }); + expect(writeResponse.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('outside-secret'); + }); + + it('rejects raw reads through settings-file symlink escapes', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside-raw.settings.json'); + const linkedSettingsPath = path.join(tempHome, '.ccs', 'linked.settings.json'); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'raw-outside-secret', + }, + }); + fs.mkdirSync(path.dirname(linkedSettingsPath), { recursive: true }); + if (!createSymlinkIfAvailable(outsideSettingsPath, linkedSettingsPath)) { + return; + } + + const response = await fetch(`${baseUrl}/api/settings/linked/raw`); + expect(response.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('raw-outside-secret'); + }); + + it('rejects variant settings paths outside the CCS directory', async () => { + const outsideSettingsPath = path.join(tempHome, 'outside-variant', 'escaped.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: {}, + cliproxy: { + evilvar: { + provider: 'gemini', + settings: outsideSettingsPath, + }, + }, + }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-outside-secret', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/evilvar/raw`); + expect(response.status).toBe(500); + + const writeResponse = await fetch(`${baseUrl}/api/settings/evilvar`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-overwritten-secret', + }, + }, + }), + }); + expect(writeResponse.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-outside-secret'); + }); + + it('rejects variant PUT settings paths that escape through symlinked parents', async () => { + const outsideDir = path.join(tempHome, 'outside-variant-link'); + const outsideSettingsPath = path.join(outsideDir, 'escaped.settings.json'); + const linkDir = path.join(tempHome, '.ccs', 'variant-link'); + const linkedSettingsPath = path.join(linkDir, 'escaped.settings.json'); + fs.mkdirSync(outsideDir, { recursive: true }); + writeJson(outsideSettingsPath, { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-link-original', + }, + }); + fs.mkdirSync(path.dirname(linkDir), { recursive: true }); + if (!createSymlinkIfAvailable(outsideDir, linkDir, 'dir')) { + return; + } + + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: {}, + cliproxy: { + 'linked-var': { + provider: 'gemini', + settings: linkedSettingsPath, + }, + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/linked-var`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: 'variant-link-overwritten', + }, + }, + }), + }); + expect(response.status).toBe(500); + + const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as { + env: { ANTHROPIC_AUTH_TOKEN: string }; + }; + expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-link-original'); + }); + it('previews image-analysis status from unsaved editor settings', async () => { writeProfileSettings(tempHome, 'glm', { ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',