diff --git a/core/src/artifacts/registry.ts b/core/src/artifacts/registry.ts index 66e2470d1..5dff93cc9 100644 --- a/core/src/artifacts/registry.ts +++ b/core/src/artifacts/registry.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {redactUriPassword} from '../utils/redact_uri.js'; import {BaseArtifactService} from './base_artifact_service.js'; import {FileArtifactService} from './file_artifact_service.js'; import {GcsArtifactService} from './gcs_artifact_service.js'; @@ -29,5 +30,7 @@ export function getArtifactServiceFromUri(uri: string): BaseArtifactService { return new FileArtifactService(rootDir); } - throw new Error(`Unsupported artifact service URI: ${uri}`); + throw new Error( + `Unsupported artifact service URI: ${redactUriPassword(uri)}`, + ); } diff --git a/core/src/sessions/db/operations.ts b/core/src/sessions/db/operations.ts index ef5897292..92f7bcb25 100644 --- a/core/src/sessions/db/operations.ts +++ b/core/src/sessions/db/operations.ts @@ -5,6 +5,7 @@ */ import {MikroORM, Options as MikroORMOptions} from '@mikro-orm/core'; +import {redactUriPassword} from '../../utils/redact_uri.js'; import { ENTITIES, SCHEMA_VERSION_1_JSON, @@ -40,7 +41,7 @@ export async function getConnectionOptionsFromUri( const {MsSqlDriver} = await import('@mikro-orm/mssql'); driver = MsSqlDriver; } else { - throw new Error(`Unsupported database URI: ${uri}`); + throw new Error(`Unsupported database URI: ${redactUriPassword(uri)}`); } if (uri.startsWith('sqlite://')) { diff --git a/core/src/sessions/registry.ts b/core/src/sessions/registry.ts index c1a077993..6962f7203 100644 --- a/core/src/sessions/registry.ts +++ b/core/src/sessions/registry.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {redactUriPassword} from '../utils/redact_uri.js'; import {BaseSessionService} from './base_session_service.js'; import { DatabaseSessionService, @@ -32,5 +33,5 @@ export function getSessionServiceFromUri(uri: string): BaseSessionService { return new VertexAiSessionService({}); } - throw new Error(`Unsupported session service URI: ${uri}`); + throw new Error(`Unsupported session service URI: ${redactUriPassword(uri)}`); } diff --git a/core/src/utils/redact_uri.ts b/core/src/utils/redact_uri.ts new file mode 100644 index 000000000..b5176a24a --- /dev/null +++ b/core/src/utils/redact_uri.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Redacts the password from a connection URI so the URI can be safely included + * in error messages and logs. + * + * A database or session-service connection URI such as + * `postgres://user:password@host:5432/db` embeds the password in its userinfo + * component. Including such a URI verbatim in a thrown Error or log entry leaks + * the credential to wherever those are collected (log files, error-tracking + * services, stdout captured by an orchestrator), which is frequently a + * different trust boundary from whoever provisioned the connection string. + * + * This masks the password while keeping the rest of the URI intact for + * debugging, mirroring the semantics of Go's `net/url.URL.Redacted()`. + * + * If the input cannot be parsed as a URL, only its scheme prefix is returned so + * that a credential embedded in an otherwise-unparseable string is not leaked. + */ +export function redactUriPassword(uri: string): string { + try { + const url = new URL(uri); + if (url.password) { + url.password = '***'; + return url.toString(); + } + return uri; + } catch { + const schemeEnd = uri.indexOf('://'); + return schemeEnd === -1 + ? '' + : `${uri.slice(0, schemeEnd)}://`; + } +} diff --git a/core/test/utils/redact_uri_test.ts b/core/test/utils/redact_uri_test.ts new file mode 100644 index 000000000..ec6f1d37a --- /dev/null +++ b/core/test/utils/redact_uri_test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {getArtifactServiceFromUri} from '../../src/artifacts/registry.js'; +import {getConnectionOptionsFromUri} from '../../src/sessions/db/operations.js'; +import {getSessionServiceFromUri} from '../../src/sessions/registry.js'; +import {redactUriPassword} from '../../src/utils/redact_uri.js'; + +describe('redactUriPassword', () => { + it('masks the password while keeping the rest of the URI', () => { + expect(redactUriPassword('postgres://user:pass@db.host:5432/mydb')).toBe( + 'postgres://user:***@db.host:5432/mydb', + ); + }); + + it('masks the password for unsupported schemes too', () => { + expect(redactUriPassword('oracle://admin:hunter2@ora.host/xe')).toBe( + 'oracle://admin:***@ora.host/xe', + ); + }); + + it('leaves a URI without a password unchanged', () => { + expect(redactUriPassword('postgres://user@db.host/mydb')).toBe( + 'postgres://user@db.host/mydb', + ); + }); + + it('does not leak anything after the scheme for unparseable input', () => { + const out = redactUriPassword('not a url with :hunter2@ inside it'); + expect(out).not.toContain('hunter2'); + }); +}); + +describe('connection-URI errors do not leak the password', () => { + it('getConnectionOptionsFromUri redacts the password in its error', async () => { + await expect( + getConnectionOptionsFromUri('oracle://admin:hunter2@ora.host/xe'), + ).rejects.toThrow(/oracle:\/\/admin:\*\*\*@ora\.host\/xe/); + await expect( + getConnectionOptionsFromUri('oracle://admin:hunter2@ora.host/xe'), + ).rejects.not.toThrow(/hunter2/); + }); + + it('getSessionServiceFromUri redacts the password in its error', () => { + expect(() => + getSessionServiceFromUri('oracle://admin:hunter2@ora.host/xe'), + ).toThrow(/oracle:\/\/admin:\*\*\*@ora\.host\/xe/); + expect(() => + getSessionServiceFromUri('oracle://admin:hunter2@ora.host/xe'), + ).not.toThrow(/hunter2/); + }); + + it('getArtifactServiceFromUri redacts the password in its error', () => { + expect(() => + getArtifactServiceFromUri('s3://admin:hunter2@bucket/prefix'), + ).toThrow(/s3:\/\/admin:\*\*\*@bucket\/prefix/); + expect(() => + getArtifactServiceFromUri('s3://admin:hunter2@bucket/prefix'), + ).not.toThrow(/hunter2/); + }); +});