From f5c4862184f7988afaadc7ab57d9aa23002a2f59 Mon Sep 17 00:00:00 2001 From: yjg-djb <189134749+yjg-djb@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:53:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(providers):=20=E8=BF=9E=E6=8E=A5=20endp?= =?UTF-8?q?oint=20=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E5=9B=9E=E6=98=BE?= =?UTF-8?q?=E5=89=8D=E5=89=A5=E7=A6=BB=20userinfo=20=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProviderConfig 是非 secret 设置通道(凭证属于 OS 级 CredentialStore),setConfig 已做字段白名单,但 endpoint 字段本身 未做 secret 形态检查——URL userinfo(scheme://user:pass@host)是 常见凭证携带形态,会在 connections.json 明文落盘并回显到 UI (PR #64 production canary 运行实测发现,见该 PR 证据评论)。 - 新增 sanitizeEndpoint:scheme://user:pass@host → scheme://[REDACTED]@host,协议/主机/路径保留,目标地址仍可读; 无 userinfo 的 endpoint 原样通过 - setConfig 持久化前净化(写侧) - getConfig 惰性净化(读侧),修复存量明文文件,不破坏本地优先 的无迁移语义 - 单测覆盖:userinfo 密码剥离且落盘无明文、旧文件读侧净化、 无 userinfo endpoint 行为不变 Closes #93 --- packages/shared/src/providers/connection.ts | 26 ++++++++++- .../shared/src/providers/providers.test.ts | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) 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', () => { From d0a0194d70d5cf99bf117a14d1d5b5680291d9b5 Mon Sep 17 00:00:00 2001 From: yjg-djb <189134749+yjg-djb@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:06:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E9=87=8D=E6=96=B0=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20CI=EF=BC=88advisory=20=E5=A4=B1=E8=B4=A5=E4=B8=BA?= =?UTF-8?q?=20research=20recovery=20=E6=97=B6=E5=BA=8F=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E4=B8=8E=E6=9C=AC=E6=AC=A1=E6=94=B9=E5=8A=A8=E6=97=A0?= =?UTF-8?q?=E5=85=B3=E8=81=94=EF=BC=8C=E6=9C=AC=E5=9C=B0=E5=90=8C=E5=A5=97?= =?UTF-8?q?=E4=BB=B6=200=20fail=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit