diff --git a/package.json b/package.json index 3225fba8e..c7fd25bd9 100644 --- a/package.json +++ b/package.json @@ -1273,6 +1273,7 @@ "@mongodb-js/compass-components": "^1.68.0", "@mongodb-js/connection-form": "^1.85.0", "@mongodb-js/connection-info": "^0.26.3", + "@mongodb-js/connection-string-policy": "^0.2.4", "@mongodb-js/device-id": "^0.4.16", "@mongodb-js/mongodb-constants": "^0.16.1", "@mongodb-js/mongodb-schema": "^12.7.10", @@ -1302,7 +1303,6 @@ "mongodb-query-parser": "^5.0.3", "node-machine-id": "1.1.12", "numeral": "^2.0.6", - "query-string": "^7.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf6ed53fd..bfc6871f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@mongodb-js/connection-info': specifier: ^0.26.3 version: 0.26.3(@aws-sdk/credential-providers@3.1042.0)(@mongodb-js/oidc-plugin@2.2.0)(gcp-metadata@7.0.1)(kerberos@7.0.0)(mongodb-client-encryption@7.0.0)(socks@2.8.7) + '@mongodb-js/connection-string-policy': + specifier: ^0.2.4 + version: 0.2.4(mongodb@7.2.0(@aws-sdk/credential-providers@3.1042.0)(gcp-metadata@7.0.1)(kerberos@7.0.0)(mongodb-client-encryption@7.0.0)(socks@2.8.7)) '@mongodb-js/device-id': specifier: ^0.4.16 version: 0.4.16 @@ -129,9 +132,6 @@ importers: numeral: specifier: ^2.0.6 version: 2.0.6 - query-string: - specifier: ^7.1.3 - version: 7.1.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -2151,6 +2151,11 @@ packages: '@mongodb-js/connection-info@0.28.2': resolution: {integrity: sha512-p3GuXp+lG5h4mkMARIhGWBEtXWKeY6TL3n3UkziXe82fwx+MiZD8bhTxv1jlSmc1AoWdhZjDzkeifORLxvwRqA==} + '@mongodb-js/connection-string-policy@0.2.4': + resolution: {integrity: sha512-oHektnUGuaDNihDZnbRG6DCCMWmeBVOKtEy+BeR36H/Q+NZe+cqyjUptEvNmEDSmtQyMdx2zJ0lsutKPRN4D1Q==} + peerDependencies: + mongodb: ^7.2.0 + '@mongodb-js/device-id@0.4.16': resolution: {integrity: sha512-yrNmI5o8W0cFzuUeeraQ+sJ8XFND4NAl8BFSU0fUDMmkvxVm5Bqj9eFs9xMnQrgwl8W6oX1qWnHns3U0H1Z/gQ==} @@ -4231,10 +4236,6 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} - decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -4794,10 +4795,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - filter-obj@1.1.0: - resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} - engines: {node: '>=0.10.0'} - finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -6072,6 +6069,9 @@ packages: mongodb-build-info@1.9.14: resolution: {integrity: sha512-l1bL8ldG2UrZNSV255AJR+9Atqx5kSd4oYQlMLLtbuHhUj5KvFKtSRWkkNR1KrEKHA5UoihM5fMIvl9yTDF9Ow==} + mongodb-build-info@1.9.16: + resolution: {integrity: sha512-GqZkK0+X6CZz15Qk/Q3kUlvwEzPePLT7YgPiInlWABfKYURTAqecuneOZcM3LrKGmSEof6O1uglAU3dnYSnp1Q==} + mongodb-client-encryption@7.0.0: resolution: {integrity: sha512-0egSmyCQ31MLdDFH2j5fHnX8OkAWytUC4ZoPuelU0E+lgPQ2/UcpxkYQXF20SW0rCzADIc0qouiULtqAKDs/uQ==} engines: {node: '>=20.19.0'} @@ -6679,10 +6679,6 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} - query-string@7.1.3: - resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} - engines: {node: '>=6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7245,10 +7241,6 @@ packages: spdx-satisfies@5.0.1: resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} - split-on-first@1.1.0: - resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} - engines: {node: '>=6'} - sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -7284,10 +7276,6 @@ packages: stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} - strict-uri-encode@2.0.0: - resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} - engines: {node: '>=4'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -11568,6 +11556,14 @@ snapshots: - socks - supports-color + '@mongodb-js/connection-string-policy@0.2.4(mongodb@7.2.0(@aws-sdk/credential-providers@3.1042.0)(gcp-metadata@7.0.1)(kerberos@7.0.0)(mongodb-client-encryption@7.0.0)(socks@2.8.7))': + dependencies: + mongodb: 7.2.0(@aws-sdk/credential-providers@3.1042.0)(gcp-metadata@7.0.1)(kerberos@7.0.0)(mongodb-client-encryption@7.0.0)(socks@2.8.7) + mongodb-build-info: 1.9.16 + mongodb-connection-string-url: 7.0.2 + transitivePeerDependencies: + - supports-color + '@mongodb-js/device-id@0.4.16': {} '@mongodb-js/devtools-connect@3.17.3(@mongodb-js/oidc-plugin@2.2.0)(mongodb-log-writer@2.5.16(bson@7.2.0))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1042.0)(gcp-metadata@7.0.1)(kerberos@7.0.0)(mongodb-client-encryption@7.0.0)(socks@2.8.7))': @@ -14295,8 +14291,6 @@ snapshots: dependencies: character-entities: 2.0.2 - decode-uri-component@0.2.2: {} - decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -15014,8 +15008,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - filter-obj@1.1.0: {} - finalhandler@2.1.0: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -16465,6 +16457,13 @@ snapshots: transitivePeerDependencies: - supports-color + mongodb-build-info@1.9.16: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + mongodb-connection-string-url: 7.0.2 + transitivePeerDependencies: + - supports-color + mongodb-client-encryption@7.0.0: dependencies: node-addon-api: 8.5.0 @@ -17159,13 +17158,6 @@ snapshots: dependencies: side-channel: 1.1.0 - query-string@7.1.3: - dependencies: - decode-uri-component: 0.2.2 - filter-obj: 1.1.0 - split-on-first: 1.1.0 - strict-uri-encode: 2.0.0 - queue-microtask@1.2.3: {} quick-lru@4.0.1: {} @@ -17862,8 +17854,6 @@ snapshots: spdx-expression-parse: 3.0.1 spdx-ranges: 2.1.1 - split-on-first@1.1.0: {} - sprintf-js@1.0.3: {} sprintf-js@1.1.3: {} @@ -17899,8 +17889,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - strict-uri-encode@2.0.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index a9c8942eb..5c8160afb 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -46,7 +46,6 @@ import { DocumentEditedTelemetryEvent, } from './telemetry'; -import * as queryString from 'query-string'; import { z } from 'zod'; import { MCPController } from './mcp/mcpController'; import formatError from './utils/formatError'; @@ -164,6 +163,67 @@ const DEEP_LINK_PARAM_SCHEMA: Partial< }), }; +// Every parameter name the commands above accept, plus the tracking parameter that +// `_handleDeepLink` consumes itself. +const DEEP_LINK_PARAM_NAMES = [ + 'utm_source', + ...Object.values(DEEP_LINK_PARAM_SCHEMA).flatMap((schema) => + schema ? Object.keys(schema.shape) : [], + ), +]; + +/** + * Parse the query of a deep link into its parameters. + * + * VS Code percent-decodes `Uri.query` before the handler runs, so by the time we see it + * a connection string's own `&` and `=` are indistinguishable from the separators + * between parameters, and a `+` can no longer be told apart from an encoded space. That + * rules out parsing this as form encoding: it would read `mongodb+srv://` as + * `mongodb srv://` and split the connection string's own options out into parameters of + * their own. + * + * So we split only where a parameter name we know about begins a new parameter, and take + * the rest of each parameter verbatim. A connection string containing `&=` + * would still be split too early, but none of these names is a connection string option. + */ +function parseDeepLinkQuery(query: string): Record { + // A deep link is untrusted input, so the parameters must not inherit from + // `Object.prototype` and let a parameter named e.g. `__proto__` reach it. + const parameters: Record = Object.create(null); + + if (!query) { + return parameters; + } + + const nextParameter = new RegExp( + `&(?=(?:${DEEP_LINK_PARAM_NAMES.join('|')})=)`, + ); + + for (const parameter of query.split(nextParameter)) { + const separator = parameter.indexOf('='); + const name = separator === -1 ? parameter : parameter.slice(0, separator); + const rawValue = separator === -1 ? null : parameter.slice(separator + 1); + + let value: unknown = rawValue; + if (rawValue === 'true' || rawValue === 'false') { + value = rawValue === 'true'; + } + + // Keep repeated parameters as a list, so that they are rejected by the schema + // rather than one of them quietly winning. + if (name in parameters) { + const existing = parameters[name]; + parameters[name] = Array.isArray(existing) + ? [...existing, value] + : [existing, value]; + } else { + parameters[name] = value; + } + } + + return parameters; +} + function validateDeepLinkParams( command: ExtensionCommand, params: Record, @@ -330,10 +390,7 @@ export default class MDBExtensionController implements vscode.Disposable { command = `mdb.${command}`; } - const parameters = queryString.parse(uri.query, { - parseBooleans: true, - parseNumbers: true, - }); + const parameters = parseDeepLinkQuery(uri.query); const source = 'utm_source' in parameters && typeof parameters.utm_source === 'string' diff --git a/src/test/suite/mdbExtensionController.test.ts b/src/test/suite/mdbExtensionController.test.ts index 40e490f00..36e488fcc 100644 --- a/src/test/suite/mdbExtensionController.test.ts +++ b/src/test/suite/mdbExtensionController.test.ts @@ -1671,6 +1671,23 @@ suite('MDBExtensionController Test Suite', function () { }); suite('handleDeepLink', function () { + // Build a deep link the way something linking to the extension would: percent-encode + // each value once, then hand the whole URI to `Uri.parse`. Going through `Uri.parse` + // matters, because it decodes the query before a handler sees it, while `Uri.from` + // keeps whatever it is given verbatim. + const deepLink = ( + command: string, + parameters: Record, + ): vscode.Uri => { + const query = Object.entries(parameters) + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join('&'); + + return vscode.Uri.parse( + `vscode://mongodb.mongodb-vscode/${command}?${query}`, + ); + }; + let fakeExecuteCommand: sinon.SinonStub; let fakeTrack: sinon.SinonStub; @@ -1720,12 +1737,9 @@ suite('MDBExtensionController Test Suite', function () { test('handles valid command with query parameters', async function () { await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/connectWithURI', - query: - 'connectionString=mongodb%3A%2F%2Flocalhost%3A27017&name=local', + deepLink('connectWithURI', { + connectionString: 'mongodb://localhost:27017', + name: 'local', }), ); @@ -1736,14 +1750,58 @@ suite('MDBExtensionController Test Suite', function () { expect(fakeShowErrorMessage).to.not.have.been.called; }); + test('handles an srv connection string', async function () { + await mdbTestExtension.testExtensionController._handleDeepLink( + deepLink('connectWithURI', { + connectionString: 'mongodb+srv://cluster0.example.com/', + }), + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI', { + connectionString: 'mongodb+srv://cluster0.example.com/', + }); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('handles a connection string that sets options', async function () { + const connectionString = + 'mongodb+srv://cluster0.example.com/admin?appName=x&proxyHost=proxy.example.com&tls=false'; + + await mdbTestExtension.testExtensionController._handleDeepLink( + deepLink('connectWithURI', { connectionString }), + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI', { + connectionString, + }); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + + test('handles a connection string that sets options alongside other parameters', async function () { + const connectionString = + 'mongodb+srv://user:s3cr3t@cluster0.example.com/admin?appName=x&tls=false'; + + await mdbTestExtension.testExtensionController._handleDeepLink( + deepLink('connectWithURI', { + connectionString, + reuseExisting: 'true', + name: 'local', + }), + ); + + expect(fakeExecuteCommand).to.have.been.calledWith('mdb.connectWithURI', { + connectionString, + reuseExisting: true, + name: 'local', + }); + expect(fakeShowErrorMessage).to.not.have.been.called; + }); + test('converts query parameters to booleans', async function () { await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/connectWithURI', - query: - 'connectionString=mongodb%3A%2F%2Flocalhost%3A27017&reuseExisting=true', + deepLink('connectWithURI', { + connectionString: 'mongodb://localhost:27017', + reuseExisting: 'true', }), ); @@ -1759,12 +1817,9 @@ suite('MDBExtensionController Test Suite', function () { test('decodes query parameters', async function () { await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/connectWithURI', - query: - 'connectionString=mongodb%3A%2F%2Flocalhost%3A27017%2F%3FappName%3Dblah%26test%3Dtrue&reuseExisting=true', + deepLink('connectWithURI', { + connectionString: 'mongodb://localhost:27017/?appName=blah&test=true', + reuseExisting: 'true', }), ); @@ -1778,6 +1833,32 @@ suite('MDBExtensionController Test Suite', function () { expect(fakeShowErrorMessage).to.not.have.been.called; }); + test('does not let a parameter reach Object.prototype', async function () { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?__proto__=polluted', + ), + ); + + // The parameters are built without a prototype, so the name is carried as an + // ordinary key that never reaches Object.prototype, and the schema then + // rejects it as unrecognised rather than the command running with it. + expect(({} as Record).polluted).to.be.undefined; + expect(fakeExecuteCommand).to.not.have.been.called; + expect(fakeShowErrorMessage).to.have.been.calledOnce; + }); + + test('rejects a repeated parameter rather than picking one', async function () { + await mdbTestExtension.testExtensionController._handleDeepLink( + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/connectWithURI?connectionString=mongodb%3A%2F%2Fa&connectionString=mongodb%3A%2F%2Fb', + ), + ); + + expect(fakeExecuteCommand).to.not.have.been.called; + expect(fakeShowErrorMessage).to.have.been.calledOnce; + }); + test('shows an error message when executeCommand fails', async function () { fakeExecuteCommand.rejects(new Error('fake error')); @@ -1829,12 +1910,9 @@ suite('MDBExtensionController Test Suite', function () { test('removes utm_source from parameters passed to command', async function () { await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/mdb.connectWithURI', - query: - 'connectionString=mongodb%3A%2F%2Flocalhost%3A27017&utm_source=abc', + deepLink('mdb.connectWithURI', { + connectionString: 'mongodb://localhost:27017', + utm_source: 'abc', }), ); @@ -1912,11 +1990,9 @@ suite('MDBExtensionController Test Suite', function () { test('rejects name parameter exceeding maxLength', async function () { const longName = 'a'.repeat(101); await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/connectWithURI', - query: `connectionString=mongodb%3A%2F%2Flocalhost&name=${longName}`, + deepLink('connectWithURI', { + connectionString: 'mongodb://localhost', + name: longName, }), ); @@ -1944,12 +2020,9 @@ suite('MDBExtensionController Test Suite', function () { test('rejects array values', async function () { await mdbTestExtension.testExtensionController._handleDeepLink( - vscode.Uri.from({ - scheme: 'vscode', - authority: 'mongodb.mongodb-vscode', - path: '/searchForDocuments', - query: 'databaseName=a&databaseName=b', - }), + vscode.Uri.parse( + 'vscode://mongodb.mongodb-vscode/searchForDocuments?databaseName=a&databaseName=b', + ), ); expect(fakeExecuteCommand).to.not.have.been.called; diff --git a/src/test/suite/utils/confirmConnection.test.ts b/src/test/suite/utils/confirmConnection.test.ts index 8fc3f9ab1..57c6e5f1d 100644 --- a/src/test/suite/utils/confirmConnection.test.ts +++ b/src/test/suite/utils/confirmConnection.test.ts @@ -40,6 +40,44 @@ suite('Confirm Connection Test Suite', function () { expect(options.detail).to.not.include('s3cr3t'); }); + test('warns about the options that are outside of the policy', async function () { + showWarningMessageStub.resolves(undefined); + + await confirmConnection({ + connectionString: + 'mongodb+srv://cluster0.example.com/?proxyHost=proxy.example.com&appName=mongodb-vscode', + question: 'Do the thing?', + action: 'Connect', + }); + + const [, options] = showWarningMessageStub.firstCall.args as [ + string, + { detail: string }, + ]; + expect(options.detail).to.include('Warning'); + expect(options.detail).to.include('proxyHost'); + // Options that are within the policy are shown, but not warned about. + expect(options.detail).to.include('appName=mongodb-vscode'); + expect(options.detail.split('Warning')[1]).to.not.include('appName'); + }); + + test('does not warn when every option is within the policy', async function () { + showWarningMessageStub.resolves(undefined); + + await confirmConnection({ + connectionString: + 'mongodb+srv://cluster0.example.com/?appName=mongodb-vscode&readPreference=primary', + question: 'Do the thing?', + action: 'Connect', + }); + + const [, options] = showWarningMessageStub.firstCall.args as [ + string, + { detail: string }, + ]; + expect(options.detail).to.not.include('Warning'); + }); + test('is confirmed only when the action is picked', async function () { showWarningMessageStub.resolves('Connect'); expect( diff --git a/src/utils/confirmConnection.ts b/src/utils/confirmConnection.ts index 08b1827f4..fbe50ff78 100644 --- a/src/utils/confirmConnection.ts +++ b/src/utils/confirmConnection.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import ConnectionString, { redactConnectionString, } from 'mongodb-connection-string-url'; +import { checkConnectionStringPolicy } from '@mongodb-js/connection-string-policy'; /** * A connection string is a single unbroken token, and the native dialog VS Code @@ -43,7 +44,9 @@ export const formatConnectionStringForDisplay = ( /** * Ask the user to confirm something we are about to do with a connection string, * showing them the whole connection string with its credentials removed so that they - * can see the options it sets and not just the host they recognise. + * can see the options it sets and not just the host they recognise. Options that are + * outside of the shared connection string policy are called out, because those are the + * ones worth refusing over. */ export const confirmConnection = async ({ connectionString, @@ -54,9 +57,23 @@ export const confirmConnection = async ({ question: string; action: string; }): Promise => { + const { withinPolicy, flaggedOptions } = + checkConnectionStringPolicy(connectionString); + + const detail = [ + formatConnectionStringForDisplay(connectionString), + ...(withinPolicy + ? [] + : [ + '', + 'Warning: these options may put your credentials or data at risk:', + flaggedOptions.join(', '), + ]), + ].join('\n'); + const confirmed = await vscode.window.showWarningMessage( question, - { modal: true, detail: formatConnectionStringForDisplay(connectionString) }, + { modal: true, detail }, action, );