From 389ec718b4c184b9c3ea2bd08b0808b277f00b6d Mon Sep 17 00:00:00 2001 From: lazerg Date: Sun, 20 Sep 2026 12:16:37 +0500 Subject: [PATCH 1/9] fix(mongo): accept multi-host connection strings Signed-off-by: lazerg --- packages/3-extensions/mongo/src/runtime/binding.ts | 11 ++++++++++- packages/3-extensions/mongo/test/mongo.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/3-extensions/mongo/src/runtime/binding.ts b/packages/3-extensions/mongo/src/runtime/binding.ts index ac7ee8865e46..1811b523bf7d 100644 --- a/packages/3-extensions/mongo/src/runtime/binding.ts +++ b/packages/3-extensions/mongo/src/runtime/binding.ts @@ -48,6 +48,15 @@ type MongoBindingFields = { readonly mongoClient?: MongoDriverClient; }; +const SEED_LIST = /^([^:]+:\/\/(?:[^/?#]*@)?[^/?#,]+),[^/?#]*/; + +// `new URL` rejects a seed list like `host1:27017,host2:27017`, reading everything +// after the first colon as the port. Only the scheme and database path matter here, +// so parse without the extra hosts; the driver still receives the original URL. +function collapseSeedList(url: string): string { + return url.replace(SEED_LIST, '$1'); +} + function validateMongoUrl(url: string): URL { const trimmed = url.trim(); if (trimmed.length === 0) { @@ -56,7 +65,7 @@ function validateMongoUrl(url: string): URL { let parsed: URL; try { - parsed = new URL(trimmed); + parsed = new URL(collapseSeedList(trimmed)); } catch { throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must be a valid URL'); } diff --git a/packages/3-extensions/mongo/test/mongo.test.ts b/packages/3-extensions/mongo/test/mongo.test.ts index 49dbdf01d751..3e9cfe5896e5 100644 --- a/packages/3-extensions/mongo/test/mongo.test.ts +++ b/packages/3-extensions/mongo/test/mongo.test.ts @@ -177,6 +177,16 @@ describe('mongo() facade', () => { ); }); + it('accepts a multi-host seed list url and forwards it unchanged', async () => { + const url = + 'mongodb://user:password@host1:27017,host2:27017,host3:27017/absensi?replicaSet=my-replica-set'; + const db = mongo({ contract: fakeContract, url }); + + await db.runtime(); + + expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'absensi'); + }); + it('accepts a pre-built mongoClient and uses fromDb', async () => { const fakeClient = { db: vi.fn().mockReturnValue({ id: 'db-handle' }) }; const db = mongo({ From 80f1d15f53495b5bf0678cd8d69b85578bbb6f88 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sun, 20 Sep 2026 12:33:56 +0500 Subject: [PATCH 2/9] fix(mongo): keep credentials out of the seed list scan Signed-off-by: lazerg --- .../3-extensions/mongo/src/runtime/binding.ts | 25 +++++++++++++++---- .../3-extensions/mongo/test/mongo.test.ts | 24 ++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/3-extensions/mongo/src/runtime/binding.ts b/packages/3-extensions/mongo/src/runtime/binding.ts index 1811b523bf7d..8ddf0ac6b296 100644 --- a/packages/3-extensions/mongo/src/runtime/binding.ts +++ b/packages/3-extensions/mongo/src/runtime/binding.ts @@ -48,13 +48,28 @@ type MongoBindingFields = { readonly mongoClient?: MongoDriverClient; }; -const SEED_LIST = /^([^:]+:\/\/(?:[^/?#]*@)?[^/?#,]+),[^/?#]*/; +const AUTHORITY_END_PATTERN = /[/?#]/; -// `new URL` rejects a seed list like `host1:27017,host2:27017`, reading everything -// after the first colon as the port. Only the scheme and database path matter here, -// so parse without the extra hosts; the driver still receives the original URL. +/** + * `new URL` rejects a seed list like `host1:27017,host2:27017`, reading everything + * after the first colon as the port. Only the scheme and database path matter here, + * so parse without the extra hosts; the driver still receives the original URL. + */ function collapseSeedList(url: string): string { - return url.replace(SEED_LIST, '$1'); + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + const authorityStart = schemeEnd + 3; + const delimiter = url.slice(authorityStart).search(AUTHORITY_END_PATTERN); + const authorityEnd = delimiter === -1 ? url.length : authorityStart + delimiter; + const authority = url.slice(authorityStart, authorityEnd); + // A password may contain a comma, so only look for the seed list after the userinfo. + const comma = authority.indexOf(',', authority.lastIndexOf('@') + 1); + if (comma === -1) { + return url; + } + return url.slice(0, authorityStart + comma) + url.slice(authorityEnd); } function validateMongoUrl(url: string): URL { diff --git a/packages/3-extensions/mongo/test/mongo.test.ts b/packages/3-extensions/mongo/test/mongo.test.ts index 3e9cfe5896e5..5d06801dd4dc 100644 --- a/packages/3-extensions/mongo/test/mongo.test.ts +++ b/packages/3-extensions/mongo/test/mongo.test.ts @@ -187,6 +187,24 @@ describe('mongo() facade', () => { expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'absensi'); }); + it('accepts a bracketed IPv6 seed list url', async () => { + const url = 'mongodb://[::1]:27017,[::2]:27017/db'; + const db = mongo({ contract: fakeContract, url }); + + await db.runtime(); + + expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'db'); + }); + + it('accepts a single-host url whose password contains a comma', async () => { + const url = 'mongodb://user:pa,ss@host1:27017/db'; + const db = mongo({ contract: fakeContract, url }); + + await db.runtime(); + + expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'db'); + }); + it('accepts a pre-built mongoClient and uses fromDb', async () => { const fakeClient = { db: vi.fn().mockReturnValue({ id: 'db-handle' }) }; const db = mongo({ @@ -269,6 +287,12 @@ describe('mongo() facade', () => { ); }); + it('throws for a seed list url without a dbName in the path', () => { + expect(() => mongo({ contract: fakeContract, url: 'mongodb://h1:27017,h2:27017' })).toThrow( + 'Mongo URL must include a database name', + ); + }); + it('throws for a url with the wrong scheme', () => { expect(() => mongo({ contract: fakeContract, url: 'http://localhost/x' })).toThrow( 'Mongo URL must use mongodb:// or mongodb+srv://', From 134a8c320e5765206f1d509520f65145ac21734e Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:14:10 +0200 Subject: [PATCH 3/9] test(mongo): move the connection-string tests into binding.test.ts mongo.test.ts was already over the 500-line limit for test files. The seed-list cases only test URL parsing, so they now call resolveMongoBinding directly and assert the whole binding, without the facade mocks. The cases are the same as before. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-extensions/mongo/test/binding.test.ts | 33 ++++++++++++++++++ .../3-extensions/mongo/test/mongo.test.ts | 34 ------------------- 2 files changed, 33 insertions(+), 34 deletions(-) create mode 100644 packages/3-extensions/mongo/test/binding.test.ts diff --git a/packages/3-extensions/mongo/test/binding.test.ts b/packages/3-extensions/mongo/test/binding.test.ts new file mode 100644 index 000000000000..fbd08c4bc812 --- /dev/null +++ b/packages/3-extensions/mongo/test/binding.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { resolveMongoBinding } from '../src/runtime/binding'; + +describe('resolveMongoBinding with a connection string', () => { + it('accepts a seed list with credentials and a replica set', () => { + const url = + 'mongodb://user:password@host1:27017,host2:27017,host3:27017/absensi?replicaSet=my-replica-set'; + + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'absensi' }); + }); + + it('accepts a bracketed IPv6 seed list', () => { + const url = 'mongodb://[::1]:27017,[::2]:27017/db'; + + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'db' }); + }); + + it('accepts a single host whose password contains a comma', () => { + const url = 'mongodb://user:pa,ss@host1:27017/db'; + + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'db' }); + }); + + it('rejects a seed list without a database name in the path', () => { + expect(() => resolveMongoBinding({ url: 'mongodb://h1:27017,h2:27017' })).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: + 'Mongo URL must include a database name in its path (e.g. mongodb://host:27017/mydb), or pass dbName explicitly', + }), + ); + }); +}); diff --git a/packages/3-extensions/mongo/test/mongo.test.ts b/packages/3-extensions/mongo/test/mongo.test.ts index 5d06801dd4dc..49dbdf01d751 100644 --- a/packages/3-extensions/mongo/test/mongo.test.ts +++ b/packages/3-extensions/mongo/test/mongo.test.ts @@ -177,34 +177,6 @@ describe('mongo() facade', () => { ); }); - it('accepts a multi-host seed list url and forwards it unchanged', async () => { - const url = - 'mongodb://user:password@host1:27017,host2:27017,host3:27017/absensi?replicaSet=my-replica-set'; - const db = mongo({ contract: fakeContract, url }); - - await db.runtime(); - - expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'absensi'); - }); - - it('accepts a bracketed IPv6 seed list url', async () => { - const url = 'mongodb://[::1]:27017,[::2]:27017/db'; - const db = mongo({ contract: fakeContract, url }); - - await db.runtime(); - - expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'db'); - }); - - it('accepts a single-host url whose password contains a comma', async () => { - const url = 'mongodb://user:pa,ss@host1:27017/db'; - const db = mongo({ contract: fakeContract, url }); - - await db.runtime(); - - expect(mocks.driverFromConnection).toHaveBeenCalledWith(url, 'db'); - }); - it('accepts a pre-built mongoClient and uses fromDb', async () => { const fakeClient = { db: vi.fn().mockReturnValue({ id: 'db-handle' }) }; const db = mongo({ @@ -287,12 +259,6 @@ describe('mongo() facade', () => { ); }); - it('throws for a seed list url without a dbName in the path', () => { - expect(() => mongo({ contract: fakeContract, url: 'mongodb://h1:27017,h2:27017' })).toThrow( - 'Mongo URL must include a database name', - ); - }); - it('throws for a url with the wrong scheme', () => { expect(() => mongo({ contract: fakeContract, url: 'http://localhost/x' })).toThrow( 'Mongo URL must use mongodb:// or mongodb+srv://', From 2a324cce84f910f6e41d07ff453c4fccef5504c3 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:15:50 +0200 Subject: [PATCH 4/9] fix(mongo): parse connection strings with the driver's parser Replace the hand-written seed-list slicing with mongodb-connection-string-url, the parser the mongodb driver runs on every connection. The binding now accepts exactly the strings the driver accepts. The package is added to the catalog at ^7.0.1, the range mongodb declares, so the lockfile keeps the single 7.0.1 copy it already had. @prisma/orm-mongo declares it too, because the shell must list the dependencies of the packages it bundles. The scheme is checked before parsing, so "not a url" and a non-Mongo scheme keep their separate messages. Strings the driver would refuse on the first runtime() call are now refused when mongo() is called: mongodb+srv with several hosts or a port, an unescaped @ in the password, malformed percent-encoding in the credentials, an upper-case scheme, and a scheme without //. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/3-extensions/mongo/package.json | 1 + .../3-extensions/mongo/src/runtime/binding.ts | 45 ++++++------------ .../3-extensions/mongo/test/binding.test.ts | 46 +++++++++++++++++++ .../9-public/@prisma/orm-mongo/package.json | 1 + pnpm-lock.yaml | 9 ++++ pnpm-workspace.yaml | 1 + 6 files changed, 71 insertions(+), 32 deletions(-) diff --git a/packages/3-extensions/mongo/package.json b/packages/3-extensions/mongo/package.json index eca049374b5d..3e6a682bddd8 100644 --- a/packages/3-extensions/mongo/package.json +++ b/packages/3-extensions/mongo/package.json @@ -31,6 +31,7 @@ "@internal/mongo-runtime": "workspace:8.0.0-rc.12", "@internal/target-mongo": "workspace:8.0.0-rc.12", "@internal/utils": "workspace:8.0.0-rc.12", + "mongodb-connection-string-url": "catalog:", "pathe": "^2.0.3", "tinyglobby": "catalog:" }, diff --git a/packages/3-extensions/mongo/src/runtime/binding.ts b/packages/3-extensions/mongo/src/runtime/binding.ts index 8ddf0ac6b296..08d201de5b0b 100644 --- a/packages/3-extensions/mongo/src/runtime/binding.ts +++ b/packages/3-extensions/mongo/src/runtime/binding.ts @@ -1,5 +1,6 @@ import { InternalError } from '@internal/utils/internal-error'; import type { MongoClient as MongoDriverClient } from 'mongodb'; +import { ConnectionString } from 'mongodb-connection-string-url'; import { mongoError } from './mongo-errors'; export type MongoBinding = @@ -48,51 +49,31 @@ type MongoBindingFields = { readonly mongoClient?: MongoDriverClient; }; -const AUTHORITY_END_PATTERN = /[/?#]/; - -/** - * `new URL` rejects a seed list like `host1:27017,host2:27017`, reading everything - * after the first colon as the port. Only the scheme and database path matter here, - * so parse without the extra hosts; the driver still receives the original URL. - */ -function collapseSeedList(url: string): string { - const schemeEnd = url.indexOf('://'); - if (schemeEnd === -1) { - return url; - } - const authorityStart = schemeEnd + 3; - const delimiter = url.slice(authorityStart).search(AUTHORITY_END_PATTERN); - const authorityEnd = delimiter === -1 ? url.length : authorityStart + delimiter; - const authority = url.slice(authorityStart, authorityEnd); - // A password may contain a comma, so only look for the seed list after the userinfo. - const comma = authority.indexOf(',', authority.lastIndexOf('@') + 1); - if (comma === -1) { - return url; - } - return url.slice(0, authorityStart + comma) + url.slice(authorityEnd); -} +const URL_SCHEME_PATTERN = /^([a-z][a-z\d+.-]*):\/\//i; +const MONGO_SCHEMES = new Set(['mongodb', 'mongodb+srv']); -function validateMongoUrl(url: string): URL { +function validateMongoUrl(url: string): ConnectionString { const trimmed = url.trim(); if (trimmed.length === 0) { throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must be a non-empty string'); } - let parsed: URL; - try { - parsed = new URL(collapseSeedList(trimmed)); - } catch { + const scheme = URL_SCHEME_PATTERN.exec(trimmed)?.[1]; + if (scheme === undefined) { throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must be a valid URL'); } - - if (parsed.protocol !== 'mongodb:' && parsed.protocol !== 'mongodb+srv:') { + if (!MONGO_SCHEMES.has(scheme)) { throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must use mongodb:// or mongodb+srv://'); } - return parsed; + try { + return new ConnectionString(trimmed); + } catch { + throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must be a valid URL'); + } } -function extractDbNameFromUrl(parsed: URL): string | undefined { +function extractDbNameFromUrl(parsed: ConnectionString): string | undefined { // pathname is "/dbname" or "" — strip the leading slash. Anything past // a second slash is invalid for our purposes (auth-source style paths). const path = parsed.pathname.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; diff --git a/packages/3-extensions/mongo/test/binding.test.ts b/packages/3-extensions/mongo/test/binding.test.ts index fbd08c4bc812..9ede7b96a35b 100644 --- a/packages/3-extensions/mongo/test/binding.test.ts +++ b/packages/3-extensions/mongo/test/binding.test.ts @@ -21,6 +21,28 @@ describe('resolveMongoBinding with a connection string', () => { expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'db' }); }); + it('accepts a seed list whose credentials percent-encode @, : and /', () => { + const url = 'mongodb://us%40er:p%40ss%3A%2F@host1:27017,host2:27017/db'; + + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'db' }); + }); + + it('accepts a mongodb+srv url', () => { + const url = 'mongodb+srv://user:pw@cluster0.example.net/db?retryWrites=true&w=majority'; + + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'db' }); + }); + + it('accepts a seed list passed as { uri, dbName }', () => { + const uri = 'mongodb://host1:27017,host2:27017/?replicaSet=rs'; + + expect(resolveMongoBinding({ uri, dbName: 'app' })).toEqual({ + kind: 'url', + url: uri, + dbName: 'app', + }); + }); + it('rejects a seed list without a database name in the path', () => { expect(() => resolveMongoBinding({ url: 'mongodb://h1:27017,h2:27017' })).toThrow( expect.objectContaining({ @@ -30,4 +52,28 @@ describe('resolveMongoBinding with a connection string', () => { }), ); }); + + it.each([ + ['a mongodb+srv url with several hosts', 'mongodb+srv://a.example.net,b.example.net/db'], + ['a mongodb+srv url with a port', 'mongodb+srv://cluster0.example.net:27017/db'], + ['an unescaped @ in the password', 'mongodb://user:p@ss@host1:27017,host2:27017/db'], + ['malformed percent-encoding in the password', 'mongodb://user:p%zz@host1:27017/db'], + ['a scheme without //', 'mongodb:host1/db'], + ])('rejects %s as an invalid URL', (_name, url) => { + expect(() => resolveMongoBinding({ url })).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: 'Mongo URL must be a valid URL', + }), + ); + }); + + it('rejects an upper-case scheme, which the driver does not accept', () => { + expect(() => resolveMongoBinding({ url: 'MONGODB://host1:27017/db' })).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: 'Mongo URL must use mongodb:// or mongodb+srv://', + }), + ); + }); }); diff --git a/packages/9-public/@prisma/orm-mongo/package.json b/packages/9-public/@prisma/orm-mongo/package.json index 1b14e6b9b2aa..4c8ba09a2a6d 100644 --- a/packages/9-public/@prisma/orm-mongo/package.json +++ b/packages/9-public/@prisma/orm-mongo/package.json @@ -20,6 +20,7 @@ "@prisma/orm-framework": "workspace:8.0.0-rc.12", "@prisma/orm-target-mongo": "workspace:8.0.0-rc.12", "@prisma/orm-toolchain": "workspace:8.0.0-rc.12", + "mongodb-connection-string-url": "catalog:", "pathe": "^2.0.3", "tinyglobby": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f717c79a8c56..6a079274b940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: mongodb: specifier: ^7.4.0 version: 7.5.0 + mongodb-connection-string-url: + specifier: ^7.0.1 + version: 7.0.1 mongodb-memory-server: specifier: 11.2.0 version: 11.2.0 @@ -3083,6 +3086,9 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.12 version: link:../../1-framework/0-foundation/utils + mongodb-connection-string-url: + specifier: 'catalog:' + version: 7.0.1 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -4977,6 +4983,9 @@ importers: mongodb: specifier: ^7.0.0 version: 7.5.0(socks@2.8.9) + mongodb-connection-string-url: + specifier: 'catalog:' + version: 7.0.1 pathe: specifier: ^2.0.3 version: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7779dea8cdbc..1c4b8df0bc41 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ catalog: arktype: ^2.2.2 gray-matter: ^4.0.3 mongodb: ^7.4.0 + mongodb-connection-string-url: ^7.0.1 mongodb-memory-server: 11.2.0 pg: 8.22.0 tinyglobby: ^0.2.17 From c95271ce3106ddec53f2f9e4b2d7e708cfc55d5f Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:16:31 +0200 Subject: [PATCH 5/9] fix(mongo): percent-decode the database name in the url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime read the database name from the url path without decoding it, while the driver decodes it. For mongodb://host/données the runtime used the database "donn%C3%A9es" and the control plane, which calls client.db(), used "données". Queries and migrations then hit different databases. The runtime now decodes the name like the driver does, and a path with malformed percent-encoding is refused as an invalid url. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-extensions/mongo/src/runtime/binding.ts | 16 ++++++++++------ packages/3-extensions/mongo/test/binding.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/3-extensions/mongo/src/runtime/binding.ts b/packages/3-extensions/mongo/src/runtime/binding.ts index 08d201de5b0b..1e99f8b7ec78 100644 --- a/packages/3-extensions/mongo/src/runtime/binding.ts +++ b/packages/3-extensions/mongo/src/runtime/binding.ts @@ -74,14 +74,18 @@ function validateMongoUrl(url: string): ConnectionString { } function extractDbNameFromUrl(parsed: ConnectionString): string | undefined { - // pathname is "/dbname" or "" — strip the leading slash. Anything past - // a second slash is invalid for our purposes (auth-source style paths). - const path = parsed.pathname.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname; - if (path.length === 0) { + // pathname is "/dbname" or "/". Anything past a second slash is invalid + // for our purposes (auth-source style paths). + const [encodedDbName = ''] = parsed.pathname.slice(1).split('/'); + if (encodedDbName.length === 0) { return undefined; } - const slash = path.indexOf('/'); - return slash === -1 ? path : path.slice(0, slash); + // The driver decodes the path too; the control plane uses the name it produces. + try { + return decodeURIComponent(encodedDbName); + } catch { + throw mongoError('RUNTIME.BINDING_INVALID', 'Mongo URL must be a valid URL'); + } } export function resolveMongoBinding(options: MongoBindingInput): MongoBinding { diff --git a/packages/3-extensions/mongo/test/binding.test.ts b/packages/3-extensions/mongo/test/binding.test.ts index 9ede7b96a35b..503827061025 100644 --- a/packages/3-extensions/mongo/test/binding.test.ts +++ b/packages/3-extensions/mongo/test/binding.test.ts @@ -43,6 +43,22 @@ describe('resolveMongoBinding with a connection string', () => { }); }); + it.each([ + ['a non-ASCII database name', 'mongodb://host1:27017/données'], + ['a percent-encoded database name', 'mongodb://host1:27017,host2:27017/donn%C3%A9es'], + ])('decodes %s the way the driver does', (_name, url) => { + expect(resolveMongoBinding({ url })).toEqual({ kind: 'url', url, dbName: 'données' }); + }); + + it('rejects a database name with malformed percent-encoding', () => { + expect(() => resolveMongoBinding({ url: 'mongodb://host1:27017/%E0%A4%A' })).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: 'Mongo URL must be a valid URL', + }), + ); + }); + it('rejects a seed list without a database name in the path', () => { expect(() => resolveMongoBinding({ url: 'mongodb://h1:27017,h2:27017' })).toThrow( expect.objectContaining({ From 49147ba063135bd1d9bf0ccbe0be1c20e39f7f04 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:20:48 +0200 Subject: [PATCH 6/9] chore(mongo): declare that the connection-string fix needs no upgrade steps The upgrade-coverage check in CI requires every PR that changes packages/3-extensions/ to add an extension declaration. Consumers have nothing to translate for this fix, so the declaration is empty. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../extension/instructions.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 upgrade-instructions/pending/mongo-multi-host-connection-strings/extension/instructions.md diff --git a/upgrade-instructions/pending/mongo-multi-host-connection-strings/extension/instructions.md b/upgrade-instructions/pending/mongo-multi-host-connection-strings/extension/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/mongo-multi-host-connection-strings/extension/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +--- From 531466a661de9c34497602557d98202139017e3b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:30:17 +0200 Subject: [PATCH 7/9] fix(cli): mask credentials in connection URLs that new URL rejects maskConnectionUrl and sanitizeErrorMessage parse the connection string with new URL and fall back to libpq key=value regexes when it throws. new URL throws for a multi-host URL where a host before the last has a port (mongodb://u:p@h1:27017,h2:27017/app), so the CLI printed the URL with its password in db verify, db sign, db schema, db prepare, contract infer, migrate and migration status/log, and left it in driver error messages. A string that starts with scheme:// now gets its userinfo masked without parsing the hosts: everything up to the last @ before the first /, ? or # becomes ****:****@, the same as the parsed path, and password query parameters are masked too. The libpq fallback is unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/utils/command-helpers.ts | 54 +++++++---- .../cli/src/utils/url-credentials.ts | 50 ++++++++++ .../cli/test/utils/command-helpers.test.ts | 96 ++++++++++++++++--- 3 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts diff --git a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts index 9be1e7869c58..6d8c1bd75fbf 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts @@ -7,6 +7,12 @@ import { APP_SPACE_ID, spaceMigrationDirectory } from '@internal/migration-tools import { relative, resolve } from 'pathe'; import type { ControlClient } from '../control-api/types'; import { CliStructuredError, errorRuntime } from './cli-errors'; +import { + hasUrlScheme, + redactUrlCredentials, + type UrlUserinfo, + urlUserinfo, +} from './url-credentials'; /** * Resolves the absolute path to contract.json from the config. @@ -195,7 +201,8 @@ export async function readContractEnvelope(config: { /** * Masks credentials in a database connection URL. - * Handles standard URLs (username + password + query params) and libpq-style key=value strings. + * Handles standard URLs (username + password + query params), URLs `new URL` rejects (such as + * multi-host URLs), and libpq-style key=value strings. */ export function maskConnectionUrl(url: string): string { try { @@ -214,10 +221,7 @@ export function maskConnectionUrl(url: string): string { } return parsed.toString(); } catch { - // Fallback for libpq-style key=value connection strings (e.g., "host=localhost password=secret user=admin") - return url - .replace(/password\s*=\s*\S+/gi, 'password=****') - .replace(/user\s*=\s*\S+/gi, 'user=****'); + return hasUrlScheme(url) ? redactUrlCredentials(url) : maskKeyValueCredentials(url); } } @@ -229,23 +233,31 @@ export function sanitizeErrorMessage(message: string, connectionUrl?: string): s if (!connectionUrl) { return message; } + const credentials = connectionUrlCredentials(connectionUrl); + if (credentials === undefined) { + return maskKeyValueCredentials(message); + } + let sanitized = message.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl)); + if (credentials.password) { + sanitized = sanitized.replaceAll(credentials.password, '****'); + } + if (credentials.username) { + sanitized = sanitized.replaceAll(credentials.username, '****'); + } + return sanitized; +} + +function connectionUrlCredentials(connectionUrl: string): UrlUserinfo | undefined { try { - const parsed = new URL(connectionUrl); - // Replace the full URL (with and without trailing slash) - let sanitized = message; - sanitized = sanitized.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl)); - // Also replace the password and username individually if they appear - if (parsed.password) { - sanitized = sanitized.replaceAll(parsed.password, '****'); - } - if (parsed.username) { - sanitized = sanitized.replaceAll(parsed.username, '****'); - } - return sanitized; + const { username, password } = new URL(connectionUrl); + return { username, password }; } catch { - // For libpq-style strings, mask password and user values in the message - return message - .replace(/password\s*=\s*\S+/gi, 'password=****') - .replace(/user\s*=\s*\S+/gi, 'user=****'); + return hasUrlScheme(connectionUrl) ? urlUserinfo(connectionUrl) : undefined; } } + +function maskKeyValueCredentials(text: string): string { + return text + .replace(/password\s*=\s*\S+/gi, 'password=****') + .replace(/user\s*=\s*\S+/gi, 'user=****'); +} diff --git a/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts b/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts new file mode 100644 index 000000000000..4b50aee5830c --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts @@ -0,0 +1,50 @@ +const MASK = '****'; +const URL_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//; +const LEADING_URL_USERINFO = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#\s]+)@/; +const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#\s]+)@/g; +const PASSWORD_QUERY_PARAMETER = /([?&][^=&#\s]*password[^=&#\s]*=)[^&#\s]*/gi; + +export interface UrlUserinfo { + readonly username: string; + readonly password: string; +} + +export function hasUrlScheme(text: string): boolean { + return URL_SCHEME.test(text); +} + +/** + * Reads the userinfo of a URL without parsing its hosts, so it works for URLs `new URL` rejects, + * such as a seed list of several `host:port` pairs. The userinfo ends at the last `@` before the + * first `/`, `?` or `#`. + */ +export function urlUserinfo(url: string): UrlUserinfo { + return splitUserinfo(url.match(LEADING_URL_USERINFO)?.[1] ?? ''); +} + +/** + * Masks the userinfo and any password query parameter of every URL in `text`, without parsing + * hosts. The masked form matches what `new URL` gives when its username and password are set to + * `****`. + */ +export function redactUrlCredentials(text: string): string { + return text + .replace( + URL_USERINFO, + (_match, scheme: string, userinfo: string) => + `${scheme}${maskUserinfo(splitUserinfo(userinfo))}@`, + ) + .replace(PASSWORD_QUERY_PARAMETER, `$1${MASK}`); +} + +function splitUserinfo(userinfo: string): UrlUserinfo { + const colon = userinfo.indexOf(':'); + if (colon === -1) { + return { username: userinfo, password: '' }; + } + return { username: userinfo.slice(0, colon), password: userinfo.slice(colon + 1) }; +} + +function maskUserinfo({ username, password }: UrlUserinfo): string { + return `${username ? MASK : ''}${password ? `:${MASK}` : ''}`; +} diff --git a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts index a56bdec1eeb9..420e89646a75 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts @@ -45,15 +45,56 @@ describe('maskConnectionUrl', () => { }); it('masks password and user in libpq-style connection string', () => { - const url = 'host=localhost password=secret user=admin dbname=mydb'; - const masked = maskConnectionUrl(url); + expect(maskConnectionUrl('host=localhost password=secret user=admin dbname=mydb')).toBe( + 'host=localhost password=**** user=**** dbname=mydb', + ); + }); - expect(masked).not.toContain('secret'); - expect(masked).not.toContain('admin'); - expect(masked).toContain('password=****'); - expect(masked).toContain('user=****'); - expect(masked).toContain('host=localhost'); - expect(masked).toContain('dbname=mydb'); + it('masks the credentials of an SRV URL', () => { + expect( + maskConnectionUrl('mongodb+srv://admin:s3cret@cluster0.example.net/app?retryWrites=true'), + ).toBe('mongodb+srv://****:****@cluster0.example.net/app?retryWrites=true'); + }); + + describe('a URL that new URL rejects', () => { + it('masks the credentials of a multi-host URL', () => { + expect(maskConnectionUrl('mongodb://admin:s3cret@h1:27017,h2:27017/app?replicaSet=rs')).toBe( + 'mongodb://****:****@h1:27017,h2:27017/app?replicaSet=rs', + ); + }); + + it('masks a password with percent-encoded @, : and /', () => { + expect(maskConnectionUrl('mongodb://admin:p%40ss%3Aw%2Frd@h1:27017,h2:27017/app')).toBe( + 'mongodb://****:****@h1:27017,h2:27017/app', + ); + }); + + it('masks everything up to the last @ of the authority', () => { + expect(maskConnectionUrl('mongodb://admin:p@ss@h1:27017,h2:27017/app')).toBe( + 'mongodb://****:****@h1:27017,h2:27017/app', + ); + }); + + it('masks the credentials of a multi-host Postgres URL', () => { + expect( + maskConnectionUrl( + 'postgresql://admin:secret@h1:5432,h2:5432/mydb?target_session_attrs=read-write', + ), + ).toBe('postgresql://****:****@h1:5432,h2:5432/mydb?target_session_attrs=read-write'); + }); + + it('masks a password query parameter', () => { + expect( + maskConnectionUrl( + 'postgresql://admin@h1:5432,h2:5432/mydb?password=secret&sslmode=require', + ), + ).toBe('postgresql://****@h1:5432,h2:5432/mydb?password=****&sslmode=require'); + }); + + it('returns a URL without credentials unchanged', () => { + const url = 'mongodb://h1:27017,h2:27017/app?replicaSet=rs'; + expect(maskConnectionUrl(url)).toBe(url); + }); }); }); @@ -100,10 +141,43 @@ describe('sanitizeErrorMessage', () => { it('handles libpq-style connection strings in messages', () => { const url = 'host=localhost password=secret user=admin dbname=mydb'; const message = 'Failed to connect: host=localhost password=secret user=admin'; - const sanitized = sanitizeErrorMessage(message, url); - expect(sanitized).not.toContain('password=secret'); - expect(sanitized).not.toContain('user=admin'); + expect(sanitizeErrorMessage(message, url)).toBe( + 'Failed to connect: host=localhost password=**** user=****', + ); + }); + + describe('a connection URL that new URL rejects', () => { + it('masks the URL where the message quotes it', () => { + const url = 'mongodb://admin:s3cret@h1:27017,h2:27017/app?replicaSet=rs'; + + expect(sanitizeErrorMessage(`connect failed: ${url}`, url)).toBe( + 'connect failed: mongodb://****:****@h1:27017,h2:27017/app?replicaSet=rs', + ); + }); + + it('masks a percent-encoded password where the message quotes the URL', () => { + const url = 'mongodb://admin:p%40ss%3Aw%2Frd@h1:27017,h2:27017/app'; + + expect(sanitizeErrorMessage(`bad auth for ${url}`, url)).toBe( + 'bad auth for mongodb://****:****@h1:27017,h2:27017/app', + ); + }); + + it('masks the user and password where the message names them on their own', () => { + const url = 'postgresql://admin:secret@h1:5432,h2:5432/mydb'; + + expect(sanitizeErrorMessage('auth failed for user "admin" with secret', url)).toBe( + 'auth failed for user "****" with ****', + ); + }); + + it('returns a message with a URL without credentials unchanged', () => { + const url = 'mongodb://h1:27017,h2:27017/app'; + const message = `connect ECONNREFUSED ${url}`; + + expect(sanitizeErrorMessage(message, url)).toBe(message); + }); }); }); From 50d0dc50e7e970e6b1559908e0d93a12b1b05dfa Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:31:47 +0200 Subject: [PATCH 8/9] refactor(cli): init redacts URL credentials with the shared helper init had two copies of one regex for URL userinfo: redactDatabaseUrlSecrets in probe-db.ts and the URL half of redactSecrets. Both now call redactUrlCredentials, the helper the connection-URL masking uses, and redactDatabaseUrlSecrets is gone. What init prints changes in three ways. Masked userinfo reads ****:****@ (or ****@ for a bare token) instead of ***@. A password with a raw @ is masked up to the last @ of the authority; the old regex stopped at the first @ and printed the rest. Password query parameters are masked too. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/commands/init/probe-db.ts | 17 ++------------ .../cli/src/commands/init/redact-secrets.ts | 6 ++--- .../cli/test/commands/init/probe-db.test.ts | 22 ++++--------------- .../test/commands/init/redact-secrets.test.ts | 18 ++++++++++++--- .../3-tooling/cli/test/orm/init-emit.test.ts | 2 +- .../cli/test/utils/url-credentials.test.ts | 22 +++++++++++++++++++ 6 files changed, 47 insertions(+), 40 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/utils/url-credentials.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/probe-db.ts b/packages/1-framework/3-tooling/cli/src/commands/init/probe-db.ts index 5bc899883f11..8061bf895774 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/probe-db.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/probe-db.ts @@ -2,6 +2,7 @@ import { createRequire } from 'node:module'; import { CliStructuredError } from '@internal/errors/control'; import { suppressIdleConnectionErrors } from '@internal/utils/suppress-idle-connection-errors'; import { join } from 'pathe'; +import { redactUrlCredentials } from '../../utils/url-credentials'; import type { TargetId } from './templates/code-templates'; /** @@ -126,7 +127,7 @@ export async function probeServerVersion( message: `Skipped --probe-db: ${err.message}. (Run with install enabled, or install the driver yourself, then re-run \`prisma orm init --probe-db\`.)`, }; } - const cause = redactDatabaseUrlSecrets(causeMessage(err)); + const cause = redactUrlCredentials(causeMessage(err)); return { kind: 'connection-failed', minVersion, @@ -194,20 +195,6 @@ function causeMessage(err: unknown): string { return String(err); } -/** - * Strips `user:password@` userinfo from any URL-shaped substring before - * we surface the cause to the user. Mirrors `redactSecrets` in - * `init.ts` — the probe path has its own redactor because the inputs - * here include the raw connection string by construction (driver - * errors echo the URL back). - * - * Exported for unit tests. - */ -export function redactDatabaseUrlSecrets(text: string): string { - if (!text) return text; - return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/@\s]+)@/g, '$1***@'); -} - async function defaultProbePostgres( databaseUrl: string, baseDir: string, diff --git a/packages/1-framework/3-tooling/cli/src/commands/init/redact-secrets.ts b/packages/1-framework/3-tooling/cli/src/commands/init/redact-secrets.ts index 56cb4b01cc93..aa9c78894f47 100644 --- a/packages/1-framework/3-tooling/cli/src/commands/init/redact-secrets.ts +++ b/packages/1-framework/3-tooling/cli/src/commands/init/redact-secrets.ts @@ -1,3 +1,5 @@ +import { redactUrlCredentials } from '../../utils/url-credentials'; + /** * Strips credentials out of package-manager stderr before it reaches a warning, * an error's meta, or a log. Two shapes carry them: userinfo inside a registry @@ -11,7 +13,5 @@ * while trusting another layer to have done it is not. */ export function redactSecrets(stderr: string): string { - return stderr - .replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/@\s]+)@/g, '$1***@') - .replace(/(\b_(?:authToken|auth|password)=)\S+/gi, '$1***'); + return redactUrlCredentials(stderr).replace(/(\b_(?:authToken|auth|password)=)\S+/gi, '$1***'); } diff --git a/packages/1-framework/3-tooling/cli/test/commands/init/probe-db.test.ts b/packages/1-framework/3-tooling/cli/test/commands/init/probe-db.test.ts index c57ff1bd6881..d7dd0e2d255c 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/init/probe-db.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/init/probe-db.test.ts @@ -6,7 +6,6 @@ import { type ProbeOverrides, parsePostgresVersion, probeServerVersion, - redactDatabaseUrlSecrets, } from '../../../src/commands/init/probe-db'; // --------------------------------------------------------------------------- @@ -71,18 +70,6 @@ describe('parsePostgresVersion (FR8.3)', () => { }); }); -describe('redactDatabaseUrlSecrets (FR8.3)', () => { - it('strips userinfo from any URL-shaped substring', () => { - expect(redactDatabaseUrlSecrets('failed: postgres://alice:hunter2@localhost:5432')).toBe( - 'failed: postgres://***@localhost:5432', - ); - }); - - it('passes empty input through untouched', () => { - expect(redactDatabaseUrlSecrets('')).toBe(''); - }); -}); - // --------------------------------------------------------------------------- // FR8.3 — probeServerVersion outcome surface // --------------------------------------------------------------------------- @@ -172,11 +159,10 @@ describe('probeServerVersion (FR8.3)', () => { }, ); - expect(outcome.kind).toBe('connection-failed'); - if (outcome.kind === 'connection-failed') { - expect(outcome.cause).not.toContain('hunter2'); - expect(outcome.cause).toContain('***@'); - } + expect(outcome).toMatchObject({ + kind: 'connection-failed', + cause: 'connect ECONNREFUSED postgres://****:****@localhost:5432', + }); }); it('returns driver-missing when require() cannot resolve the peer driver', async () => { diff --git a/packages/1-framework/3-tooling/cli/test/commands/init/redact-secrets.test.ts b/packages/1-framework/3-tooling/cli/test/commands/init/redact-secrets.test.ts index 57c5b4f8d834..a4f790e31518 100644 --- a/packages/1-framework/3-tooling/cli/test/commands/init/redact-secrets.test.ts +++ b/packages/1-framework/3-tooling/cli/test/commands/init/redact-secrets.test.ts @@ -5,20 +5,32 @@ describe('redactSecrets', () => { describe('credentials inside a URL', () => { it('redacts userinfo from URLs in stderr', () => { expect(redactSecrets('failed: https://user:pass@registry.example.com/foo')).toBe( - 'failed: https://***@registry.example.com/foo', + 'failed: https://****:****@registry.example.com/foo', ); }); it('redacts a bare token URL', () => { expect(redactSecrets('npm error: https://npm-token-123@registry.npmjs.org/')).toBe( - 'npm error: https://***@registry.npmjs.org/', + 'npm error: https://****@registry.npmjs.org/', ); }); it('redacts even when the URL is in the middle of a longer line', () => { expect( redactSecrets('GET https://alice:secret@registry.example.com/foo failed: 401 Unauthorized'), - ).toBe('GET https://***@registry.example.com/foo failed: 401 Unauthorized'); + ).toBe('GET https://****:****@registry.example.com/foo failed: 401 Unauthorized'); + }); + + it('redacts up to the last @ of the authority', () => { + expect(redactSecrets('failed: https://user:p@ss@registry.example.com/foo')).toBe( + 'failed: https://****:****@registry.example.com/foo', + ); + }); + + it('leaves an @ in the path alone', () => { + expect(redactSecrets('404 GET https://registry.npmjs.org/@prisma%2fclient')).toBe( + '404 GET https://registry.npmjs.org/@prisma%2fclient', + ); }); it('leaves URLs without userinfo untouched', () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts b/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts index 06e09ee64eac..1964e0340ac2 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/init-emit.test.ts @@ -126,7 +126,7 @@ describe('emitScaffoldedContract', () => { expect(error.message).toContain('line-39'); expect(error.message).not.toContain('line-00'); - expect(error.message).toContain('***@registry.example.com'); + expect(error.message).toContain('https://****:****@registry.example.com/'); expect(error.message).not.toContain('hunter2'); expect(error.message).toContain('3'); }, diff --git a/packages/1-framework/3-tooling/cli/test/utils/url-credentials.test.ts b/packages/1-framework/3-tooling/cli/test/utils/url-credentials.test.ts new file mode 100644 index 000000000000..9fe6519fcd4e --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/utils/url-credentials.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { redactUrlCredentials } from '../../src/utils/url-credentials'; + +describe('redactUrlCredentials', () => { + it('masks the userinfo of every URL in the text', () => { + expect( + redactUrlCredentials( + 'failed: postgres://alice:hunter2@localhost:5432 and mongodb://bob:pw@h1:1,h2:2/db', + ), + ).toBe('failed: postgres://****:****@localhost:5432 and mongodb://****:****@h1:1,h2:2/db'); + }); + + it('masks a password query parameter', () => { + expect(redactUrlCredentials('failed: postgres://h1:1,h2:2/db?sslpassword=pw&x=1')).toBe( + 'failed: postgres://h1:1,h2:2/db?sslpassword=****&x=1', + ); + }); + + it('passes empty input through untouched', () => { + expect(redactUrlCredentials('')).toBe(''); + }); +}); From 18e3e1dfec62878dabfaeed7d3698c3024e5003b Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 25 Sep 2026 07:29:46 +0200 Subject: [PATCH 9/9] fix(cli): mask password query parameter values wherever an error names them sanitizeErrorMessage masked a password query parameter (any key that contains "password", such as ?sslpassword=...) only while the whole connection URL appeared in the message. A driver error that repeated the value on its own, as password=sslsecret or just sslsecret, kept it in clear. This held for URLs new URL parses and for the ones it rejects. The values of those query parameters are now masked wherever they appear, like the username and password. Each path collects the same values its maskConnectionUrl branch masks: searchParams when new URL parses the URL, the existing query regex when it does not. The libpq key=value regex still never runs on a URL, so sslmode=require after a masked parameter survives. Secrets are masked longest first, so a password that is part of an sslpassword value no longer leaves the rest of that value in clear. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/utils/command-helpers.ts | 36 ++++++++++++------- .../cli/src/utils/url-credentials.ts | 10 +++++- .../cli/test/utils/command-helpers.test.ts | 23 ++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts index 6d8c1bd75fbf..5d5425dbd84e 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts @@ -9,11 +9,13 @@ import type { ControlClient } from '../control-api/types'; import { CliStructuredError, errorRuntime } from './cli-errors'; import { hasUrlScheme, + passwordQueryValues, redactUrlCredentials, - type UrlUserinfo, urlUserinfo, } from './url-credentials'; +const PASSWORD_QUERY_KEY = /password/i; + /** * Resolves the absolute path to contract.json from the config. */ @@ -215,7 +217,7 @@ export function maskConnectionUrl(url: string): string { } // Also mask password in query parameters (e.g., ?password=secret, ?sslpassword=secret) for (const key of [...parsed.searchParams.keys()]) { - if (/password/i.test(key)) { + if (PASSWORD_QUERY_KEY.test(key)) { parsed.searchParams.set(key, '****'); } } @@ -233,29 +235,37 @@ export function sanitizeErrorMessage(message: string, connectionUrl?: string): s if (!connectionUrl) { return message; } - const credentials = connectionUrlCredentials(connectionUrl); - if (credentials === undefined) { + const secrets = connectionUrlSecrets(connectionUrl); + if (secrets === undefined) { return maskKeyValueCredentials(message); } let sanitized = message.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl)); - if (credentials.password) { - sanitized = sanitized.replaceAll(credentials.password, '****'); - } - if (credentials.username) { - sanitized = sanitized.replaceAll(credentials.username, '****'); + for (const secret of secrets.filter(Boolean).sort(longestFirst)) { + sanitized = sanitized.replaceAll(secret, '****'); } return sanitized; } -function connectionUrlCredentials(connectionUrl: string): UrlUserinfo | undefined { +function connectionUrlSecrets(connectionUrl: string): string[] | undefined { try { - const { username, password } = new URL(connectionUrl); - return { username, password }; + const { username, password, searchParams } = new URL(connectionUrl); + const queryPasswords = [...searchParams] + .filter(([key]) => PASSWORD_QUERY_KEY.test(key)) + .map(([, value]) => value); + return [username, password, ...queryPasswords]; } catch { - return hasUrlScheme(connectionUrl) ? urlUserinfo(connectionUrl) : undefined; + if (!hasUrlScheme(connectionUrl)) { + return undefined; + } + const { username, password } = urlUserinfo(connectionUrl); + return [username, password, ...passwordQueryValues(connectionUrl)]; } } +function longestFirst(a: string, b: string): number { + return b.length - a.length; +} + function maskKeyValueCredentials(text: string): string { return text .replace(/password\s*=\s*\S+/gi, 'password=****') diff --git a/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts b/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts index 4b50aee5830c..8e38944dc172 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts @@ -2,7 +2,7 @@ const MASK = '****'; const URL_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//; const LEADING_URL_USERINFO = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#\s]+)@/; const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#\s]+)@/g; -const PASSWORD_QUERY_PARAMETER = /([?&][^=&#\s]*password[^=&#\s]*=)[^&#\s]*/gi; +const PASSWORD_QUERY_PARAMETER = /([?&][^=&#\s]*password[^=&#\s]*=)([^&#\s]*)/gi; export interface UrlUserinfo { readonly username: string; @@ -22,6 +22,14 @@ export function urlUserinfo(url: string): UrlUserinfo { return splitUserinfo(url.match(LEADING_URL_USERINFO)?.[1] ?? ''); } +/** + * Reads the values of the query parameters whose key contains `password`, the same ones + * `redactUrlCredentials` masks, without parsing hosts. + */ +export function passwordQueryValues(url: string): string[] { + return [...url.matchAll(PASSWORD_QUERY_PARAMETER)].map((match) => match[2] ?? ''); +} + /** * Masks the userinfo and any password query parameter of every URL in `text`, without parsing * hosts. The masked form matches what `new URL` gives when its username and password are set to diff --git a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts index 420e89646a75..438e23a0a4bc 100644 --- a/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts +++ b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts @@ -138,6 +138,21 @@ describe('sanitizeErrorMessage', () => { expect(sanitized).not.toContain('supersecret'); }); + it('masks a password query parameter value where the message names it on its own', () => { + const url = 'postgresql://admin:s3cret@localhost:5432/mydb?sslpassword=sslkey&sslmode=require'; + const message = 'could not decrypt SSL key with password=sslkey (sslmode=require)'; + + expect(sanitizeErrorMessage(message, url)).toBe( + 'could not decrypt SSL key with password=**** (sslmode=require)', + ); + }); + + it('masks a secret whole when another secret is part of it', () => { + const url = 'postgresql://admin:secret@localhost:5432/mydb?sslpassword=sslsecret'; + + expect(sanitizeErrorMessage('bad SSL key sslsecret', url)).toBe('bad SSL key ****'); + }); + it('handles libpq-style connection strings in messages', () => { const url = 'host=localhost password=secret user=admin dbname=mydb'; const message = 'Failed to connect: host=localhost password=secret user=admin'; @@ -172,6 +187,14 @@ describe('sanitizeErrorMessage', () => { ); }); + it('masks a password query parameter value where the message names it on its own', () => { + const url = 'postgresql://admin@h1:5432,h2:5432/mydb?sslpassword=sslsecret&sslmode=require'; + + expect(sanitizeErrorMessage(`bad SSL key sslsecret for ${url}`, url)).toBe( + 'bad SSL key **** for postgresql://****@h1:5432,h2:5432/mydb?sslpassword=****&sslmode=require', + ); + }); + it('returns a message with a URL without credentials unchanged', () => { const url = 'mongodb://h1:27017,h2:27017/app'; const message = `connect ECONNREFUSED ${url}`;