mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 10:23:11 +00:00
fix(cliproxy): redact AI provider header secrets (#1268)
* fix(cliproxy): redact AI provider header secrets * style: apply prettier formatting
This commit is contained in:
@@ -152,6 +152,92 @@ describe('ai-provider service stable ids', () => {
|
|||||||
).toBe('sk-openrouter');
|
).toBe('sk-openrouter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redacts custom headers and URL userinfo in provider list views', async () => {
|
||||||
|
const { listAiProviders } = await loadAiProviderService();
|
||||||
|
|
||||||
|
writeCliproxyConfig(tempHome, {
|
||||||
|
'claude-api-key': [
|
||||||
|
{
|
||||||
|
id: 'claude-secret-route',
|
||||||
|
'api-key': 'sk-ant-provider-secret-1234',
|
||||||
|
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
|
||||||
|
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||||
|
'X-API-Key': 'x-api-key-secret-abcdef',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'openai-compatibility': [
|
||||||
|
{
|
||||||
|
id: 'openrouter-secret-route',
|
||||||
|
name: 'openrouter',
|
||||||
|
'base-url': 'https://routeruser:routerpass@openrouter.example/api/v1',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer OPENAI-COMPAT-HEADER-SECRET',
|
||||||
|
},
|
||||||
|
'api-key-entries': [{ 'api-key': 'sk-openrouter-secret-9999' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await listAiProviders();
|
||||||
|
const claudeEntry = listed.families.find((entry) => entry.id === 'claude-api-key')?.entries[0];
|
||||||
|
const openAiEntry = listed.families.find((entry) => entry.id === 'openai-compatibility')
|
||||||
|
?.entries[0];
|
||||||
|
|
||||||
|
expect(claudeEntry?.baseUrl).toBe('https://***:***@anthropic.example/v1');
|
||||||
|
expect(claudeEntry?.proxyUrl).toBe('http://***:***@example.internal:8080/');
|
||||||
|
expect(claudeEntry?.headers).toEqual([
|
||||||
|
{ key: 'Authorization', value: '...3456' },
|
||||||
|
{ key: 'X-API-Key', value: '...cdef' },
|
||||||
|
]);
|
||||||
|
expect(openAiEntry?.baseUrl).toBe('https://***:***@openrouter.example/api/v1');
|
||||||
|
expect(openAiEntry?.headers).toEqual([{ key: 'Authorization', value: '...CRET' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves stored header and URL secrets when saving unchanged redacted values', async () => {
|
||||||
|
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
|
||||||
|
|
||||||
|
writeCliproxyConfig(tempHome, {
|
||||||
|
'claude-api-key': [
|
||||||
|
{
|
||||||
|
id: 'claude-secret-route',
|
||||||
|
'api-key': 'sk-ant-provider-secret-1234',
|
||||||
|
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
|
||||||
|
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||||
|
'X-Project': 'public-routing-context',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await listAiProviders();
|
||||||
|
const entry = listed.families.find((family) => family.id === 'claude-api-key')?.entries[0];
|
||||||
|
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
|
||||||
|
await updateAiProviderEntry('claude-api-key', 'claude-secret-route', {
|
||||||
|
apiKey: 'sk-ant-provider-secret-1234',
|
||||||
|
baseUrl: entry?.baseUrl,
|
||||||
|
proxyUrl: entry?.proxyUrl,
|
||||||
|
headers: entry?.headers,
|
||||||
|
preserveSecrets: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = readCliproxyConfig(tempHome)['claude-api-key'] as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
expect(persisted[0]?.['base-url']).toBe('https://baseuser:basepass@anthropic.example/v1');
|
||||||
|
expect(persisted[0]?.['proxy-url']).toBe('http://proxyuser:proxypass@example.internal:8080');
|
||||||
|
expect(persisted[0]?.headers).toEqual({
|
||||||
|
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||||
|
'X-Project': 'public-routing-context',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes plain openai-compatible model rules without aliases', async () => {
|
it('normalizes plain openai-compatible model rules without aliases', async () => {
|
||||||
const { listAiProviders } = await loadAiProviderService();
|
const { listAiProviders } = await loadAiProviderService();
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,39 @@ function maskSecret(value: string | undefined): string | undefined {
|
|||||||
return value.length > 8 ? `...${value.slice(-4)}` : '***';
|
return value.length > 8 ? `...${value.slice(-4)}` : '***';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeUrlForView(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.username) parsed.username = '***';
|
||||||
|
if (parsed.password) parsed.password = '***';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreMaskedViewValue(
|
||||||
|
value: string | undefined,
|
||||||
|
existing: string | undefined,
|
||||||
|
sanitizeForView: (value: string | undefined) => string | undefined = maskSecret
|
||||||
|
): string | undefined {
|
||||||
|
const next = value?.trim() || undefined;
|
||||||
|
if (!next || !existing) return next;
|
||||||
|
return next === sanitizeForView(existing) ? existing : next;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHeaders(
|
function normalizeHeaders(
|
||||||
headers: Array<{ key: string; value: string }> | undefined
|
headers: Array<{ key: string; value: string }> | undefined,
|
||||||
|
existing?: Record<string, string>
|
||||||
): Record<string, string> | undefined {
|
): Record<string, string> | undefined {
|
||||||
if (!headers) return undefined;
|
if (!headers) return undefined;
|
||||||
const normalized = headers.reduce<Record<string, string>>((acc, header) => {
|
const normalized = headers.reduce<Record<string, string>>((acc, header) => {
|
||||||
const key = header.key.trim();
|
const key = header.key.trim();
|
||||||
if (!key) return acc;
|
if (!key) return acc;
|
||||||
acc[key] = header.value;
|
acc[key] = restoreMaskedViewValue(header.value, existing?.[key]) || '';
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||||
@@ -33,7 +58,10 @@ function normalizeHeaders(
|
|||||||
function toHeaderPairs(
|
function toHeaderPairs(
|
||||||
headers: Record<string, string> | undefined
|
headers: Record<string, string> | undefined
|
||||||
): Array<{ key: string; value: string }> {
|
): Array<{ key: string; value: string }> {
|
||||||
return Object.entries(headers || {}).map(([key, value]) => ({ key, value }));
|
return Object.entries(headers || {}).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: maskSecret(value) || '***',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) {
|
function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) {
|
||||||
@@ -62,9 +90,9 @@ function buildApiKeyEntryView(
|
|||||||
return {
|
return {
|
||||||
id: entry.id || `${family}:${index}`,
|
id: entry.id || `${family}:${index}`,
|
||||||
index,
|
index,
|
||||||
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
|
label: entry.prefix?.trim() || sanitizeUrlForView(entry['base-url']) || `Entry ${index + 1}`,
|
||||||
baseUrl: entry['base-url']?.trim() || undefined,
|
baseUrl: sanitizeUrlForView(entry['base-url']),
|
||||||
proxyUrl: entry['proxy-url']?.trim() || undefined,
|
proxyUrl: sanitizeUrlForView(entry['proxy-url']),
|
||||||
prefix: entry.prefix?.trim() || undefined,
|
prefix: entry.prefix?.trim() || undefined,
|
||||||
headers: toHeaderPairs(entry.headers),
|
headers: toHeaderPairs(entry.headers),
|
||||||
excludedModels: [...(entry['excluded-models'] || [])],
|
excludedModels: [...(entry['excluded-models'] || [])],
|
||||||
@@ -80,7 +108,7 @@ function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): Ai
|
|||||||
index,
|
index,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
label: entry.name,
|
label: entry.name,
|
||||||
baseUrl: entry['base-url']?.trim() || undefined,
|
baseUrl: sanitizeUrlForView(entry['base-url']),
|
||||||
headers: toHeaderPairs(entry.headers),
|
headers: toHeaderPairs(entry.headers),
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
models: normalizeModelAliases(entry.models),
|
models: normalizeModelAliases(entry.models),
|
||||||
@@ -142,10 +170,14 @@ function toApiKeyEntry(
|
|||||||
return {
|
return {
|
||||||
id: existing?.id,
|
id: existing?.id,
|
||||||
'api-key': nextSecret,
|
'api-key': nextSecret,
|
||||||
'base-url': input.baseUrl?.trim() || undefined,
|
'base-url': restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView),
|
||||||
'proxy-url': input.proxyUrl?.trim() || undefined,
|
'proxy-url': restoreMaskedViewValue(
|
||||||
|
input.proxyUrl,
|
||||||
|
existing?.['proxy-url'],
|
||||||
|
sanitizeUrlForView
|
||||||
|
),
|
||||||
prefix: input.prefix?.trim() || undefined,
|
prefix: input.prefix?.trim() || undefined,
|
||||||
headers: normalizeHeaders(input.headers),
|
headers: normalizeHeaders(input.headers, existing?.headers),
|
||||||
'excluded-models': (input.excludedModels || [])
|
'excluded-models': (input.excludedModels || [])
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter((value) => value.length > 0),
|
.filter((value) => value.length > 0),
|
||||||
@@ -167,8 +199,11 @@ function toOpenAiCompatEntry(
|
|||||||
return {
|
return {
|
||||||
id: existing?.id,
|
id: existing?.id,
|
||||||
name: input.name?.trim() || existing?.name || 'connector',
|
name: input.name?.trim() || existing?.name || 'connector',
|
||||||
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
|
'base-url':
|
||||||
headers: normalizeHeaders(input.headers),
|
restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView) ||
|
||||||
|
existing?.['base-url'] ||
|
||||||
|
'',
|
||||||
|
headers: normalizeHeaders(input.headers, existing?.headers),
|
||||||
'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })),
|
'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })),
|
||||||
models: normalizeModelAliases(input.models),
|
models: normalizeModelAliases(input.models),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user