Accept connection string logins without validating them

SQL, Tables, and Gremlin logins probed the account before signing in, so a user whose key was wrong or whose account was unreachable was blocked at the connect form. The probe is now gone: the connection string is accepted as-is and any bad key surfaces on the data-plane requests made from inside the explorer.

Removes validateDirectConnectionStringConnectivity, isAuthorizationError, and the connectExplorer.errors.connectFailed string along with their tests.

Also takes documentEndpoint straight from the AccountEndpoint in SQL and Gremlin connection strings instead of rebuilding it from the account name and a hardcoded DNS zone. Tables still derives it, since a Tables connection string only carries the table endpoint.
This commit is contained in:
Asier Isayas
2026-08-19 08:42:35 -07:00
parent b4a8e78fed
commit 2e979a9b65
10 changed files with 39 additions and 251 deletions
+1 -30
View File
@@ -28,16 +28,10 @@ import React from "react";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { App } from "./HostedExplorer";
import {
ConnectExplorer,
fetchEncryptedToken,
validateDirectConnectionStringConnectivity,
} from "./Platform/Hosted/Components/ConnectExplorer";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { fetchAccessData } from "./Platform/Hosted/Helpers/PortalAccessData";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
const mockValidateDirectConnectionStringConnectivity =
validateDirectConnectionStringConnectivity as jest.MockedFunction<typeof validateDirectConnectionStringConnectivity>;
(ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />);
@@ -62,7 +56,6 @@ beforeEach(() => {
(useConfig as jest.Mock).mockReturnValue({});
(fetchAccessData as jest.Mock).mockResolvedValue(undefined);
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
mockValidateDirectConnectionStringConnectivity.mockResolvedValue(undefined);
});
const dispatchPostMessage = (data: unknown, origin: string) => {
@@ -89,7 +82,6 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled();
});
it("accepts a valid Mongo connection string from an allowed origin", async () => {
@@ -138,7 +130,6 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled();
});
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
@@ -155,26 +146,6 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled();
});
it("does not open Data Explorer when the Cosmos client cannot connect", async () => {
mockValidateDirectConnectionStringConnectivity.mockRejectedValue(new Error("Unable to connect to the account."));
const { container } = render(<App />);
const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`;
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: validConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled();
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
expect(container.querySelector('[data-test="DataExplorerFrame"]')).toBeNull();
});
it("rejects messages from a disallowed origin", async () => {
+3 -18
View File
@@ -12,11 +12,7 @@ import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
import {
ConnectExplorer,
fetchEncryptedToken,
validateDirectConnectionStringConnectivity,
} from "./Platform/Hosted/Components/ConnectExplorer";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { MeControl } from "./Platform/Hosted/Components/MeControl";
@@ -76,19 +72,8 @@ 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.
validateDirectConnectionStringConnectivity(connStr, metadata)
.then(() => {
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
const message = getErrorMessage(error);
logError(
`Failed to connect with connection string: ${message}`,
"HostedExplorer/connectWithConnectionString",
);
setErrorMessage(message);
});
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
-5
View File
@@ -1174,10 +1174,5 @@
"retryConsoleMessage": "Data transfer job \"{{jobName}}\" in progress, total count: {{totalCount}}, processed count: {{processedCount}}"
}
}
},
"connectExplorer": {
"errors": {
"connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and that the account is reachable, then try again."
}
}
}
@@ -1,18 +1,10 @@
jest.mock("../../../hooks/useDirectories");
jest.mock("Common/CosmosClient", () => ({
client: jest.fn(),
}));
jest.mock("Common/Logger", () => ({
logWarning: jest.fn(),
}));
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { client } from "Common/CosmosClient";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { updateUserContext, userContext } from "UserContext";
import React from "react";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { ConnectExplorer, validateDirectConnectionStringConnectivity } from "./ConnectExplorer";
import { ConnectExplorer } from "./ConnectExplorer";
it("shows the connect form", () => {
const connectionString = "fakeConnectionString";
@@ -75,42 +67,3 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures });
});
describe("validateDirectConnectionStringConnectivity", () => {
const connectionString = "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=fakeKey==;";
const metadata = { documentEndpoint: "https://test.documents.azure.com:443/" } as AccessInputMetadata;
const mockGetDatabaseAccount = (getDatabaseAccount: () => Promise<unknown>) => {
(client as jest.Mock).mockReturnValue({ getDatabaseAccount });
};
it.each([401, 403])("blocks the login and surfaces the error returned for a %i", async (statusCode) => {
// The Cosmos SDK reports the HTTP status on `ErrorResponse.code`.
const authorizationError = Object.assign(new Error("The input authorization token is invalid"), {
code: statusCode,
});
mockGetDatabaseAccount(() => Promise.reject(authorizationError));
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).rejects.toBe(
authorizationError,
);
});
it.each([429, 503])("does not block the login for a %i", async (statusCode) => {
mockGetDatabaseAccount(() => Promise.reject(Object.assign(new Error("Service unavailable"), { code: statusCode })));
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
});
it("does not block the login when the account is unreachable", async () => {
mockGetDatabaseAccount(() => Promise.reject(new Error("Failed to fetch")));
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
});
it("allows the login when the account can be read", async () => {
mockGetDatabaseAccount(() => Promise.resolve({}));
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
});
});
@@ -1,9 +1,6 @@
import { useBoolean } from "@fluentui/react-hooks";
import { client } from "Common/CosmosClient";
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { logWarning } from "Common/Logger";
import { Keys, t } from "Localization";
import { updateUserContext, userContext } from "UserContext";
import { userContext } from "UserContext";
import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
@@ -11,10 +8,9 @@ import { AuthType } from "../../../AuthType";
import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { isAuthorizationError } from "../../../Utils/AuthorizationUtils";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "../HostedUtils";
import { isDirectConnectionStringLoginApi } from "../HostedUtils";
interface Props {
connectionString: string;
@@ -27,10 +23,9 @@ interface Props {
setErrorMessage: (message: string) => void;
}
// Turns a failed Portal Backend response into an error that carries the message returned by the service
// and the HTTP status code, so the caller can tell an authorization failure from anything else.
// Turns a failed Portal Backend response into an error that carries the message returned by the service.
const errorFromResponse = async (response: Response): Promise<Error> =>
Object.assign(new Error((await response.text()) || response.statusText), { statusCode: response.status });
new Error((await response.text()) || response.statusText);
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
const headers = new Headers();
@@ -58,41 +53,6 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
return (await response.text()).toLowerCase() === "true";
};
// Verifies the connection string can actually authenticate against the account by making a lightweight
// read of the database account through the Cosmos client. A 401 or 403 means the key is invalid or not
// authorized, so the login is blocked and the error returned by the service is shown. Any other failure
// (network blip, throttling, service outage) is not a credential problem, so the login is allowed to
// continue instead of blocking on a transient error.
export const validateDirectConnectionStringConnectivity = async (
connectionString: string,
metadata: AccessInputMetadata,
): Promise<void> => {
const masterKey = extractMasterKeyfromConnectionString(connectionString);
if (!metadata?.documentEndpoint || !masterKey) {
throw new Error(t(Keys.connectExplorer.errors.connectFailed));
}
updateUserContext({
authType: AuthType.ConnectionString,
masterKey,
endpoint: metadata.documentEndpoint,
refreshCosmosClient: true,
});
try {
await client().getDatabaseAccount();
} catch (error) {
if (isAuthorizationError(error)) {
throw error;
}
logWarning(
`Could not verify the connection string against the account: ${getErrorMessage(error)}`,
"ConnectExplorer/validateDirectConnectionStringConnectivity",
);
}
};
export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken,
login,
@@ -142,13 +102,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
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.
try {
await validateDirectConnectionStringConnectivity(connectionString, metadata);
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
} catch (error) {
setErrorMessage(getErrorMessage(error));
}
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
@@ -16,6 +16,16 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiEndpoint).toBeUndefined();
});
it("should keep the document endpoint given by the connection string", () => {
// The endpoint is taken from the connection string rather than rebuilt from the account name, so a
// string that omits the port keeps it omitted.
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com/;AccountKey=${mockMasterKey};`,
);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
});
it("should parse a valid mongo account connection string", () => {
const metadata = parseConnectionString(
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
@@ -2,7 +2,8 @@ import * as Constants from "../../../Common/Constants";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's
// accessinputmetadata API constructs from the account name for SQL, Tables, and Gremlin accounts.
// accessinputmetadata API constructs from the account name when the connection string does not already
// contain the endpoint.
const DocumentEndpointZone = "documents.azure.com";
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
const DnsPort = "443";
@@ -17,6 +18,9 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
accessInput.apiKind = ApiKind.SQL;
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
// given instead of rebuilding it from the account name.
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2];
@@ -44,18 +48,14 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined;
}
// For the APIs that log in directly through the Cosmos client (SQL, Tables, Gremlin), derive the
// endpoints client-side instead of Portal Backend's accessinputmetadata call.
// Tables connection strings only carry the table endpoint, so the document endpoint that data
// operations go through has to be derived from the account name. Gremlin accounts additionally
// need the Gremlin endpoint, which is never part of the connection string.
if (accessInput.accountName) {
if (
accessInput.apiKind === ApiKind.SQL ||
accessInput.apiKind === ApiKind.Table ||
accessInput.apiKind === ApiKind.Graph
) {
if (accessInput.apiKind === ApiKind.Table) {
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
if (accessInput.apiKind === ApiKind.Graph) {
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
}
} else if (accessInput.apiKind === ApiKind.Graph) {
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
}
}
-34
View File
@@ -203,38 +203,4 @@ describe("AuthorizationUtils", () => {
expect(AuthorizationUtils.getRedirectBridgeUrl()).toBe("https://cosmos.azure.com/redirectBridge.html");
});
});
describe("isAuthorizationError()", () => {
it.each([401, 403])("treats a Cosmos client `code` of %i as an authorization failure", (statusCode) => {
expect(
AuthorizationUtils.isAuthorizationError(Object.assign(new Error("Unauthorized"), { code: statusCode })),
).toBe(true);
});
it.each([401, 403])("treats a `statusCode` of %i as an authorization failure", (statusCode) => {
expect(AuthorizationUtils.isAuthorizationError(Object.assign(new Error("Unauthorized"), { statusCode }))).toBe(
true,
);
});
it("handles a status reported as a string", () => {
expect(AuthorizationUtils.isAuthorizationError({ code: "403" })).toBe(true);
});
it.each([404, 429, 500, 503])("does not treat %i as an authorization failure", (statusCode) => {
expect(AuthorizationUtils.isAuthorizationError({ code: statusCode })).toBe(false);
});
it("does not treat a non numeric code as an authorization failure", () => {
expect(AuthorizationUtils.isAuthorizationError({ code: "ENOTFOUND" })).toBe(false);
});
it("does not treat a network failure as an authorization failure", () => {
expect(AuthorizationUtils.isAuthorizationError(new Error("Failed to fetch"))).toBe(false);
});
it("handles a missing error", () => {
expect(AuthorizationUtils.isAuthorizationError(undefined)).toBe(false);
});
});
});
-11
View File
@@ -254,14 +254,3 @@ export function isCloudShellEntraAuthEnabled(userContext: UserContext): boolean
useDataplaneRbacAuthorization(userContext) || userContext.databaseAccount?.properties?.disableLocalAuth === true
);
}
/**
* True when an error is an authorization failure (401 Unauthorized or 403 Forbidden). The Cosmos client
* reports the HTTP status on `ErrorResponse.code`; errors raised from a `fetch` response carry it on
* `statusCode`.
*/
export function isAuthorizationError(error: unknown): boolean {
const { code, statusCode } = (error ?? {}) as { code?: unknown; statusCode?: unknown };
const status = Number(statusCode ?? code);
return status === Constants.HttpStatusCodes.Unauthorized || status === Constants.HttpStatusCodes.Forbidden;
}