Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/shared/src/providers/connection.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -69,17 +83,25 @@ export class ConnectionStore {

async getConfig(providerId: string): Promise<ProviderConfig | undefined> {
const file = await this.store.read<ConnectionsFile>(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<void> {
const file = await this.store.read<ConnectionsFile>(ConnectionStore.FILE, { connections: [] });
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 });
Expand Down
45 changes: 45 additions & 0 deletions packages/shared/src/providers/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,51 @@ describe('ConnectionStore', () => {
const raw = await store.read<Record<string, unknown>>('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<Record<string, unknown>>('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', () => {
Expand Down
Loading