From ece43fc30e735c385098cd20350cb7b4bf9568f9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 16 May 2026 13:58:55 -0400 Subject: [PATCH] fix(cliproxy): redact AI provider header secrets (#1268) * fix(cliproxy): redact AI provider header secrets * style: apply prettier formatting --- .../ai-provider-service-stable-id.test.ts | 86 +++++++++++++++++++ src/cliproxy/ai-providers/service.ts | 59 ++++++++++--- 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts b/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts index e689b6f6..375ace19 100644 --- a/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts +++ b/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts @@ -152,6 +152,92 @@ describe('ai-provider service stable ids', () => { ).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 + >; + 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 () => { const { listAiProviders } = await loadAiProviderService(); diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts index d35982f4..0bcd01ee 100644 --- a/src/cliproxy/ai-providers/service.ts +++ b/src/cliproxy/ai-providers/service.ts @@ -17,14 +17,39 @@ function maskSecret(value: string | undefined): string | undefined { 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( - headers: Array<{ key: string; value: string }> | undefined + headers: Array<{ key: string; value: string }> | undefined, + existing?: Record ): Record | undefined { if (!headers) return undefined; const normalized = headers.reduce>((acc, header) => { const key = header.key.trim(); if (!key) return acc; - acc[key] = header.value; + acc[key] = restoreMaskedViewValue(header.value, existing?.[key]) || ''; return acc; }, {}); return Object.keys(normalized).length > 0 ? normalized : undefined; @@ -33,7 +58,10 @@ function normalizeHeaders( function toHeaderPairs( headers: Record | undefined ): 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) { @@ -62,9 +90,9 @@ function buildApiKeyEntryView( return { id: entry.id || `${family}:${index}`, index, - label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, - baseUrl: entry['base-url']?.trim() || undefined, - proxyUrl: entry['proxy-url']?.trim() || undefined, + label: entry.prefix?.trim() || sanitizeUrlForView(entry['base-url']) || `Entry ${index + 1}`, + baseUrl: sanitizeUrlForView(entry['base-url']), + proxyUrl: sanitizeUrlForView(entry['proxy-url']), prefix: entry.prefix?.trim() || undefined, headers: toHeaderPairs(entry.headers), excludedModels: [...(entry['excluded-models'] || [])], @@ -80,7 +108,7 @@ function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): Ai index, name: entry.name, label: entry.name, - baseUrl: entry['base-url']?.trim() || undefined, + baseUrl: sanitizeUrlForView(entry['base-url']), headers: toHeaderPairs(entry.headers), excludedModels: [], models: normalizeModelAliases(entry.models), @@ -142,10 +170,14 @@ function toApiKeyEntry( return { id: existing?.id, 'api-key': nextSecret, - 'base-url': input.baseUrl?.trim() || undefined, - 'proxy-url': input.proxyUrl?.trim() || undefined, + 'base-url': restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView), + 'proxy-url': restoreMaskedViewValue( + input.proxyUrl, + existing?.['proxy-url'], + sanitizeUrlForView + ), prefix: input.prefix?.trim() || undefined, - headers: normalizeHeaders(input.headers), + headers: normalizeHeaders(input.headers, existing?.headers), 'excluded-models': (input.excludedModels || []) .map((value) => value.trim()) .filter((value) => value.length > 0), @@ -167,8 +199,11 @@ function toOpenAiCompatEntry( return { id: existing?.id, name: input.name?.trim() || existing?.name || 'connector', - 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', - headers: normalizeHeaders(input.headers), + 'base-url': + 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 })), models: normalizeModelAliases(input.models), };