mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +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
@@ -26,6 +26,53 @@ import { infoBox, warn } from '../utils/ui';
|
||||
|
||||
const BACKUP_DIR_PREFIX = 'backup-v1-';
|
||||
|
||||
function resolveCanonicalPath(targetPath: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithinDirectory(candidatePath: string, rootPath: string): boolean {
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
const normalizedRoot = path.resolve(rootPath);
|
||||
const relative = path.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function resolveManagedBackupPath(backupPath: string): string | null {
|
||||
const resolvedInput = path.resolve(backupPath);
|
||||
const baseName = path.basename(resolvedInput);
|
||||
|
||||
if (!baseName.startsWith(BACKUP_DIR_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.lstatSync(resolvedInput);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalCcsDir = resolveCanonicalPath(getCcsDir());
|
||||
const canonicalBackupPath = resolveCanonicalPath(resolvedInput);
|
||||
|
||||
if (!isPathWithinDirectory(canonicalBackupPath, canonicalCcsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.dirname(canonicalBackupPath) !== canonicalCcsDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return canonicalBackupPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration result with details about what was migrated.
|
||||
*/
|
||||
@@ -112,6 +159,7 @@ export function getBackupDirectories(): string[] {
|
||||
.readdirSync(ccsDir)
|
||||
.filter((name) => name.startsWith(BACKUP_DIR_PREFIX))
|
||||
.map((name) => path.join(ccsDir, name))
|
||||
.filter((backupPath) => resolveManagedBackupPath(backupPath) !== null)
|
||||
.sort()
|
||||
.reverse(); // Most recent first
|
||||
}
|
||||
@@ -327,9 +375,10 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
||||
*/
|
||||
export async function rollback(backupPath: string): Promise<boolean> {
|
||||
const ccsDir = getCcsDir();
|
||||
const managedBackupPath = resolveManagedBackupPath(backupPath);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
console.error(`[X] Backup not found: ${backupPath}`);
|
||||
if (!managedBackupPath) {
|
||||
console.error(`[X] Invalid backup path: ${backupPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -356,9 +405,9 @@ export async function rollback(backupPath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
// Restore files from backup
|
||||
const files = fs.readdirSync(backupPath);
|
||||
const files = fs.readdirSync(managedBackupPath);
|
||||
for (const file of files) {
|
||||
fs.copyFileSync(path.join(backupPath, file), path.join(ccsDir, file));
|
||||
fs.copyFileSync(path.join(managedBackupPath, file), path.join(ccsDir, file));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as fs from 'fs';
|
||||
import {
|
||||
hasUnifiedConfig,
|
||||
loadUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
mutateUnifiedConfig,
|
||||
getConfigFormat,
|
||||
getConfigYamlPath,
|
||||
} from '../../config/unified-config-loader';
|
||||
@@ -16,15 +16,291 @@ import {
|
||||
migrate,
|
||||
rollback,
|
||||
getBackupDirectories,
|
||||
resolveManagedBackupPath,
|
||||
} from '../../config/migration-manager';
|
||||
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||
import { isUnifiedConfig } from '../../config/unified-config-types';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
isValidContextGroupName,
|
||||
normalizeContextGroupName,
|
||||
} from '../../auth/account-context';
|
||||
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../../config/unified-config-types';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
||||
|
||||
const router = Router();
|
||||
const LOCAL_CONFIG_ERROR =
|
||||
'Local configuration endpoints require localhost access when dashboard auth is disabled.';
|
||||
const REDACTED_SECRET_VALUE = '[redacted]';
|
||||
const RAW_YAML_REDACTED_PATHS = new Set([
|
||||
'cliproxy_server.remote.auth_token',
|
||||
'cliproxy_server.remote.management_key',
|
||||
'dashboard_auth.password_hash',
|
||||
]);
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, LOCAL_CONFIG_ERROR)) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
function redactSecretValue(value: string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.length > 0 ? REDACTED_SECRET_VALUE : value;
|
||||
}
|
||||
|
||||
function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig {
|
||||
return {
|
||||
...config,
|
||||
global_env: config.global_env
|
||||
? {
|
||||
...config.global_env,
|
||||
env: Object.fromEntries(
|
||||
Object.entries(config.global_env.env).map(([key, value]) => [
|
||||
key,
|
||||
isSensitiveKey(key) && value ? REDACTED_SECRET_VALUE : value,
|
||||
])
|
||||
),
|
||||
}
|
||||
: config.global_env,
|
||||
cliproxy_server: config.cliproxy_server
|
||||
? {
|
||||
...config.cliproxy_server,
|
||||
remote: {
|
||||
...config.cliproxy_server.remote,
|
||||
auth_token: redactSecretValue(config.cliproxy_server.remote.auth_token) ?? '',
|
||||
management_key: redactSecretValue(config.cliproxy_server.remote.management_key),
|
||||
},
|
||||
}
|
||||
: config.cliproxy_server,
|
||||
dashboard_auth: config.dashboard_auth
|
||||
? {
|
||||
...config.dashboard_auth,
|
||||
password_hash: redactSecretValue(config.dashboard_auth.password_hash) ?? '',
|
||||
}
|
||||
: config.dashboard_auth,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeYamlKey(rawKey: string): string {
|
||||
const key = rawKey.trim();
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
return key.slice(1, -1);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function splitYamlScalarAndComment(valuePortion: string): {
|
||||
value: string;
|
||||
comment: string;
|
||||
} {
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
|
||||
for (let index = 0; index < valuePortion.length; index += 1) {
|
||||
const char = valuePortion[index];
|
||||
|
||||
if (char === "'" && !inDoubleQuote) {
|
||||
if (inSingleQuote && valuePortion[index + 1] === "'") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
inSingleQuote = !inSingleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' && !inSingleQuote && valuePortion[index - 1] !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
char === '#' &&
|
||||
!inSingleQuote &&
|
||||
!inDoubleQuote &&
|
||||
(index === 0 || /\s/.test(valuePortion[index - 1]))
|
||||
) {
|
||||
const prefix = valuePortion.slice(0, index);
|
||||
const spacing = prefix.match(/\s*$/)?.[0] ?? '';
|
||||
return {
|
||||
value: prefix.trimEnd(),
|
||||
comment: `${spacing}${valuePortion.slice(index)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: valuePortion.trimEnd(),
|
||||
comment: '',
|
||||
};
|
||||
}
|
||||
|
||||
function redactYamlScalarLine(line: string): string {
|
||||
const match = line.match(/^(\s*[^:#][^:]*:\s*)(.*)$/);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const [, prefix, rawTail] = match;
|
||||
const leadingSpacing = rawTail.match(/^\s*/)?.[0] ?? '';
|
||||
const { value, comment } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length));
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (
|
||||
trimmedValue.length === 0 ||
|
||||
trimmedValue === '""' ||
|
||||
trimmedValue === "''" ||
|
||||
trimmedValue === '|' ||
|
||||
trimmedValue === '>'
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const quote =
|
||||
trimmedValue.startsWith('"') && trimmedValue.endsWith('"')
|
||||
? '"'
|
||||
: trimmedValue.startsWith("'") && trimmedValue.endsWith("'")
|
||||
? "'"
|
||||
: '';
|
||||
const redactedValue = quote ? `${quote}${REDACTED_SECRET_VALUE}${quote}` : REDACTED_SECRET_VALUE;
|
||||
|
||||
return `${prefix}${leadingSpacing}${redactedValue}${comment}`;
|
||||
}
|
||||
|
||||
function shouldRedactRawYamlPath(pathKeys: string[]): boolean {
|
||||
if (
|
||||
pathKeys.length === 3 &&
|
||||
pathKeys[0] === 'global_env' &&
|
||||
pathKeys[1] === 'env' &&
|
||||
isSensitiveKey(pathKeys[2])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return RAW_YAML_REDACTED_PATHS.has(pathKeys.join('.'));
|
||||
}
|
||||
|
||||
function redactRawConfigYamlForDashboard(content: string): string {
|
||||
const stack: Array<{ indent: number; key: string }> = [];
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/);
|
||||
if (!mappingMatch) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const [, indentText, rawKey] = mappingMatch;
|
||||
if (rawKey.trimStart().startsWith('-')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const indent = indentText.length;
|
||||
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
stack.push({ indent, key: normalizeYamlKey(rawKey) });
|
||||
const currentPath = stack.map((entry) => entry.key);
|
||||
|
||||
return shouldRedactRawYamlPath(currentPath) ? redactYamlScalarLine(line) : line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function restoreRedactedSecretValue(
|
||||
currentValue: string | undefined,
|
||||
nextValue: string | undefined
|
||||
): string | undefined {
|
||||
if (nextValue === undefined || nextValue === REDACTED_SECRET_VALUE) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
function mergeGlobalEnvConfig(
|
||||
currentConfig: UnifiedConfig,
|
||||
nextConfig: Partial<UnifiedConfig>
|
||||
): UnifiedConfig['global_env'] {
|
||||
const nextGlobalEnv = nextConfig.global_env;
|
||||
if (!nextGlobalEnv) {
|
||||
return currentConfig.global_env;
|
||||
}
|
||||
|
||||
const currentEnv = currentConfig.global_env?.env ?? {};
|
||||
const nextEnv = nextGlobalEnv.env;
|
||||
|
||||
return {
|
||||
enabled: nextGlobalEnv.enabled ?? currentConfig.global_env?.enabled ?? true,
|
||||
env:
|
||||
nextEnv === undefined
|
||||
? currentEnv
|
||||
: Object.fromEntries(
|
||||
Object.entries(nextEnv).map(([key, value]) => {
|
||||
if (value === REDACTED_SECRET_VALUE && isSensitiveKey(key)) {
|
||||
return [key, currentEnv[key] ?? value];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCliproxyServerConfig(
|
||||
currentConfig: UnifiedConfig,
|
||||
nextConfig: Partial<UnifiedConfig>
|
||||
): UnifiedConfig['cliproxy_server'] {
|
||||
const nextServer = nextConfig.cliproxy_server;
|
||||
if (!nextServer) {
|
||||
return currentConfig.cliproxy_server;
|
||||
}
|
||||
|
||||
const nextRemote = nextServer.remote;
|
||||
const currentServer = currentConfig.cliproxy_server ?? DEFAULT_CLIPROXY_SERVER_CONFIG;
|
||||
|
||||
return {
|
||||
remote:
|
||||
nextRemote === undefined
|
||||
? currentServer.remote
|
||||
: {
|
||||
...nextRemote,
|
||||
auth_token:
|
||||
restoreRedactedSecretValue(currentServer.remote.auth_token, nextRemote.auth_token) ??
|
||||
'',
|
||||
management_key: restoreRedactedSecretValue(
|
||||
currentServer.remote.management_key,
|
||||
nextRemote.management_key
|
||||
),
|
||||
},
|
||||
fallback: nextServer.fallback ?? currentServer.fallback,
|
||||
local: nextServer.local ?? currentServer.local,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDashboardAuthConfig(
|
||||
currentConfig: UnifiedConfig,
|
||||
nextConfig: Partial<UnifiedConfig>
|
||||
): UnifiedConfig['dashboard_auth'] {
|
||||
const nextAuth = nextConfig.dashboard_auth;
|
||||
if (!nextAuth) {
|
||||
return currentConfig.dashboard_auth;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextAuth,
|
||||
password_hash:
|
||||
restoreRedactedSecretValue(
|
||||
currentConfig.dashboard_auth?.password_hash,
|
||||
nextAuth.password_hash
|
||||
) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function validateAndNormalizeAccountContextMetadata(config: unknown): string | null {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
@@ -130,7 +406,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
res.json(sanitizeUnifiedConfigForDashboard(config));
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -142,9 +418,10 @@ router.get('/raw', (_req: Request, res: Response): void => {
|
||||
res.status(404).json({ error: 'Config file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
res.type('text/plain').send(content);
|
||||
res.type('text/plain').send(redactRawConfigYamlForDashboard(content));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
@@ -154,7 +431,7 @@ router.get('/raw', (_req: Request, res: Response): void => {
|
||||
* PUT /api/config - Update unified config
|
||||
*/
|
||||
router.put('/', (req: Request, res: Response): void => {
|
||||
const config = req.body;
|
||||
const config = req.body as Partial<UnifiedConfig>;
|
||||
|
||||
if (!isUnifiedConfig(config)) {
|
||||
res.status(400).json({ error: 'Invalid config format' });
|
||||
@@ -168,7 +445,72 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
try {
|
||||
saveUnifiedConfig(config);
|
||||
mutateUnifiedConfig((currentConfig) => {
|
||||
if ('setup_completed' in config) {
|
||||
currentConfig.setup_completed = config.setup_completed;
|
||||
}
|
||||
|
||||
if ('default' in config) {
|
||||
currentConfig.default = config.default;
|
||||
}
|
||||
|
||||
if (config.accounts !== undefined) {
|
||||
currentConfig.accounts = config.accounts;
|
||||
}
|
||||
|
||||
if (config.profiles !== undefined) {
|
||||
currentConfig.profiles = config.profiles;
|
||||
}
|
||||
|
||||
if (config.cliproxy !== undefined) {
|
||||
currentConfig.cliproxy = config.cliproxy;
|
||||
}
|
||||
|
||||
if (config.preferences !== undefined) {
|
||||
currentConfig.preferences = config.preferences;
|
||||
}
|
||||
|
||||
if (config.websearch !== undefined) {
|
||||
currentConfig.websearch = config.websearch;
|
||||
}
|
||||
|
||||
if (config.continuity !== undefined) {
|
||||
currentConfig.continuity = config.continuity;
|
||||
}
|
||||
|
||||
if (config.copilot !== undefined) {
|
||||
currentConfig.copilot = config.copilot;
|
||||
}
|
||||
|
||||
if (config.cursor !== undefined) {
|
||||
currentConfig.cursor = config.cursor;
|
||||
}
|
||||
|
||||
if (config.quota_management !== undefined) {
|
||||
currentConfig.quota_management = config.quota_management;
|
||||
}
|
||||
|
||||
if (config.thinking !== undefined) {
|
||||
currentConfig.thinking = config.thinking;
|
||||
}
|
||||
|
||||
if (config.image_analysis !== undefined) {
|
||||
currentConfig.image_analysis = config.image_analysis;
|
||||
}
|
||||
|
||||
if (config.global_env !== undefined) {
|
||||
currentConfig.global_env = mergeGlobalEnvConfig(currentConfig, config);
|
||||
}
|
||||
|
||||
if (config.cliproxy_server !== undefined) {
|
||||
currentConfig.cliproxy_server = mergeCliproxyServerConfig(currentConfig, config);
|
||||
}
|
||||
|
||||
if (config.dashboard_auth !== undefined) {
|
||||
currentConfig.dashboard_auth = mergeDashboardAuthConfig(currentConfig, config);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -209,7 +551,15 @@ router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await rollback(backupPath);
|
||||
const managedBackupPath = resolveManagedBackupPath(backupPath);
|
||||
if (!managedBackupPath) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid backupPath. Must reference a managed CCS migration backup directory.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await rollback(managedBackupPath);
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
|
||||
Reference in new issue
Block a user