diff --git a/packages/shared/src/providers/connection.ts b/packages/shared/src/providers/connection.ts index 73e2dbe..add4958 100644 --- a/packages/shared/src/providers/connection.ts +++ b/packages/shared/src/providers/connection.ts @@ -1,6 +1,20 @@ import type { FinancialProviderStatus, ProviderRoutingConfig } from '@finagent/core'; import type { JsonFileStore } from '../storage/json-file-store.ts'; +/** + * Strip URL userinfo credentials (`scheme://user:pass@host`) from an + * endpoint before it reaches disk or the UI (issue #93). `ProviderConfig` + * is the non-secret settings channel โ€” credentials belong in the OS-backed + * CredentialStore โ€” and userinfo is a credential shape. Scheme, host and + * path are preserved so the endpoint stays readable; hosts without + * userinfo pass through untouched. + */ +const ENDPOINT_USERINFO = /(^[a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/i; + +export function sanitizeEndpoint(endpoint: string): string { + return endpoint.replace(ENDPOINT_USERINFO, '$1[REDACTED]@'); +} + /** * Connection lifecycle state for ONE provider (spec ยง8). Provider-agnostic: * any financial-data or broker-account provider records the same shape. @@ -69,7 +83,14 @@ export class ConnectionStore { async getConfig(providerId: string): Promise { const file = await this.store.read(ConnectionStore.FILE, { connections: [] }); - return file.configs?.[providerId]; + const config = file.configs?.[providerId]; + // Lazy sanitization: files written before issue #93 may still carry + // cleartext userinfo in endpoints โ€” never surface it, even at rest. + if (!config) return config; + return { + ...config, + endpoint: config.endpoint !== undefined ? sanitizeEndpoint(config.endpoint) : undefined, + }; } async setConfig(providerId: string, config: ProviderConfig): Promise { @@ -77,9 +98,10 @@ export class ConnectionStore { const configs = { ...(file.configs ?? {}) }; // Copy only the allowlisted non-secret fields. This prevents accidental // credential persistence even when an untyped caller supplies apiKey. + // Endpoints additionally lose userinfo credentials (issue #93). configs[providerId] = { enabled: config.enabled, - endpoint: config.endpoint, + endpoint: config.endpoint !== undefined ? sanitizeEndpoint(config.endpoint) : undefined, region: config.region, }; await this.store.write(ConnectionStore.FILE, { ...file, configs }); diff --git a/packages/shared/src/providers/providers.test.ts b/packages/shared/src/providers/providers.test.ts index a08649d..be94432 100644 --- a/packages/shared/src/providers/providers.test.ts +++ b/packages/shared/src/providers/providers.test.ts @@ -453,6 +453,51 @@ describe('ConnectionStore', () => { const raw = await store.read>('connections.json', {}); expect(JSON.stringify(raw)).not.toContain('canary-secret-123'); }); + + it('persists endpoints without cleartext userinfo credentials (issue #93)', async () => { + const connections = new ConnectionStore(store); + await connections.setConfig('massive', { + enabled: true, + endpoint: 'https://folio_user:sup3rsecret@db.host.internal:5432/api', + }); + + // At-rest file must not carry the password. + const raw = await store.read>('connections.json', {}); + expect(JSON.stringify(raw)).not.toContain('sup3rsecret'); + expect(JSON.stringify(raw)).not.toContain('folio_user:'); + // Host/path survive so the target stays readable. + expect(JSON.stringify(raw)).toContain('db.host.internal:5432/api'); + expect(JSON.stringify(raw)).toContain('[REDACTED]'); + + // Read side never surfaces the credential either. + const config = await connections.getConfig('massive'); + expect(config?.endpoint).toBe('https://[REDACTED]@db.host.internal:5432/api'); + }); + + it('sanitizes legacy cleartext-userinfo configs on read (issue #93)', async () => { + // Simulate a file written before the fix. + await store.write('connections.json', { + connections: [], + configs: { + massive: { enabled: true, endpoint: 'https://user:legacy-pass@old.host/api' }, + }, + }); + const connections = new ConnectionStore(store); + expect((await connections.getConfig('massive'))?.endpoint).toBe( + 'https://[REDACTED]@old.host/api' + ); + }); + + it('leaves endpoints without userinfo untouched (issue #93)', async () => { + const connections = new ConnectionStore(store); + await connections.setConfig('massive', { + enabled: true, + endpoint: 'https://api.example.com/v1?symbol=AAPL', + }); + expect((await connections.getConfig('massive'))?.endpoint).toBe( + 'https://api.example.com/v1?symbol=AAPL' + ); + }); }); describe('createRouterFetchers', () => {