fix(settings): confine dashboard settings paths (#1231)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 18:13:26 -04:00
committed by GitHub
parent a840793a9b
commit 349db830df
2 changed files with 316 additions and 15 deletions
+107 -14
View File
@@ -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/<profile>.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<T>(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<void> => {
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<void> =
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;
}
@@ -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',