Support Sovereign/PPE endpoint zones for connection string login + E2E test for SQL account with public network access disabled (#2568)

* Support sovereign/PPE endpoint zones for connection string login

Move the hardcoded account endpoint suffixes out of Constants and into ConfigContext so connection string login works in sovereign clouds and PPE, and widen ConnectionStringParser to accept every configured zone.

Surface Portal Backend rejections in the hosted connect form: read the body off the Response that fetchEncryptedToken throws, and offer a firewall help link on a 403.

Add an E2E test covering SQL connection string login against an account with public network access disabled.

* Harden connection string DNS zone matching

Detect PPE accounts from Mongo and Cassandra connection strings, build their document endpoint from the matched zone, match the PPE suffix on a label boundary, and escape every regex metacharacter in config-supplied zones. Drop the sqlx.cosmosdb.azure.com zone, which is not a real SQL zone.

* Mock web-vitals globally in test setup

ScenarioMonitor subscribes to web-vitals when it is imported, so the onTTFB timer can throw during any suite that outlives it. Mocking it in setupTests.ts keeps that out of individual test files.

* Improve connection string login feedback

* Preserve connection restriction check order

* Flatten connection login error handling

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
This commit is contained in:
asier-isayas
2026-08-31 10:35:54 -04:00
committed by GitHub
parent 49a2ad491a
commit a825a7ddc5
11 changed files with 671 additions and 143 deletions
@@ -1,11 +1,27 @@
jest.mock("../../../hooks/useDirectories");
jest.mock("../../../Common/PortalBackendClient");
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { updateUserContext, userContext } from "UserContext";
import React from "react";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { ConnectExplorer } from "./ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction<
typeof isAccountRestrictedForConnectionStringLogin
>;
// fetchEncryptedToken rejects with the raw Response.
const rejectWithResponse = (status: number, body: string) =>
mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response);
beforeEach(() => {
jest.resetAllMocks();
mockIsAccountRestricted.mockResolvedValue(false);
});
it("shows the connect form", () => {
const connectionString = "fakeConnectionString";
const login = jest.fn();
@@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures });
});
it("rejects an unrecognized connection string before token exchange", async () => {
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "not-a-valid-connection-string",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText(
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
),
).toBeInTheDocument();
expect(mockIsAccountRestricted).toHaveBeenCalledWith("not-a-valid-connection-string");
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("shows that a connection is in progress", async () => {
let finishRestrictionCheck: (restricted: boolean) => void = () => undefined;
mockIsAccountRestricted.mockImplementation(
() =>
new Promise((resolve) => {
finishRestrictionCheck = resolve;
}),
);
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=some-key;",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
const connectButton = screen.getByDisplayValue("Connecting...");
expect(connectButton).toBeDisabled();
expect(connectButton.closest("form")).toHaveAttribute("aria-busy", "true");
finishRestrictionCheck(false);
expect(await screen.findByDisplayValue("Connect")).toBeEnabled();
});
it("shows the error when the Portal Backend rejects the connection string", async () => {
// Mongo and Cassandra are the APIs that still exchange the connection string for an encrypted token.
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText(
"Couldn't authenticate with Cosmos DB: Request originated from IP 1.2.3.4 through public internet.",
),
).toBeInTheDocument();
});
it("shows a generic error when the Portal Backend fails without a message", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
// A failure with an empty body leaves nothing worth rendering.
rejectWithResponse(500, "");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText("Failed to connect to the account. Please check the connection string and try again."),
).toBeInTheDocument();
});
it("offers the firewall help link when the Portal Backend is blocked by the account firewall", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(await screen.findByText("Allow access from Azure Portal")).toBeInTheDocument();
});
it("does not offer the firewall help link for a failure the firewall did not cause", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(401, "The connection string is invalid.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText("Couldn't authenticate with Cosmos DB: The connection string is invalid."),
).toBeInTheDocument();
expect(screen.queryByText("Allow access from Azure Portal")).toBeNull();
});
@@ -1,10 +1,11 @@
import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components";
import { useBoolean } from "@fluentui/react-hooks";
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { userContext } from "UserContext";
import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType";
import { HttpStatusCodes } from "../../../Common/Constants";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
@@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
return (
@@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
{isFormVisible && enableConnectionStringLogin ? (
<form
id="connectWithConnectionString"
aria-busy={isConnecting}
onSubmit={async (event) => {
event.preventDefault();
if (isConnecting) {
return;
}
setErrorMessage("");
setIsBlockedByFirewall(false);
setIsConnecting(true);
try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
);
setIsConnecting(false);
return;
}
} catch (error) {
setErrorMessage(getErrorMessage(error));
setErrorMessage(getErrorMessage(error as Error));
setIsConnecting(false);
return;
}
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
return;
}
try {
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
return;
}
const metadata = parseConnectionString(connectionString);
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
// SQL, Table, 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.
setAccountMetadata(metadata);
const metadata = parseConnectionString(connectionString);
if (!metadata) {
setErrorMessage(
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
);
return;
}
if (isDirectConnectionStringLoginApi(metadata.apiKind)) {
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
// Mongo and Cassandra go through the Portal Backend
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
return;
}
} catch (error) {
const errorDetails = await (error as Response).text();
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
setErrorMessage(
errorDetails
? `Couldn't authenticate with Cosmos DB: ${errorDetails}`
: "Failed to connect to the account. Please check the connection string and try again.",
);
// A Forbidden usually means the account firewall dropped the request. The connection
// string is exchanged by the Portal Backend rather than the browser, so the account has
// to allowlist those services.
setIsBlockedByFirewall((error as Response).status === HttpStatusCodes.Forbidden);
} finally {
setIsConnecting(false);
}
}}
>
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setConnectionString(event.target.value);
}}
/>
{errorMessage.length > 0 && (
<span className="errorDetailsInfoTooltip">
<img className="errorImg" src={ErrorImage} alt="Error notification" />
<span className="errorDetails">{errorMessage}</span>
</span>
)}
</p>
{errorMessage.length > 0 && (
<FluentProvider theme={webLightTheme} className="connectErrorMessageBar">
<MessageBar intent="error" layout="multiline">
<MessageBarBody>
<span className="errorDetails">{errorMessage}</span>
{isBlockedByFirewall && (
<Link
className="errorHelpLink"
href="https://learn.microsoft.com/azure/cosmos-db/how-to-configure-firewall#allow-requests-from-the-azure-portal"
target="_blank"
rel="noreferrer"
>
Allow access from Azure Portal
</Link>
)}
</MessageBarBody>
</MessageBar>
</FluentProvider>
)}
<p className="connectExplorerContent">
<input className="filterbtnstyle" type="submit" value="Connect" />
<input
className="filterbtnstyle"
type="submit"
value={isConnecting ? "Connecting..." : "Connect"}
disabled={isConnecting}
/>
</p>
<p className="switchConnectTypeText" onClick={login}>
Sign In with Azure Account
+11 -33
View File
@@ -55,41 +55,19 @@
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
font-style: italic;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip {
position: relative;
display: inline-block;
padding-left: 4px;
vertical-align: top;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar {
/* Matches the width of the connection string input so the form stays balanced. */
width: 308px;
margin: 0px auto 8px auto;
text-align: left;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails {
visibility: visible;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails {
/* Service messages are diagnostics containing unbroken URIs and ids. */
overflow-wrap: anywhere;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
bottom: 24px;
width: 165px;
visibility: hidden;
background-color: #393939;
color: #ffffff;
position: absolute;
z-index: 1;
left: -10px;
padding: 6px;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
border-width: 10px 10px 0px 10px;
bottom: -8px;
content: "";
position: absolute;
right: 100%;
border-style: solid;
left: 12px;
width: 0;
height: 0;
border-color: #3b3b3b transparent;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
height: 14px;
width: 14px;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink {
display: block;
margin-top: 8px;
}
.filterbtnstyle {
@@ -1,19 +1,75 @@
import { configContext, updateConfigContext } from "../../../ConfigContext";
import * as DataModels from "../../../Contracts/DataModels";
import { parseConnectionString } from "./ConnectionStringParser";
import {
buildEndpointsRegex,
dnsZoneAlternation,
parseConnectionString,
selectEndpointZone,
} from "./ConnectionStringParser";
describe("ConnectionStringParser", () => {
const mockAccountName = "Test";
const mockMasterKey = "some-key";
it("should parse a valid sql account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
);
// The shape of each api's connection string, parameterized by the dns zone the account sits in. What
// these tests are about is the zones, so keeping the shapes here stops them being restated once per zone.
const buildConnectionString = {
sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`,
mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`,
// A cassandra connection string can name the account host under either AccountEndpoint or HostName.
cassandra: (zone: string, hostKey = "AccountEndpoint") =>
`${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`,
table: (zone: string) =>
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`,
graph: (zone: string) =>
`AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
};
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
const connectionStringsByApiKind: Record<DataModels.ApiKind, string> = {
[DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"),
[DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"),
[DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"),
[DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"),
[DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"),
[DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"),
};
it("should parse a connection string for every api kind", () => {
Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => {
const metadata = parseConnectionString(connectionString);
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(Number(apiKind));
});
});
// The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the
// suite rather than fail it. Pin the expected contents so that stays visible in review.
it("should support the expected dns zones", () => {
expect(configContext.SQL_DNS_ZONES).toEqual([
"documents.azure.com",
"sql.cosmosdb.azure.com",
"sql.cosmos.azure.com",
"sqlx.cosmos.azure.com",
"documents-staging.windows-ppe.net",
"sql.cosmosdb.windows-ppe.net",
"sql.cosmos.windows-ppe.net",
"sqlx.cosmos.windows-ppe.net",
]);
expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]);
expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]);
expect(configContext.CASSANDRA_DNS_ZONES).toEqual([
"cassandra.cosmosdb.azure.com",
"cassandra.cosmos.azure.com",
"cassandra.cosmosdb.windows-ppe.net",
"cassandra.cosmos.windows-ppe.net",
]);
expect(configContext.TABLE_DNS_ZONES).toEqual([
"table.cosmosdb.azure.com",
"table.cosmos.azure.com",
"table.cosmosdb.windows-ppe.net",
"table.cosmos.windows-ppe.net",
]);
});
it("should keep the document endpoint given by the connection string", () => {
@@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => {
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`,
);
it.each(configContext.SQL_DNS_ZONES)(
"should parse a sql account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
});
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
},
);
it("should parse a valid compute mongo account connection string", () => {
const metadata = parseConnectionString(
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
);
it.each(configContext.MONGO_DNS_ZONES)(
"should parse a mongo account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
});
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
},
);
it.each(configContext.MONGO_COMPUTE_DNS_ZONES)(
"should parse a compute mongo account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
},
);
it.each(
["AccountEndpoint", "HostName"].flatMap((hostKey) =>
configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]),
),
)(
"should parse a cassandra account connection string using %s and the %s zone",
(hostKey: string, dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
},
);
it.each(configContext.TABLE_DNS_ZONES)(
"should parse a table account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.table(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
expect(metadata.apiEndpoint).toBeUndefined();
},
);
// A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than
// giving the document endpoint, so the document endpoint that data plane operations go through is built
// from the account name, under a zone of the same kind as the one that matched.
const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`;
const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`;
const expectedDocumentEndpointByZone: Record<string, string> = {
"documents.azure.com": publicDocumentEndpoint,
"documents-staging.windows-ppe.net": ppeDocumentEndpoint,
"mongo.cosmos.azure.com": publicDocumentEndpoint,
"mongo.cosmos.windows-ppe.net": ppeDocumentEndpoint,
"cassandra.cosmosdb.azure.com": publicDocumentEndpoint,
"cassandra.cosmos.azure.com": publicDocumentEndpoint,
"cassandra.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
"cassandra.cosmos.windows-ppe.net": ppeDocumentEndpoint,
"table.cosmosdb.azure.com": publicDocumentEndpoint,
"table.cosmos.azure.com": publicDocumentEndpoint,
"table.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
"table.cosmos.windows-ppe.net": ppeDocumentEndpoint,
};
it.each([
...configContext.MONGO_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
...configContext.MONGO_COMPUTE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
...configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.cassandra(dnsZone)]),
...configContext.TABLE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.table(dnsZone)]),
])(
"should construct the document endpoint for an account in the %s zone",
(dnsZone: string, connectionString: string) => {
const metadata = parseConnectionString(connectionString);
expect(metadata.documentEndpoint).toBe(expectedDocumentEndpointByZone[dnsZone]);
},
);
it("should parse a valid graph account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
);
const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com"));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
@@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
});
it("should parse a valid table account connection string", () => {
const metadata = parseConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
);
it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
// Table data operations go through the document endpoint, which is constructed from the account name.
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
// The constructed endpoint has to match the kind of zone the document endpoint we matched came from.
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`);
});
it("should parse a valid table account connection string using the cosmos.azure.com zone", () => {
const metadata = parseConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`,
);
it("should reject a connection string when no DNS zone matches the account", () => {
const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES;
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
try {
const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net"));
// The account key travels to the constructed document endpoint, so a config carrying no PPE zone
// has to fail on a PPE account rather than fall back to a zone the account does not own.
expect(metadata).toBe(undefined);
} finally {
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones });
}
});
it("should parse a valid cassandra account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
);
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
it.each([
buildConnectionString.sql("documents.azure.com.attacker.example"),
buildConnectionString.mongo("documents.azure.com.attacker.example"),
buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"),
buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"),
buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"),
])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => {
// The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that
// appends to an allowed zone must not pass as that zone.
expect(parseConnectionString(connectionString)).toBe(undefined);
});
it("should fail to parse an invalid connection string", () => {
@@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => {
expect(metadata).toBe(undefined);
});
describe("dnsZoneAlternation", () => {
it("should escape the dots in a zone", () => {
expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)");
});
it("should join multiple zones into a single alternation", () => {
expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)");
});
it("should not let the dots match arbitrary characters", () => {
// An unescaped dot would make the zone list match hosts that only resemble a real zone.
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
expect(regex.test("documents.azure.com")).toBe(true);
expect(regex.test("documentsXazure.com")).toBe(false);
});
it("should capture the zone that matched", () => {
const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"]));
expect("account.b.test".match(regex)[1]).toBe("b.test");
});
it("should require the zone to run to the end of the host", () => {
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
expect(regex.test("account.documents.azure.com")).toBe(true);
expect(regex.test("account.documents.azure.com:443/")).toBe(true);
expect(regex.test("account.documents.azure.com/")).toBe(true);
// Without this the zone list stops being an allowlist, since anything can be appended to a zone.
expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false);
});
});
describe("buildEndpointsRegex", () => {
it("should build a pattern for every api matched by dns zone", () => {
expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]);
});
it("should build a cassandra pattern for each supported key", () => {
const { cassandra } = buildEndpointsRegex();
expect(cassandra).toHaveLength(2);
expect(cassandra[0]).toContain("AccountEndpoint=");
expect(cassandra[1]).toContain("HostName=");
});
it("should build each pattern from its own zone list", () => {
// The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in
// review and would let an account of one api be parsed as another.
const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex();
expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES));
expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES));
expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES));
cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)));
expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES));
});
});
describe("selectEndpointZone", () => {
const nonPpeZone = "documents.azure.com";
const ppeZone = "documents-staging.windows-ppe.net";
it("should pick the ppe zone for a ppe account", () => {
expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone);
});
it("should pick the non ppe zone for a non ppe account", () => {
expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone);
});
it("should not depend on the order of the zones", () => {
expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone);
expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone);
});
it("should return undefined when no zone matches the kind of account", () => {
// Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone.
expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined();
expect(selectEndpointZone([ppeZone], false)).toBeUndefined();
});
it("should return undefined for an empty zone list", () => {
expect(selectEndpointZone([], false)).toBeUndefined();
});
});
});
@@ -1,44 +1,80 @@
import * as Constants from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
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 when the connection string does not already
// contain the endpoint.
const DocumentEndpointZone = "documents.azure.com";
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
const PpeDnsSuffix = "windows-ppe.net";
const DnsPort = "443";
// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone.
const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`);
// Picks the DNS zone matching the kind of account the connection string came from, since a PPE
// account's endpoints sit under PPE zones and every other account's do not.
export const selectEndpointZone = (zones: ReadonlyArray<string>, isPpeAccount: boolean): string | undefined =>
zones.find((zone) => isPpeZone(zone) === isPpeAccount);
// Builds an alternation matching any of the given DNS zones, e.g. "(documents\.azure\.com|sql\.cosmos\.azure\.com)".
// The group captures so callers can tell which zone matched, and with it whether the account is a PPE account.
// The zone has to run to the end of the host, otherwise a host that merely starts with an allowed zone
// would pass as that zone and the account key would travel to whatever was appended to it. Zones come
// from config, so every regex metacharacter is escaped rather than just the dots.
export const dnsZoneAlternation = (zones: ReadonlyArray<string>): string =>
`(${zones.map((zone) => zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?=[:/\\s]|$)`;
// The zone lists live in ConfigContext, which is populated asynchronously by initializeConfiguration,
// so these are built per call rather than once at module load.
export const buildEndpointsRegex = () => ({
sql: `AccountEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.SQL_DNS_ZONES)}`,
mongo: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_DNS_ZONES)}`,
mongoCompute: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)}`,
cassandra: ["AccountEndpoint", "HostName"].map(
(key) => `${key}=([^.]+)\\.${dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)}`,
),
table: `TableEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.TABLE_DNS_ZONES)}`,
});
export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) {
try {
const accessInput = {} as AccessInputMetadata;
const connectionStringParts = connectionString.split(";");
const endpointsRegex = buildEndpointsRegex();
// Endpoints we build from the account name have to match the kind of zone the connection string
// actually matched, since PPE accounts and other accounts do not share zones.
let isPpeAccount = false;
connectionStringParts.forEach((connectionStringPart: string) => {
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
if (RegExp(endpointsRegex.sql).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.sql);
accessInput.accountName = matches[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);
isPpeAccount = isPpeZone(matches[2]);
} else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDB;
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
isPpeAccount = isPpeZone(matches[3]);
} else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute);
accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDBCompute;
} else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
Constants.EndpointsRegex.cassandra.forEach((regex) => {
if (RegExp(regex).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(regex)[1];
isPpeAccount = isPpeZone(matches[3]);
} else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
endpointsRegex.cassandra.forEach((regex) => {
const matches: string[] = connectionStringPart.match(regex);
if (matches) {
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Cassandra;
isPpeAccount = isPpeZone(matches[2]);
}
});
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
} else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.table);
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Table;
isPpeAccount = isPpeZone(matches[2]);
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
accessInput.apiKind = ApiKind.Graph;
}
@@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined;
}
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
// 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.
// A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather
// than giving the document endpoint, so the document endpoint that data plane operations go through
// has to be built from the account name. SQL and Gremlin strings carry it and take it as given.
// Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string.
if (accessInput.accountName) {
if (accessInput.apiKind === ApiKind.Table) {
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
if (
accessInput.apiKind === ApiKind.Table ||
accessInput.apiKind === ApiKind.MongoDB ||
accessInput.apiKind === ApiKind.MongoDBCompute ||
accessInput.apiKind === ApiKind.Cassandra
) {
const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount);
if (!documentEndpointZone) {
return undefined;
}
accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`;
} else if (accessInput.apiKind === ApiKind.Graph) {
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount);
if (!gremlinEndpointZone) {
return undefined;
}
accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`;
}
}