Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion core/src/sessions/db/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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://')) {
Expand Down
3 changes: 2 additions & 1 deletion core/src/sessions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`);
Comment thread
herdiyana256 marked this conversation as resolved.
}
38 changes: 38 additions & 0 deletions core/src/utils/redact_uri.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit, though not a blocker either. A credential in the query string is returned verbatim, because url.password is only the userinfo component.

    return uri;

I checked these against the real helper:

postgres://user:pass@host/db          -> postgres://user:***@host/db      ok
postgres://user@host/db?password=hunter2 -> unchanged, leaks hunter2
postgres://host/db?password=hunter2      -> unchanged, leaks hunter2

?password= is an accepted form in several of the drivers this function guards, and it reaches exactly the same error path — an unsupported or mistyped scheme with the credential in the query rather than the userinfo. Since the whole point here is that these strings land in logs and error trackers, the narrower coverage is worth closing:

    const SECRET_PARAMS = ['password', 'sslpassword', 'passwd', 'pwd'];
    let touched = false;
    for (const p of SECRET_PARAMS) {
      if (url.searchParams.has(p)) {
        url.searchParams.set(p, '***');
        touched = true;
      }
    }
    if (url.password) {
      url.password = '***';
      touched = true;
    }
    return touched ? url.toString() : uri;

Caveat I'd want you to weigh rather than take on faith: this makes the function return url.toString() on the query-only path, so a URI with a redacted param gets URL-normalized where today it is passed through byte-for-byte. That is the same normalization the userinfo path already accepts, and I confirmed it is a no-op for the schemes in your tests, but it is a behaviour change on inputs nothing currently covers. Alternatively, scoping the doc comment to "userinfo password" and saying query parameters are out of scope would be an honest, zero-risk resolution.

} catch {
const schemeEnd = uri.indexOf('://');
return schemeEnd === -1
? '<redacted>'
: `${uri.slice(0, schemeEnd)}://<redacted>`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, optional — the unparseable fallback is right, and I want to flag one consequence you may already have intended.

      : `${uri.slice(0, schemeEnd)}://<redacted>`;

This throws away the host and database name for any URI that new URL() rejects, so a genuine typo like postgres//host/db (missing colon) produces <redacted> and the operator loses every clue about what they mistyped. That is the safe direction to err in and I would not change the default, but the error message at the call site now reads Unsupported database URI: <redacted>, which is close to useless for debugging. Worth a sentence in the thrown error telling the user the value was redacted, so they don't think the URI itself was empty.

}
}
55 changes: 55 additions & 0 deletions core/test/utils/redact_uri_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {describe, expect, it} from 'vitest';
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/);
});
});
Loading