diff --git a/src/plugin.ts b/src/plugin.ts index d294271..b1b08f3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -163,9 +163,12 @@ function createRuntimeConfig( authBaseUrl?: string, ): CliproxyConfig { const baseUrl = getBaseUrl(options, authBaseUrl); + const hasExplicitApiKey = getStringOption(options, 'apiKey') !== undefined; + const originMatchesCredential = + !authBaseUrl || sameOrigin(baseUrl, authBaseUrl); return { baseUrl, - apiKey, + apiKey: hasExplicitApiKey || originMatchesCredential ? apiKey : '', modelCacheTtl: getPositiveNumber(options, 'modelCacheTtl'), refreshOnList: getBoolean(options, 'refreshOnList'), modelsDev: getModelsDevConfig(options), @@ -246,6 +249,14 @@ async function readAuthFromStore( } } +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +} + export function getBaseUrl( options?: Record, authBaseUrl?: string, diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 039ca7b..977d638 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -320,4 +320,27 @@ test('authorize stores baseURL and optional apiKey as JSON', async () => { const parsed = JSON.parse(result.key); assert.equal(parsed.baseURL, 'http://127.0.0.1:8317/v1'); assert.equal(parsed.apiKey, ''); -}); \ No newline at end of file +}); +test('config hook never sends a stored key to a project-overridden origin', async () => { + const tempHome = await createTempAuthHome({ + cliproxy: { + type: 'api', + key: JSON.stringify({ baseURL: 'https://trusted.example/v1', apiKey: 'global-secret' }), + }, + }); + const calls = []; + global.fetch = async (input, init) => { + calls.push({ url: input instanceof Request ? input.url : String(input), headers: new Headers(init?.headers) }); + return new Response(JSON.stringify({ object: 'list', data: [] }), { status: 200 }); + }; + try { + const plugin = await CliproxyAuthPlugin({}); + const config = { provider: { cliproxy: { options: { baseURL: 'https://attacker.example/v1' } } } }; + await plugin.config(config); + const attacker = calls.find((call) => call.url.startsWith('https://attacker.example/')); + assert.ok(attacker, 'project endpoint may still be queried without credentials'); + assert.equal(attacker.headers.get('Authorization'), null); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +});