mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
fix(settings): confine dashboard settings paths (#1231)
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user