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/src/utils/command-helpers.ts b/packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts index 9be1e7869c58..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 @@ -7,6 +7,14 @@ 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, + passwordQueryValues, + redactUrlCredentials, + urlUserinfo, +} from './url-credentials'; + +const PASSWORD_QUERY_KEY = /password/i; /** * Resolves the absolute path to contract.json from the config. @@ -195,7 +203,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 { @@ -208,16 +217,13 @@ 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, '****'); } } 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 +235,39 @@ export function sanitizeErrorMessage(message: string, connectionUrl?: string): s if (!connectionUrl) { return message; } + const secrets = connectionUrlSecrets(connectionUrl); + if (secrets === undefined) { + return maskKeyValueCredentials(message); + } + let sanitized = message.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl)); + for (const secret of secrets.filter(Boolean).sort(longestFirst)) { + sanitized = sanitized.replaceAll(secret, '****'); + } + return sanitized; +} + +function connectionUrlSecrets(connectionUrl: string): string[] | 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, searchParams } = new URL(connectionUrl); + const queryPasswords = [...searchParams] + .filter(([key]) => PASSWORD_QUERY_KEY.test(key)) + .map(([, value]) => value); + return [username, password, ...queryPasswords]; } 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=****'); + 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=****') + .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..8e38944dc172 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts @@ -0,0 +1,58 @@ +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] ?? ''); +} + +/** + * 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 + * `****`. + */ +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/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/command-helpers.test.ts b/packages/1-framework/3-tooling/cli/test/utils/command-helpers.test.ts index a56bdec1eeb9..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 @@ -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); + }); }); }); @@ -97,13 +138,69 @@ 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'; - 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('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}`; + + expect(sanitizeErrorMessage(message, url)).toBe(message); + }); }); }); 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(''); + }); +}); 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 ac7ee8865e46..1e99f8b7ec78 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,35 +49,43 @@ type MongoBindingFields = { readonly mongoClient?: MongoDriverClient; }; -function validateMongoUrl(url: string): URL { +const URL_SCHEME_PATTERN = /^([a-z][a-z\d+.-]*):\/\//i; +const MONGO_SCHEMES = new Set(['mongodb', 'mongodb+srv']); + +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(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 { - // 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) { +function extractDbNameFromUrl(parsed: ConnectionString): string | undefined { + // 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 new file mode 100644 index 000000000000..503827061025 --- /dev/null +++ b/packages/3-extensions/mongo/test/binding.test.ts @@ -0,0 +1,95 @@ +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('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.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({ + 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', + }), + ); + }); + + 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 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: [] +---