From 5ee2ca37d504c1b0463c257e7724ddd06f579b10 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Tue, 26 May 2026 13:46:33 -0400 Subject: [PATCH] Security hardening for Try Cosmos DB connection string flow (#2500) * Security hardening for Try Cosmos DB connection string flow - Validate connection string format via parseConnectionString before accepting postMessage - Restrict localhost:12900 in allowedHostedExplorerEndpoints to development builds only - Export App component for testability with null-check on render target - Add 12 unit tests covering origin validation, format validation, and message handling * Fix HostedExplorer test mock types for compile --------- Co-authored-by: Asier Isayas --- src/HostedExplorer.test.tsx | 246 ++++++++++++++++++++++++++++++++++++ src/HostedExplorer.tsx | 15 ++- src/Utils/EndpointUtils.ts | 2 +- 3 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 src/HostedExplorer.test.tsx diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx new file mode 100644 index 000000000..083700e0e --- /dev/null +++ b/src/HostedExplorer.test.tsx @@ -0,0 +1,246 @@ +jest.mock("./hooks/useAADAuth"); +jest.mock("./hooks/useConfig"); +jest.mock("./hooks/usePortalAccessToken"); +jest.mock("./Platform/Hosted/Components/ConnectExplorer"); +jest.mock("./Shared/appInsights"); +jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({ + AccountSwitcher: () => null, +})); +jest.mock("./Platform/Hosted/Components/DirectoryPickerPanel", () => ({ + DirectoryPickerPanel: () => null, +})); +jest.mock("./Platform/Hosted/Components/FeedbackCommandButton", () => ({ + FeedbackCommandButton: () => null, +})); +jest.mock("./Platform/Hosted/Components/MeControl", () => ({ + MeControl: () => null, +})); +jest.mock("./Platform/Hosted/Components/SignInButton", () => ({ + SignInButton: () => null, +})); +jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({ + AadAuthorizationFailure: () => null, +})); + +import "@testing-library/jest-dom"; +import { act, render } from "@testing-library/react"; +import React from "react"; +import { useAADAuth } from "./hooks/useAADAuth"; +import { useConfig } from "./hooks/useConfig"; +import { useTokenMetadata } from "./hooks/usePortalAccessToken"; +import { App } from "./HostedExplorer"; +import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer"; + +const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction; + +(ConnectExplorer as jest.Mock).mockImplementation(() =>
); + +import type { AccountInfo } from "@azure/msal-browser"; +import type { AadAuthFailure } from "./hooks/useAADAuth"; + +const defaultAADAuth = { + isLoggedIn: false, + armToken: "", + graphToken: "", + account: undefined as AccountInfo | null | undefined, + tenantId: "", + logout: jest.fn(), + login: jest.fn(), + switchTenant: jest.fn(), + authFailure: undefined as AadAuthFailure | null | undefined, +}; + +beforeEach(() => { + jest.clearAllMocks(); + (useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth); + (useConfig as jest.Mock).mockReturnValue({}); + (useTokenMetadata as jest.Mock).mockReturnValue(undefined); + mockFetchEncryptedToken.mockResolvedValue("encrypted-token"); +}); + +const dispatchPostMessage = (data: unknown, origin: string) => { + const event = new MessageEvent("message", { data, origin }); + window.dispatchEvent(event); +}; + +describe("HostedExplorer tryCosmosDB postMessage handler", () => { + it("accepts a valid SQL connection string from an allowed origin", async () => { + render(); + + const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: validConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr); + }); + + it("accepts a valid Mongo connection string from an allowed origin", async () => { + render(); + + const mongoConnStr = "mongodb://myaccount:dGVzdGtleQ==@myaccount.documents.azure.com:10255"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: mongoConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockFetchEncryptedToken).toHaveBeenCalledWith(mongoConnStr); + }); + + it("accepts a valid Cassandra connection string from an allowed origin", async () => { + render(); + + const cassandraConnStr = + "AccountEndpoint=https://myaccount.cassandra.cosmosdb.azure.com:443/;AccountKey=dGVzdGtleQ==;"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: cassandraConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockFetchEncryptedToken).toHaveBeenCalledWith(cassandraConnStr); + }); + + it("accepts a valid Table connection string from an allowed origin", async () => { + render(); + + const tableConnStr = + "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=dGVzdGtleQ==;TableEndpoint=https://myaccount.table.cosmosdb.azure.com:443/;"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: tableConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr); + }); + + it("accepts a valid Gremlin connection string from an allowed origin", async () => { + render(); + + const gremlinConnStr = + "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;ApiKind=Gremlin;"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: gremlinConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr); + }); + + it("rejects messages from a disallowed origin", async () => { + render(); + + const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;"; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: validConnStr }, + "https://evil.example.com", + ); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("rejects messages with an invalid connection string format", async () => { + render(); + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: "not-a-real-connection-string" }, + "https://cosmos.azure.com", + ); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("rejects messages with a non-string connection string value", async () => { + render(); + + await act(async () => { + dispatchPostMessage({ type: "tryCosmosDBConnectionString", connectionString: 12345 }, "https://cosmos.azure.com"); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("rejects messages with a missing connection string", async () => { + render(); + + await act(async () => { + dispatchPostMessage({ type: "tryCosmosDBConnectionString" }, "https://cosmos.azure.com"); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("ignores messages with an unrelated type", async () => { + render(); + + const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;"; + + await act(async () => { + dispatchPostMessage({ type: "someOtherMessage", connectionString: validConnStr }, "https://cosmos.azure.com"); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("sends tryCosmosDBReady to opener when present", () => { + const mockPostMessage = jest.fn(); + const originalOpener = window.opener; + Object.defineProperty(window, "opener", { + value: { postMessage: mockPostMessage }, + writable: true, + configurable: true, + }); + + render(); + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "tryCosmosDBReady" }, "https://cosmos.azure.com"); + + Object.defineProperty(window, "opener", { + value: originalOpener, + writable: true, + configurable: true, + }); + }); + + it("does not crash when there is no opener", () => { + const originalOpener = window.opener; + Object.defineProperty(window, "opener", { + value: null, + writable: true, + configurable: true, + }); + + expect(() => render()).not.toThrow(); + + Object.defineProperty(window, "opener", { + value: originalOpener, + writable: true, + configurable: true, + }); + }); +}); diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index 0d6cb37f0..41863fb7d 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -17,6 +17,7 @@ import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackComm import { MeControl } from "./Platform/Hosted/Components/MeControl"; 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 { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils"; import "./Shared/appInsights"; @@ -90,8 +91,11 @@ const App: React.FunctionComponent = () => { if (!allowedHostedExplorerEndpoints.includes(event.origin)) { return; } - if (event.data?.type === MSG_CONNECTION_STRING && event.data?.connectionString) { - connectWithConnectionString(event.data.connectionString); + if (event.data?.type === MSG_CONNECTION_STRING) { + const connStr: string = event.data.connectionString; + if (parseConnectionString(connStr)) { + connectWithConnectionString(connStr); + } } }; @@ -207,4 +211,9 @@ const App: React.FunctionComponent = () => { ); }; -render(, document.getElementById("App")); +export { App }; + +const appElement = document.getElementById("App"); +if (appElement) { + render(, appElement); +} diff --git a/src/Utils/EndpointUtils.ts b/src/Utils/EndpointUtils.ts index ffad9c1a2..9f716fcb2 100644 --- a/src/Utils/EndpointUtils.ts +++ b/src/Utils/EndpointUtils.ts @@ -85,7 +85,7 @@ export const allowedArcadiaEndpoints: ReadonlyArray = ["https://workspac export const allowedHostedExplorerEndpoints: ReadonlyArray = [ "https://cosmos.azure.com", - "https://localhost:12900", + ...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []), ]; export const allowedMsalRedirectEndpoints: ReadonlyArray = ["https://dataexplorer-preview.azurewebsites.net/"];