mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 14:16:16 +00:00
fix(config): harden dashboard config and rollback paths
This commit is contained in:
1 parent
c923f51cf5
commit
37251cd1f8
4 files changed
+724
-16
No files matched your search
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager';
|
||||
import {
|
||||
loadMigrationCheckData,
|
||||
migrate,
|
||||
resolveManagedBackupPath,
|
||||
rollback,
|
||||
} from '../../../src/config/migration-manager';
|
||||
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
@@ -61,13 +66,13 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(
|
||||
result.migratedFiles.some((entry) =>
|
||||
entry.includes(`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`)
|
||||
entry.includes(
|
||||
`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.migratedFiles.some((entry) =>
|
||||
entry.includes(`(settings: ${kimiSettingsPath})`)
|
||||
)
|
||||
result.migratedFiles.some((entry) => entry.includes(`(settings: ${kimiSettingsPath})`))
|
||||
).toBe(false);
|
||||
expect(
|
||||
result.warnings.some((warning) =>
|
||||
@@ -215,4 +220,47 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.work.continuity_mode).toBe('standard');
|
||||
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
|
||||
});
|
||||
|
||||
it('resolves only direct managed backup directories under ~/.ccs', () => {
|
||||
const managedBackupPath = path.join(ccsDir, 'backup-v1-2026-03-24');
|
||||
const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-external-'));
|
||||
const symlinkedBackupPath = path.join(ccsDir, 'backup-v1-symlink');
|
||||
|
||||
fs.mkdirSync(managedBackupPath, { recursive: true });
|
||||
fs.symlinkSync(externalBackupPath, symlinkedBackupPath, 'dir');
|
||||
|
||||
expect(resolveManagedBackupPath(managedBackupPath)).toBe(
|
||||
fs.realpathSync.native(managedBackupPath)
|
||||
);
|
||||
expect(resolveManagedBackupPath(externalBackupPath)).toBeNull();
|
||||
expect(resolveManagedBackupPath(symlinkedBackupPath)).toBeNull();
|
||||
|
||||
fs.rmSync(externalBackupPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects rollback from directories outside managed CCS backups', async () => {
|
||||
const externalBackupPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-backup-invalid-'));
|
||||
|
||||
expect(await rollback(externalBackupPath)).toBe(false);
|
||||
|
||||
fs.rmSync(externalBackupPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('restores files only from managed CCS backup directories', async () => {
|
||||
const backupPath = path.join(ccsDir, 'backup-v1-2026-03-24');
|
||||
fs.mkdirSync(backupPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(backupPath, 'config.json'), JSON.stringify({ restored: true }));
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 8\n');
|
||||
fs.mkdirSync(path.join(ccsDir, 'cache'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'cache', 'usage.json'), '{}');
|
||||
|
||||
const success = await rollback(backupPath);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'config.yaml'))).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8'))).toEqual({
|
||||
restored: true,
|
||||
});
|
||||
expect(fs.existsSync(path.join(ccsDir, 'usage-cache.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import configRoutes from '../../../src/web-server/routes/config-routes';
|
||||
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
|
||||
import { loadUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
||||
return fetch(`${baseUrl}${routePath}`, {
|
||||
@@ -31,12 +31,21 @@ async function postJson(baseUrl: string, routePath: string, body?: unknown): Pro
|
||||
describe('web-server config-routes account context validation', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let forcedRemoteAddress = '127.0.0.1';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalDashboardAuthEnabled: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use('/api/config', configRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -63,19 +72,48 @@ describe('web-server config-routes account context validation', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
forcedRemoteAddress = '127.0.0.1';
|
||||
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalDashboardAuthEnabled !== undefined) {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||
} else {
|
||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks remote access when dashboard auth is disabled', async () => {
|
||||
forcedRemoteAddress = '10.10.0.24';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/config/format`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'Local configuration endpoints require localhost access when dashboard auth is disabled.',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows remote access when dashboard auth is enabled', async () => {
|
||||
forcedRemoteAddress = '10.10.0.24';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/config/format`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects invalid account context_mode values', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
@@ -216,6 +254,191 @@ describe('web-server config-routes account context validation', () => {
|
||||
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('redacts secrets from config and raw YAML responses', async () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.global_env = {
|
||||
enabled: true,
|
||||
env: {
|
||||
DEBUG: '1',
|
||||
OPENAI_API_KEY: 'sk-test-123456',
|
||||
},
|
||||
};
|
||||
config.cliproxy_server = {
|
||||
remote: {
|
||||
enabled: true,
|
||||
host: 'proxy.example.com',
|
||||
protocol: 'https',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'management-secret',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
config.dashboard_auth = {
|
||||
enabled: true,
|
||||
username: 'admin',
|
||||
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
const yamlPath = path.join(tempHome, '.ccs', 'config.yaml');
|
||||
const rawYaml = fs.readFileSync(yamlPath, 'utf8');
|
||||
fs.writeFileSync(yamlPath, `# custom comment\n${rawYaml}`);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/config`);
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as typeof config;
|
||||
|
||||
expect(payload.global_env?.env.DEBUG).toBe('1');
|
||||
expect(payload.global_env?.env.OPENAI_API_KEY).toBe('[redacted]');
|
||||
expect(payload.cliproxy_server?.remote.auth_token).toBe('[redacted]');
|
||||
expect(payload.cliproxy_server?.remote.management_key).toBe('[redacted]');
|
||||
expect(payload.dashboard_auth?.password_hash).toBe('[redacted]');
|
||||
|
||||
const rawResponse = await fetch(`${baseUrl}/api/config/raw`);
|
||||
expect(rawResponse.status).toBe(200);
|
||||
const rawConfig = await rawResponse.text();
|
||||
|
||||
expect(rawConfig).toContain('# custom comment');
|
||||
expect(rawConfig).toContain('DEBUG: "1"');
|
||||
expect(rawConfig).toContain('OPENAI_API_KEY: [redacted]');
|
||||
expect(rawConfig).not.toContain('sk-test-123456');
|
||||
expect(rawConfig).not.toContain('remote-auth-token');
|
||||
expect(rawConfig).not.toContain('management-secret');
|
||||
});
|
||||
|
||||
it('preserves redacted secrets when saving a sanitized config payload', async () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.global_env = {
|
||||
enabled: true,
|
||||
env: {
|
||||
DEBUG: '1',
|
||||
OPENAI_API_KEY: 'sk-test-123456',
|
||||
},
|
||||
};
|
||||
config.cliproxy_server = {
|
||||
remote: {
|
||||
enabled: true,
|
||||
host: 'proxy.example.com',
|
||||
protocol: 'https',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'management-secret',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
config.dashboard_auth = {
|
||||
enabled: true,
|
||||
username: 'admin',
|
||||
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/config`);
|
||||
const sanitized = (await getResponse.json()) as typeof config;
|
||||
sanitized.cliproxy.kiro_no_incognito = true;
|
||||
|
||||
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
||||
expect(putResponse.status).toBe(200);
|
||||
|
||||
const savedConfig = loadUnifiedConfig();
|
||||
expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true);
|
||||
expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456');
|
||||
expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token');
|
||||
expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret');
|
||||
expect(savedConfig?.dashboard_auth?.password_hash).toBe(
|
||||
'$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows deleting visible global env keys while preserving redacted secrets', async () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.global_env = {
|
||||
enabled: true,
|
||||
env: {
|
||||
DEBUG: '1',
|
||||
OPENAI_API_KEY: 'sk-test-123456',
|
||||
},
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/config`);
|
||||
const sanitized = (await getResponse.json()) as typeof config;
|
||||
if (sanitized.global_env?.env) {
|
||||
delete sanitized.global_env.env.DEBUG;
|
||||
}
|
||||
|
||||
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
||||
expect(putResponse.status).toBe(200);
|
||||
|
||||
const savedConfig = loadUnifiedConfig();
|
||||
expect(savedConfig?.global_env?.env.DEBUG).toBeUndefined();
|
||||
expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456');
|
||||
});
|
||||
|
||||
it('allows deleting visible cliproxy_server and dashboard_auth fields while preserving secrets', async () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.cliproxy_server = {
|
||||
remote: {
|
||||
enabled: true,
|
||||
host: 'proxy.example.com',
|
||||
port: 8443,
|
||||
protocol: 'https',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'management-secret',
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
},
|
||||
local: {
|
||||
port: 8317,
|
||||
auto_start: true,
|
||||
},
|
||||
};
|
||||
config.dashboard_auth = {
|
||||
enabled: true,
|
||||
username: 'admin',
|
||||
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/config`);
|
||||
const sanitized = (await getResponse.json()) as typeof config;
|
||||
if (sanitized.cliproxy_server?.remote) {
|
||||
delete sanitized.cliproxy_server.remote.port;
|
||||
}
|
||||
if (sanitized.dashboard_auth) {
|
||||
delete sanitized.dashboard_auth.username;
|
||||
}
|
||||
|
||||
const putResponse = await putJson(baseUrl, '/api/config', sanitized);
|
||||
expect(putResponse.status).toBe(200);
|
||||
|
||||
const savedConfig = loadUnifiedConfig();
|
||||
expect(savedConfig?.cliproxy_server?.remote.port).toBeUndefined();
|
||||
expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token');
|
||||
expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret');
|
||||
expect(savedConfig?.dashboard_auth?.username).toBeUndefined();
|
||||
expect(savedConfig?.dashboard_auth?.password_hash).toBe(
|
||||
'$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns alreadyMigrated when migration is not needed', async () => {
|
||||
const response = await postJson(baseUrl, '/api/config/migrate');
|
||||
|
||||
@@ -231,4 +454,42 @@ describe('web-server config-routes account context validation', () => {
|
||||
expect(payload.warnings).toEqual([]);
|
||||
expect(payload.alreadyMigrated).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes unmanaged backup directories from format response', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const managedBackup = path.join(ccsDir, 'backup-v1-2026-03-24');
|
||||
const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-format-backup-'));
|
||||
const symlinkBackup = path.join(ccsDir, 'backup-v1-symlink');
|
||||
|
||||
fs.mkdirSync(managedBackup, { recursive: true });
|
||||
fs.symlinkSync(externalBackup, symlinkBackup, 'dir');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/config/format`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
format: string;
|
||||
migrationNeeded: boolean;
|
||||
backups: string[];
|
||||
};
|
||||
expect(payload.backups).toContain(managedBackup);
|
||||
expect(payload.backups).not.toContain(symlinkBackup);
|
||||
|
||||
fs.rmSync(externalBackup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects rollback paths outside managed CCS backups', async () => {
|
||||
const externalBackup = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-external-backup-'));
|
||||
|
||||
const response = await postJson(baseUrl, '/api/config/rollback', {
|
||||
backupPath: externalBackup,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid backupPath. Must reference a managed CCS migration backup directory.',
|
||||
});
|
||||
|
||||
fs.rmSync(externalBackup, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user