mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-19 17:12:47 +01:00
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
This commit is contained in:
@@ -159,7 +159,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not open the Data Explorer when the Cosmos client cannot connect", async () => {
|
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(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`;
|
const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`;
|
||||||
|
|||||||
+4
-28
@@ -23,12 +23,7 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
|
|||||||
import "./Platform/Hosted/ConnectScreen.less";
|
import "./Platform/Hosted/ConnectScreen.less";
|
||||||
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
|
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
|
||||||
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
||||||
import {
|
import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "./Platform/Hosted/HostedUtils";
|
||||||
extractAccountKeyFromConnectionString,
|
|
||||||
extractMasterKeyfromConnectionString,
|
|
||||||
isDirectConnectionStringLoginApi,
|
|
||||||
validateDirectConnectionStringLogin,
|
|
||||||
} from "./Platform/Hosted/HostedUtils";
|
|
||||||
import "./Shared/appInsights";
|
import "./Shared/appInsights";
|
||||||
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
|
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
|
||||||
import { useAADAuth } from "./hooks/useAADAuth";
|
import { useAADAuth } from "./hooks/useAADAuth";
|
||||||
@@ -74,31 +69,14 @@ const App: React.FunctionComponent = () => {
|
|||||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||||
// SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so we skip
|
// 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.
|
// 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)
|
validateDirectConnectionStringConnectivity(connStr, metadata)
|
||||||
.then((connectivityError) => {
|
.then(() => {
|
||||||
if (connectivityError) {
|
|
||||||
logError(
|
|
||||||
`Rejected connection string for direct login: ${connectivityError}`,
|
|
||||||
"HostedExplorer/connectWithConnectionString",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDirectLoginMetadata(metadata);
|
setDirectLoginMetadata(metadata);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logError(
|
logError(
|
||||||
`Failed to validate connection string for direct login: ${error}`,
|
`Failed to connect with connection string: ${error}`,
|
||||||
"HostedExplorer/connectWithConnectionString",
|
"HostedExplorer/connectWithConnectionString",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -167,9 +145,7 @@ const App: React.FunctionComponent = () => {
|
|||||||
authType: AuthType.ConnectionString,
|
authType: AuthType.ConnectionString,
|
||||||
encryptedToken,
|
encryptedToken,
|
||||||
encryptedTokenMetadata: accountMetadata,
|
encryptedTokenMetadata: accountMetadata,
|
||||||
masterKey: directLoginMetadata
|
masterKey: extractMasterKeyfromConnectionString(connectionString),
|
||||||
? extractAccountKeyFromConnectionString(connectionString)
|
|
||||||
: extractMasterKeyfromConnectionString(connectionString),
|
|
||||||
};
|
};
|
||||||
} else if (authType === AuthType.ResourceToken) {
|
} else if (authType === AuthType.ResourceToken) {
|
||||||
frameWindow.hostedConfig = {
|
frameWindow.hostedConfig = {
|
||||||
|
|||||||
@@ -1177,15 +1177,8 @@
|
|||||||
},
|
},
|
||||||
"connectExplorer": {
|
"connectExplorer": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"connectionStringMissing": "Connection string is missing.",
|
"connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again.",
|
||||||
"accountNameMissing": "Account name is missing from the connection string.",
|
"connectivityUnreachable": "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable."
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import { configContext } from "../../../ConfigContext";
|
|||||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||||
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
||||||
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
|
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
|
||||||
import {
|
import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "../HostedUtils";
|
||||||
extractAccountKeyFromConnectionString,
|
|
||||||
isDirectConnectionStringLoginApi,
|
|
||||||
validateDirectConnectionStringLogin,
|
|
||||||
} from "../HostedUtils";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connectionString: string;
|
connectionString: string;
|
||||||
@@ -52,21 +48,17 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
|
|||||||
return (await response.text()).toLowerCase() === "true";
|
return (await response.text()).toLowerCase() === "true";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verifies the account key can actually authenticate against the account by making a lightweight read of
|
// Verifies the connection string can actually authenticate against the account by making a lightweight
|
||||||
// the database account through the Cosmos client. Returns undefined on success, or an error message when
|
// read of the database account through the Cosmos client. Throws on failure.
|
||||||
// the client cannot connect.
|
|
||||||
export const validateDirectConnectionStringConnectivity = async (
|
export const validateDirectConnectionStringConnectivity = async (
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
metadata: AccessInputMetadata,
|
metadata: AccessInputMetadata,
|
||||||
): Promise<string | undefined> => {
|
): Promise<void> => {
|
||||||
const masterKey = extractAccountKeyFromConnectionString(connectionString);
|
const masterKey = extractMasterKeyfromConnectionString(connectionString);
|
||||||
if (!metadata?.documentEndpoint || !masterKey) {
|
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({
|
updateUserContext({
|
||||||
authType: AuthType.ConnectionString,
|
authType: AuthType.ConnectionString,
|
||||||
masterKey,
|
masterKey,
|
||||||
@@ -76,9 +68,8 @@ export const validateDirectConnectionStringConnectivity = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client().getDatabaseAccount();
|
await client().getDatabaseAccount();
|
||||||
return undefined;
|
|
||||||
} catch {
|
} catch {
|
||||||
return t(Keys.connectExplorer.errors.connectivityUnreachable);
|
throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,23 +116,13 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||||
// SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so
|
// 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.
|
// 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).
|
try {
|
||||||
const validationError = validateDirectConnectionStringLogin(connectionString, metadata);
|
await validateDirectConnectionStringConnectivity(connectionString, metadata);
|
||||||
if (validationError) {
|
setDirectLoginMetadata(metadata);
|
||||||
setErrorMessage(validationError);
|
setAuthType(AuthType.ConnectionString);
|
||||||
return;
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +131,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
setEncryptedToken(encryptedToken);
|
setEncryptedToken(encryptedToken);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(t(Keys.connectExplorer.errors.connectFailed));
|
setErrorMessage(t(Keys.connectExplorer.errors.connectivityUnreachable));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
||||||
import {
|
import {
|
||||||
extractAccountKeyFromConnectionString,
|
extractMasterKeyfromConnectionString,
|
||||||
extractEndpointHostFromConnectionString,
|
|
||||||
getDatabaseAccountPropertiesFromMetadata,
|
getDatabaseAccountPropertiesFromMetadata,
|
||||||
isDirectConnectionStringLoginApi,
|
isDirectConnectionStringLoginApi,
|
||||||
validateDirectConnectionStringLogin,
|
|
||||||
} from "./HostedUtils";
|
} from "./HostedUtils";
|
||||||
|
|
||||||
describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
||||||
@@ -37,13 +35,13 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractAccountKeyFromConnectionString", () => {
|
describe("extractMasterKeyfromConnectionString", () => {
|
||||||
const mockAccountName = "Test";
|
const mockAccountName = "Test";
|
||||||
const mockKey = "abc123+/=someKey==";
|
const mockKey = "abc123+/=someKey==";
|
||||||
|
|
||||||
it("extracts the account key from a SQL connection string", () => {
|
it("extracts the account key from a SQL connection string", () => {
|
||||||
expect(
|
expect(
|
||||||
extractAccountKeyFromConnectionString(
|
extractMasterKeyfromConnectionString(
|
||||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
|
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
|
||||||
),
|
),
|
||||||
).toBe(mockKey);
|
).toBe(mockKey);
|
||||||
@@ -51,7 +49,7 @@ describe("extractAccountKeyFromConnectionString", () => {
|
|||||||
|
|
||||||
it("extracts the account key from a Table connection string", () => {
|
it("extracts the account key from a Table connection string", () => {
|
||||||
expect(
|
expect(
|
||||||
extractAccountKeyFromConnectionString(
|
extractMasterKeyfromConnectionString(
|
||||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
|
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
|
||||||
),
|
),
|
||||||
).toBe(mockKey);
|
).toBe(mockKey);
|
||||||
@@ -59,7 +57,7 @@ describe("extractAccountKeyFromConnectionString", () => {
|
|||||||
|
|
||||||
it("extracts the account key from a Gremlin connection string", () => {
|
it("extracts the account key from a Gremlin connection string", () => {
|
||||||
expect(
|
expect(
|
||||||
extractAccountKeyFromConnectionString(
|
extractMasterKeyfromConnectionString(
|
||||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
|
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
|
||||||
),
|
),
|
||||||
).toBe(mockKey);
|
).toBe(mockKey);
|
||||||
@@ -67,12 +65,12 @@ describe("extractAccountKeyFromConnectionString", () => {
|
|||||||
|
|
||||||
it("returns undefined when there is no account key", () => {
|
it("returns undefined when there is no account key", () => {
|
||||||
expect(
|
expect(
|
||||||
extractAccountKeyFromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`),
|
extractMasterKeyfromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined for an empty connection string", () => {
|
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);
|
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.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AccountKind, CapabilityNames } from "../../Common/Constants";
|
import { AccountKind, CapabilityNames } from "../../Common/Constants";
|
||||||
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
||||||
import { Keys, t } from "../../Localization";
|
|
||||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
|
|
||||||
@@ -41,15 +40,9 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC
|
|||||||
return AccountKind.GlobalDocumentDB;
|
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
|
// 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.
|
// 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=([^;]*)/);
|
const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
|
||||||
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
|
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
|
||||||
}
|
}
|
||||||
@@ -59,101 +52,3 @@ export function extractAccountKeyFromConnectionString(connectionString: string):
|
|||||||
export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean {
|
export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean {
|
||||||
return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -478,7 +478,6 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Legacy encrypted-token proxy path (Mongo, Cassandra).
|
// 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({
|
updateUserContext({
|
||||||
authType: AuthType.EncryptedToken,
|
authType: AuthType.EncryptedToken,
|
||||||
accessToken: encodeURIComponent(config.encryptedToken),
|
accessToken: encodeURIComponent(config.encryptedToken),
|
||||||
|
|||||||
Reference in New Issue
Block a user