From ea42796379f5f221b9ee75df59daf281dd7b132e Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Mon, 17 Aug 2026 06:47:22 -0700 Subject: [PATCH] Remove connection string validation for connection string login - Remove validateDirectConnectionStringLogin and its helpers (extractEndpointHostFromConnectionString, extractHostToken, directLoginAllowlistedEndpointZones) - Remove old extractMasterKeyfromConnectionString (Gremlin-specific), rename extractAccountKeyFromConnectionString to extractMasterKeyfromConnectionString - Change validateDirectConnectionStringConnectivity to throw on error instead of returning string|undefined - Simplify direct-login flow: submit connection string to CosmosClient as-is, no format or endpoint validation - Keep connectivity pre-check (throws if CosmosClient cannot reach account) - Remove 6 unused localization keys for validation errors - Update tests to match new behavior --- src/HostedExplorer.test.tsx | 2 +- src/HostedExplorer.tsx | 32 +----- src/Localization/en/Resources.json | 11 +- .../Hosted/Components/ConnectExplorer.tsx | 47 +++----- src/Platform/Hosted/HostedUtils.test.ts | 95 ++-------------- src/Platform/Hosted/HostedUtils.ts | 107 +----------------- src/hooks/useKnockoutExplorer.ts | 1 - 7 files changed, 29 insertions(+), 266 deletions(-) diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx index b08a10df3..19483cf7d 100644 --- a/src/HostedExplorer.test.tsx +++ b/src/HostedExplorer.test.tsx @@ -159,7 +159,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { }); it("does not open the Data Explorer when the Cosmos client cannot connect", async () => { - mockValidateDirectConnectionStringConnectivity.mockResolvedValue("Unable to connect to the account."); + mockValidateDirectConnectionStringConnectivity.mockRejectedValue(new Error("Unable to connect to the account.")); const { container } = render(); const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`; diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index b4e5a9bcb..982d37d4b 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -23,12 +23,7 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton"; import "./Platform/Hosted/ConnectScreen.less"; import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser"; import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils"; -import { - extractAccountKeyFromConnectionString, - extractMasterKeyfromConnectionString, - isDirectConnectionStringLoginApi, - validateDirectConnectionStringLogin, -} from "./Platform/Hosted/HostedUtils"; +import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "./Platform/Hosted/HostedUtils"; import "./Shared/appInsights"; import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils"; import { useAADAuth } from "./hooks/useAADAuth"; @@ -74,31 +69,14 @@ const App: React.FunctionComponent = () => { if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { // SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so we skip // the Portal Backend proxy and use the metadata derived from the connection string directly. - // Validate the host and account client-side (mirrors the backend's ValidateHostAndAccount). - const validationError = validateDirectConnectionStringLogin(connStr, metadata); - if (validationError) { - logError( - `Rejected connection string for direct login: ${validationError}`, - "HostedExplorer/connectWithConnectionString", - ); - return; - } - // Only open the view once we confirm the Cosmos client can actually connect. validateDirectConnectionStringConnectivity(connStr, metadata) - .then((connectivityError) => { - if (connectivityError) { - logError( - `Rejected connection string for direct login: ${connectivityError}`, - "HostedExplorer/connectWithConnectionString", - ); - return; - } + .then(() => { setDirectLoginMetadata(metadata); setAuthType(AuthType.ConnectionString); }) .catch((error) => { logError( - `Failed to validate connection string for direct login: ${error}`, + `Failed to connect with connection string: ${error}`, "HostedExplorer/connectWithConnectionString", ); }); @@ -167,9 +145,7 @@ const App: React.FunctionComponent = () => { authType: AuthType.ConnectionString, encryptedToken, encryptedTokenMetadata: accountMetadata, - masterKey: directLoginMetadata - ? extractAccountKeyFromConnectionString(connectionString) - : extractMasterKeyfromConnectionString(connectionString), + masterKey: extractMasterKeyfromConnectionString(connectionString), }; } else if (authType === AuthType.ResourceToken) { frameWindow.hostedConfig = { diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index b43188c74..9a3c41324 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -1177,15 +1177,8 @@ }, "connectExplorer": { "errors": { - "connectionStringMissing": "Connection string is missing.", - "accountNameMissing": "Account name is missing from the connection string.", - "endpointHostMissing": "Endpoint host is missing from the connection string.", - "endpointHostNotAllowed": "Endpoint host is not allowed.", - "accountNameMismatch": "Account name does not match the endpoint host.", - "accountKeyMissing": "Account key is missing from the connection string.", - "connectivityInvalid": "Unable to connect to the account with the provided connection string.", - "connectivityUnreachable": "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable.", - "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again." + "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again.", + "connectivityUnreachable": "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable." } } } diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index ba7958957..a0b65ad58 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -11,11 +11,7 @@ import { configContext } from "../../../ConfigContext"; import { AccessInputMetadata } from "../../../Contracts/DataModels"; import { parseConnectionString } from "../Helpers/ConnectionStringParser"; import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils"; -import { - extractAccountKeyFromConnectionString, - isDirectConnectionStringLoginApi, - validateDirectConnectionStringLogin, -} from "../HostedUtils"; +import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "../HostedUtils"; interface Props { connectionString: string; @@ -52,21 +48,17 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri return (await response.text()).toLowerCase() === "true"; }; -// Verifies the account key can actually authenticate against the account by making a lightweight read of -// the database account through the Cosmos client. Returns undefined on success, or an error message when -// the client cannot connect. +// Verifies the connection string can actually authenticate against the account by making a lightweight +// read of the database account through the Cosmos client. Throws on failure. export const validateDirectConnectionStringConnectivity = async ( connectionString: string, metadata: AccessInputMetadata, -): Promise => { - const masterKey = extractAccountKeyFromConnectionString(connectionString); +): Promise => { + const masterKey = extractMasterKeyfromConnectionString(connectionString); if (!metadata?.documentEndpoint || !masterKey) { - return t(Keys.connectExplorer.errors.connectivityInvalid); + throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable)); } - // The Cosmos client reads its connection settings from userContext, so write the master key and - // endpoint there. Then we issue a lightweight authenticated read to confirm the - // credentials work. updateUserContext({ authType: AuthType.ConnectionString, masterKey, @@ -76,9 +68,8 @@ export const validateDirectConnectionStringConnectivity = async ( try { await client().getDatabaseAccount(); - return undefined; } catch { - return t(Keys.connectExplorer.errors.connectivityUnreachable); + throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable)); } }; @@ -125,23 +116,13 @@ export const ConnectExplorer: React.FunctionComponent = ({ if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { // SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so // we skip the Portal Backend proxy and use the metadata parsed from the connection string. - // Validate the host and account client-side (mirrors the backend's ValidateHostAndAccount). - const validationError = validateDirectConnectionStringLogin(connectionString, metadata); - if (validationError) { - setErrorMessage(validationError); - return; + try { + await validateDirectConnectionStringConnectivity(connectionString, metadata); + setDirectLoginMetadata(metadata); + setAuthType(AuthType.ConnectionString); + } catch (error) { + setErrorMessage(error.message); } - // Only open the view once we confirm the Cosmos client can actually connect. - const connectivityError = await validateDirectConnectionStringConnectivity( - connectionString, - metadata, - ); - if (connectivityError) { - setErrorMessage(connectivityError); - return; - } - setDirectLoginMetadata(metadata); - setAuthType(AuthType.ConnectionString); return; } @@ -150,7 +131,7 @@ export const ConnectExplorer: React.FunctionComponent = ({ setEncryptedToken(encryptedToken); setAuthType(AuthType.ConnectionString); } catch (error) { - setErrorMessage(t(Keys.connectExplorer.errors.connectFailed)); + setErrorMessage(t(Keys.connectExplorer.errors.connectivityUnreachable)); } }} > diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts index a34b71236..e6f1e4bfd 100644 --- a/src/Platform/Hosted/HostedUtils.test.ts +++ b/src/Platform/Hosted/HostedUtils.test.ts @@ -1,10 +1,8 @@ import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels"; import { - extractAccountKeyFromConnectionString, - extractEndpointHostFromConnectionString, + extractMasterKeyfromConnectionString, getDatabaseAccountPropertiesFromMetadata, isDirectConnectionStringLoginApi, - validateDirectConnectionStringLogin, } from "./HostedUtils"; describe("getDatabaseAccountPropertiesFromMetadata", () => { @@ -37,13 +35,13 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => { }); }); -describe("extractAccountKeyFromConnectionString", () => { +describe("extractMasterKeyfromConnectionString", () => { const mockAccountName = "Test"; const mockKey = "abc123+/=someKey=="; it("extracts the account key from a SQL connection string", () => { expect( - extractAccountKeyFromConnectionString( + extractMasterKeyfromConnectionString( `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`, ), ).toBe(mockKey); @@ -51,7 +49,7 @@ describe("extractAccountKeyFromConnectionString", () => { it("extracts the account key from a Table connection string", () => { expect( - extractAccountKeyFromConnectionString( + extractMasterKeyfromConnectionString( `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`, ), ).toBe(mockKey); @@ -59,7 +57,7 @@ describe("extractAccountKeyFromConnectionString", () => { it("extracts the account key from a Gremlin connection string", () => { expect( - extractAccountKeyFromConnectionString( + extractMasterKeyfromConnectionString( `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`, ), ).toBe(mockKey); @@ -67,12 +65,12 @@ describe("extractAccountKeyFromConnectionString", () => { it("returns undefined when there is no account key", () => { expect( - extractAccountKeyFromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`), + extractMasterKeyfromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`), ).toBeUndefined(); }); it("returns undefined for an empty connection string", () => { - expect(extractAccountKeyFromConnectionString("")).toBeUndefined(); + expect(extractMasterKeyfromConnectionString("")).toBeUndefined(); }); }); @@ -89,82 +87,3 @@ describe("isDirectConnectionStringLoginApi", () => { expect(isDirectConnectionStringLoginApi(ApiKind.Cassandra)).toBe(false); }); }); - -describe("extractEndpointHostFromConnectionString", () => { - it("extracts the host from a SQL/Gremlin AccountEndpoint", () => { - expect( - extractEndpointHostFromConnectionString( - "AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=key==;", - ), - ).toBe("my-account.documents.azure.com"); - }); - - it("extracts the host from a Table TableEndpoint", () => { - expect( - extractEndpointHostFromConnectionString( - "DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=key==;TableEndpoint=https://my-account.table.cosmos.azure.com:443/;", - ), - ).toBe("my-account.table.cosmos.azure.com"); - }); - - it("returns undefined when no endpoint host is present", () => { - expect(extractEndpointHostFromConnectionString("AccountName=my-account;AccountKey=key==;")).toBeUndefined(); - }); -}); - -describe("validateDirectConnectionStringLogin", () => { - const mockKey = "abc123+/=someKey=="; - const sqlMetadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata; - const tableMetadata = { accountName: "my-account", apiKind: ApiKind.Table } as AccessInputMetadata; - const gremlinMetadata = { accountName: "my-account", apiKind: ApiKind.Graph } as AccessInputMetadata; - - it("returns undefined for a valid SQL connection string", () => { - const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`; - expect(validateDirectConnectionStringLogin(connectionString, sqlMetadata)).toBeUndefined(); - }); - - it("returns undefined for a valid Table connection string using the cosmos.azure.com zone", () => { - const connectionString = `DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=${mockKey};TableEndpoint=https://my-account.table.cosmos.azure.com:443/;`; - expect(validateDirectConnectionStringLogin(connectionString, tableMetadata)).toBeUndefined(); - }); - - it("returns undefined for a valid Gremlin connection string", () => { - const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`; - expect(validateDirectConnectionStringLogin(connectionString, gremlinMetadata)).toBeUndefined(); - }); - - it("rejects an empty connection string", () => { - expect(validateDirectConnectionStringLogin("", {} as AccessInputMetadata)).toBe("Connection string is missing."); - }); - - it("rejects when the account name is missing from metadata", () => { - const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`; - expect(validateDirectConnectionStringLogin(connectionString, {} as AccessInputMetadata)).toBe( - "Account name is missing from the connection string.", - ); - }); - - it("rejects a host that is not in an allowlisted zone", () => { - // Point the endpoint host at an untrusted zone while keeping a valid parsed account name. - const tamperedConnectionString = `AccountEndpoint=https://my-account.evil.example.com:443/;AccountKey=${mockKey};`; - expect(validateDirectConnectionStringLogin(tamperedConnectionString, sqlMetadata)).toBe( - "Endpoint host is not allowed.", - ); - }); - - it("rejects when the account name does not match the endpoint host label", () => { - const connectionString = `AccountEndpoint=https://real-account.documents.azure.com:443/;AccountKey=${mockKey};`; - const metadata = { accountName: "different-account", apiKind: ApiKind.SQL } as AccessInputMetadata; - expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe( - "Account name does not match the endpoint host.", - ); - }); - - it("rejects when the account key is missing", () => { - const connectionString = "AccountEndpoint=https://my-account.documents.azure.com:443/;"; - const metadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata; - expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe( - "Account key is missing from the connection string.", - ); - }); -}); diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts index fc9e21ecb..e4f35e99a 100644 --- a/src/Platform/Hosted/HostedUtils.ts +++ b/src/Platform/Hosted/HostedUtils.ts @@ -1,6 +1,5 @@ import { AccountKind, CapabilityNames } from "../../Common/Constants"; import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels"; -import { Keys, t } from "../../Localization"; import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; import { userContext } from "../../UserContext"; @@ -41,15 +40,9 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC return AccountKind.GlobalDocumentDB; } -export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined { - // Only Gremlin uses the actual master key for connection to cosmos - const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$"); - return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined; -} - // Extracts the account key from any Cosmos connection string. The account key value cannot contain a // semicolon, so we capture everything up to the next connection-string delimiter. -export function extractAccountKeyFromConnectionString(connectionString: string): string | undefined { +export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined { const matchedParts = connectionString?.match(/AccountKey=([^;]*)/); return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined; } @@ -59,101 +52,3 @@ export function extractAccountKeyFromConnectionString(connectionString: string): export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean { return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph; } - -// DNS zones a SQL, Tables, or Gremlin connection-string endpoint host is allowed to belong to. Mirrors -// the allowlist enforced by the Portal Backend's -// ConnectionStringAccessProvider.ValidateHostAndAccount for the direct-login APIs. Both the newer -// `table.cosmos.azure.com` and legacy `table.cosmosdb.azure.com` Tables zones are accepted. -const directLoginAllowlistedEndpointZones = [ - "documents.azure.com", - "table.cosmos.azure.com", - "table.cosmosdb.azure.com", -]; - -// Returns the substring of `value` starting at `startIndex` up to the first ':', '/', or '?'. -// Mirrors ExtractHostToken in the Portal Backend. -function extractHostToken(value: string, startIndex: number): string { - let end = value.length; - for (let i = startIndex; i < value.length; i++) { - const c = value[i]; - if (c === ":" || c === "/" || c === "?") { - end = i; - break; - } - } - return value.substring(startIndex, end); -} - -// Extracts the endpoint host from a connection string, mirroring ExtractEndpointHost in the Portal -// Backend. Handles AccountEndpoint/TableEndpoint (URI or bare host) and HostName. -export function extractEndpointHostFromConnectionString(connectionString: string): string | undefined { - for (const part of connectionString.split(";")) { - const trimmed = part.trim(); - - const equalsIndex = trimmed.indexOf("="); - if (equalsIndex < 0 || equalsIndex === trimmed.length - 1) { - continue; - } - - const key = trimmed.substring(0, equalsIndex).trim().toLowerCase(); - const value = trimmed.substring(equalsIndex + 1).trim(); - - if (key === "accountendpoint" || key === "tableendpoint") { - try { - return new URL(value).hostname; - } catch { - // Value may be a bare host without a scheme. - return extractHostToken(value, 0); - } - } - - if (key === "hostname") { - return extractHostToken(value, 0); - } - } - - return undefined; -} - -// Client-side equivalent of the Portal Backend's ConnectionStringAccessProvider.ValidateHostAndAccount, -// scoped to the direct-login APIs (SQL, Tables, Gremlin). Ensures the connection string parses to an -// account name, exposes an endpoint host in an allowlisted DNS zone, that the account name matches the -// first DNS label of that host, and that an account key is present. Returns an error message when -// invalid, or undefined when the connection string is valid for direct login. -export function validateDirectConnectionStringLogin( - connectionString: string, - metadata: AccessInputMetadata, -): string | undefined { - if (!connectionString) { - return t(Keys.connectExplorer.errors.connectionStringMissing); - } - - if (!metadata || !metadata.accountName) { - return t(Keys.connectExplorer.errors.accountNameMissing); - } - - const host = extractEndpointHostFromConnectionString(connectionString); - if (!host) { - return t(Keys.connectExplorer.errors.endpointHostMissing); - } - - // The host must belong to one of the allowlisted runtime endpoint zones. - const isAllowlistedHost = directLoginAllowlistedEndpointZones.some((zone) => - host.toLowerCase().endsWith(`.${zone.toLowerCase()}`), - ); - if (!isAllowlistedHost) { - return t(Keys.connectExplorer.errors.endpointHostNotAllowed); - } - - // The account name must be the first DNS label of the host. - if (host.split(".")[0].toLowerCase() !== metadata.accountName.toLowerCase()) { - return t(Keys.connectExplorer.errors.accountNameMismatch); - } - - // Direct login signs requests with the account key, so it must be present in the connection string. - if (!extractAccountKeyFromConnectionString(connectionString)) { - return t(Keys.connectExplorer.errors.accountKeyMissing); - } - - return undefined; -} diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 6c6c1c368..7e46ae500 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -478,7 +478,6 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer }); } else { // Legacy encrypted-token proxy path (Mongo, Cassandra). - // For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login updateUserContext({ authType: AuthType.EncryptedToken, accessToken: encodeURIComponent(config.encryptedToken),