fix(config): close dashboard secret redaction gaps

This commit is contained in:
Tam Nhu Tran
2026-03-24 15:15:29 -04:00
parent 37251cd1f8
commit e983bed1da
3 changed files with 317 additions and 27 deletions
+2
View File
@@ -11,10 +11,12 @@
*/
export const SENSITIVE_KEY_PATTERNS = [
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
/_TOKEN$/, // Keys ending with _TOKEN
/_API_KEY$/, // Keys ending with _API_KEY
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
/_SECRET$/, // Keys ending with _SECRET
/_SECRET_KEY$/, // Keys ending with _SECRET_KEY
/^TOKEN$/, // Exact match for TOKEN
/^API_KEY$/, // Exact match for API_KEY
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
/^SECRET$/, // Exact match for SECRET
+203 -27
View File
@@ -34,6 +34,8 @@ 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.auth.api_key',
'cliproxy.auth.management_secret',
'cliproxy_server.remote.auth_token',
'cliproxy_server.remote.management_key',
'dashboard_auth.password_hash',
@@ -67,6 +69,16 @@ function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig
),
}
: config.global_env,
cliproxy: {
...config.cliproxy,
auth: sanitizeCliproxyAuthConfig(config.cliproxy.auth),
variants: Object.fromEntries(
Object.entries(config.cliproxy.variants).map(([variantName, variantConfig]) => [
variantName,
sanitizeCliproxyVariantAuth(variantConfig),
])
),
},
cliproxy_server: config.cliproxy_server
? {
...config.cliproxy_server,
@@ -86,6 +98,29 @@ function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig
};
}
function sanitizeCliproxyAuthConfig(
auth: UnifiedConfig['cliproxy']['auth']
): UnifiedConfig['cliproxy']['auth'] {
if (!auth) {
return auth;
}
return {
...auth,
api_key: redactSecretValue(auth.api_key),
management_secret: redactSecretValue(auth.management_secret),
};
}
function sanitizeCliproxyVariantAuth(
variantConfig: UnifiedConfig['cliproxy']['variants'][string]
): UnifiedConfig['cliproxy']['variants'][string] {
return {
...variantConfig,
auth: sanitizeCliproxyAuthConfig(variantConfig.auth),
};
}
function normalizeYamlKey(rawKey: string): string {
const key = rawKey.trim();
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
@@ -150,13 +185,7 @@ function redactYamlScalarLine(line: string): string {
const { value, comment } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length));
const trimmedValue = value.trim();
if (
trimmedValue.length === 0 ||
trimmedValue === '""' ||
trimmedValue === "''" ||
trimmedValue === '|' ||
trimmedValue === '>'
) {
if (trimmedValue.length === 0 || trimmedValue === '""' || trimmedValue === "''") {
return line;
}
@@ -171,6 +200,10 @@ function redactYamlScalarLine(line: string): string {
return `${prefix}${leadingSpacing}${redactedValue}${comment}`;
}
function isYamlBlockScalarIndicator(value: string): boolean {
return /^[>|](?:[1-9][+-]?|[+-]?[1-9])?$/.test(value);
}
function shouldRedactRawYamlPath(pathKeys: string[]): boolean {
if (
pathKeys.length === 3 &&
@@ -181,36 +214,88 @@ function shouldRedactRawYamlPath(pathKeys: string[]): boolean {
return true;
}
if (
pathKeys.length === 3 &&
pathKeys[0] === 'cliproxy' &&
pathKeys[1] === 'auth' &&
(pathKeys[2] === 'api_key' || pathKeys[2] === 'management_secret')
) {
return true;
}
if (
pathKeys.length === 5 &&
pathKeys[0] === 'cliproxy' &&
pathKeys[1] === 'variants' &&
pathKeys[3] === 'auth' &&
(pathKeys[4] === 'api_key' || pathKeys[4] === 'management_secret')
) {
return true;
}
return RAW_YAML_REDACTED_PATHS.has(pathKeys.join('.'));
}
function redactRawConfigYamlForDashboard(content: string): string {
const stack: Array<{ indent: number; key: string }> = [];
const redactedLines: string[] = [];
const lines = content.split('\n');
return content
.split('\n')
.map((line) => {
const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/);
if (!mappingMatch) {
return line;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/);
if (!mappingMatch) {
redactedLines.push(line);
continue;
}
const [, indentText, rawKey, rawTail] = mappingMatch;
if (rawKey.trimStart().startsWith('-')) {
redactedLines.push(line);
continue;
}
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);
if (!shouldRedactRawYamlPath(currentPath)) {
redactedLines.push(line);
continue;
}
const redactedLine = redactYamlScalarLine(line);
redactedLines.push(redactedLine);
const leadingSpacing = rawTail.match(/^\s*/)?.[0] ?? '';
const { value } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length));
const trimmedValue = value.trim();
if (!isYamlBlockScalarIndicator(trimmedValue)) {
continue;
}
while (index + 1 < lines.length) {
const nextLine = lines[index + 1];
if (nextLine.trim().length === 0) {
index += 1;
continue;
}
const [, indentText, rawKey] = mappingMatch;
if (rawKey.trimStart().startsWith('-')) {
return line;
const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0;
if (nextIndent <= indent) {
break;
}
const indent = indentText.length;
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
index += 1;
}
}
stack.push({ indent, key: normalizeYamlKey(rawKey) });
const currentPath = stack.map((entry) => entry.key);
return shouldRedactRawYamlPath(currentPath) ? redactYamlScalarLine(line) : line;
})
.join('\n');
return redactedLines.join('\n');
}
function restoreRedactedSecretValue(
@@ -302,6 +387,97 @@ function mergeDashboardAuthConfig(
};
}
function mergeCliproxyAuthField(
currentValue: string | undefined,
nextAuth: NonNullable<UnifiedConfig['cliproxy']['auth']>,
key: 'api_key' | 'management_secret'
): string | undefined {
if (!Object.prototype.hasOwnProperty.call(nextAuth, key)) {
return undefined;
}
return restoreRedactedSecretValue(currentValue, nextAuth[key]);
}
function mergeCliproxyAuthConfig(
currentAuth: UnifiedConfig['cliproxy']['auth'],
nextAuth: UnifiedConfig['cliproxy']['auth']
): UnifiedConfig['cliproxy']['auth'] {
if (!nextAuth) {
return undefined;
}
const mergedAuth = { ...nextAuth };
const apiKey = mergeCliproxyAuthField(currentAuth?.api_key, nextAuth, 'api_key');
const managementSecret = mergeCliproxyAuthField(
currentAuth?.management_secret,
nextAuth,
'management_secret'
);
if (apiKey === undefined) {
delete mergedAuth.api_key;
} else {
mergedAuth.api_key = apiKey;
}
if (managementSecret === undefined) {
delete mergedAuth.management_secret;
} else {
mergedAuth.management_secret = managementSecret;
}
return Object.keys(mergedAuth).length > 0 ? mergedAuth : undefined;
}
function mergeCliproxyVariantConfig(
currentVariant: UnifiedConfig['cliproxy']['variants'][string] | undefined,
nextVariant: UnifiedConfig['cliproxy']['variants'][string]
): UnifiedConfig['cliproxy']['variants'][string] {
const mergedVariant = { ...nextVariant };
const mergedAuth = mergeCliproxyAuthConfig(currentVariant?.auth, nextVariant.auth);
if (mergedAuth === undefined) {
delete mergedVariant.auth;
} else {
mergedVariant.auth = mergedAuth;
}
return mergedVariant;
}
function mergeCliproxyConfig(
currentConfig: UnifiedConfig,
nextConfig: Partial<UnifiedConfig>
): UnifiedConfig['cliproxy'] {
const nextCliproxy = nextConfig.cliproxy;
if (!nextCliproxy) {
return currentConfig.cliproxy;
}
const mergedCliproxy = {
...nextCliproxy,
variants:
nextCliproxy.variants === undefined
? currentConfig.cliproxy.variants
: Object.fromEntries(
Object.entries(nextCliproxy.variants).map(([variantName, nextVariant]) => [
variantName,
mergeCliproxyVariantConfig(currentConfig.cliproxy.variants[variantName], nextVariant),
])
),
};
const mergedAuth = mergeCliproxyAuthConfig(currentConfig.cliproxy.auth, nextCliproxy.auth);
if (mergedAuth === undefined) {
delete mergedCliproxy.auth;
} else {
mergedCliproxy.auth = mergedAuth;
}
return mergedCliproxy;
}
function validateAndNormalizeAccountContextMetadata(config: unknown): string | null {
if (typeof config !== 'object' || config === null) {
return 'Invalid config payload';
@@ -463,7 +639,7 @@ router.put('/', (req: Request, res: Response): void => {
}
if (config.cliproxy !== undefined) {
currentConfig.cliproxy = config.cliproxy;
currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config);
}
if (config.preferences !== undefined) {
@@ -260,9 +260,22 @@ describe('web-server config-routes account context validation', () => {
enabled: true,
env: {
DEBUG: '1',
GH_TOKEN: 'gh-token-secret',
GITHUB_TOKEN: 'github-token-secret',
OPENAI_API_KEY: 'sk-test-123456',
},
};
config.cliproxy.auth = {
api_key: 'cliproxy-global-api-key',
management_secret: 'cliproxy-global-management-secret',
};
config.cliproxy.variants.gemini = {
provider: 'gemini',
auth: {
api_key: 'variant-api-key',
management_secret: 'variant-management-secret',
},
};
config.cliproxy_server = {
remote: {
enabled: true,
@@ -296,7 +309,13 @@ describe('web-server config-routes account context validation', () => {
const payload = (await response.json()) as typeof config;
expect(payload.global_env?.env.DEBUG).toBe('1');
expect(payload.global_env?.env.GH_TOKEN).toBe('[redacted]');
expect(payload.global_env?.env.GITHUB_TOKEN).toBe('[redacted]');
expect(payload.global_env?.env.OPENAI_API_KEY).toBe('[redacted]');
expect(payload.cliproxy.auth?.api_key).toBe('[redacted]');
expect(payload.cliproxy.auth?.management_secret).toBe('[redacted]');
expect(payload.cliproxy.variants.gemini?.auth?.api_key).toBe('[redacted]');
expect(payload.cliproxy.variants.gemini?.auth?.management_secret).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]');
@@ -307,8 +326,18 @@ describe('web-server config-routes account context validation', () => {
expect(rawConfig).toContain('# custom comment');
expect(rawConfig).toContain('DEBUG: "1"');
expect(rawConfig).toContain('GH_TOKEN: [redacted]');
expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]');
expect(rawConfig).toContain('OPENAI_API_KEY: [redacted]');
expect(rawConfig).toContain('api_key: [redacted]');
expect(rawConfig).toContain('management_secret: [redacted]');
expect(rawConfig).not.toContain('sk-test-123456');
expect(rawConfig).not.toContain('gh-token-secret');
expect(rawConfig).not.toContain('github-token-secret');
expect(rawConfig).not.toContain('cliproxy-global-api-key');
expect(rawConfig).not.toContain('cliproxy-global-management-secret');
expect(rawConfig).not.toContain('variant-api-key');
expect(rawConfig).not.toContain('variant-management-secret');
expect(rawConfig).not.toContain('remote-auth-token');
expect(rawConfig).not.toContain('management-secret');
});
@@ -322,6 +351,17 @@ describe('web-server config-routes account context validation', () => {
OPENAI_API_KEY: 'sk-test-123456',
},
};
config.cliproxy.auth = {
api_key: 'cliproxy-global-api-key',
management_secret: 'cliproxy-global-management-secret',
};
config.cliproxy.variants.gemini = {
provider: 'gemini',
auth: {
api_key: 'variant-api-key',
management_secret: 'variant-management-secret',
},
};
config.cliproxy_server = {
remote: {
enabled: true,
@@ -357,6 +397,14 @@ describe('web-server config-routes account context validation', () => {
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.auth?.api_key).toBe('cliproxy-global-api-key');
expect(savedConfig?.cliproxy.auth?.management_secret).toBe(
'cliproxy-global-management-secret'
);
expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key');
expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe(
'variant-management-secret'
);
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(
@@ -364,6 +412,70 @@ describe('web-server config-routes account context validation', () => {
);
});
it('redacts block-scalar secrets from raw YAML responses', async () => {
const yamlPath = path.join(tempHome, '.ccs', 'config.yaml');
fs.writeFileSync(
yamlPath,
[
'# block scalar test',
'version: 10',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: []',
' variants:',
' gemini:',
' provider: gemini',
' auth:',
' api_key: |',
' variant-api-secret-line-1',
' variant-api-secret-line-2',
' auth:',
' api_key: >',
' cliproxy-global-api-secret',
' management_secret: |',
' cliproxy-global-management-secret',
'global_env:',
' enabled: true',
' env:',
' GITHUB_TOKEN: |',
' github-token-secret-line-1',
' github-token-secret-line-2',
'cliproxy_server:',
' remote:',
' enabled: true',
' host: proxy.example.com',
' protocol: https',
' auth_token: >',
' remote-auth-secret',
'dashboard_auth:',
' enabled: true',
' username: admin',
' password_hash: |',
' dashboard-password-secret',
'',
].join('\n')
);
const rawResponse = await fetch(`${baseUrl}/api/config/raw`);
expect(rawResponse.status).toBe(200);
const rawConfig = await rawResponse.text();
expect(rawConfig).toContain('# block scalar test');
expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]');
expect(rawConfig).toContain('api_key: [redacted]');
expect(rawConfig).toContain('management_secret: [redacted]');
expect(rawConfig).toContain('auth_token: [redacted]');
expect(rawConfig).toContain('password_hash: [redacted]');
expect(rawConfig).not.toContain('github-token-secret-line-1');
expect(rawConfig).not.toContain('cliproxy-global-api-secret');
expect(rawConfig).not.toContain('cliproxy-global-management-secret');
expect(rawConfig).not.toContain('variant-api-secret-line-1');
expect(rawConfig).not.toContain('remote-auth-secret');
expect(rawConfig).not.toContain('dashboard-password-secret');
});
it('allows deleting visible global env keys while preserving redacted secrets', async () => {
const config = createEmptyUnifiedConfig();
config.global_env = {