Validate endpoints from feature flags (#1196)

Validate endpoints from feature flags
This commit is contained in:
Armando Trejo Oliver 2022-01-24 13:06:43 -08:00 committed by GitHub
parent de5df90f75
commit f5da8bb276
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 178 additions and 59 deletions

6
.vscode/launch.json vendored
View File

@ -12,7 +12,8 @@
"--inspect-brk", "--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js", "${workspaceRoot}/node_modules/jest/bin/jest.js",
"--runInBand", "--runInBand",
"--coverage", "false" "--coverage",
"false"
], ],
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
@ -26,7 +27,8 @@
"--inspect-brk", "--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js", "${workspaceRoot}/node_modules/jest/bin/jest.js",
"${fileBasenameNoExtension}", "${fileBasenameNoExtension}",
"--coverage", "false", "--coverage",
"false",
// "--watch", // "--watch",
// // --no-cache only used to make --watch work. Otherwise jest ignores the breakpoints. // // --no-cache only used to make --watch work. Otherwise jest ignores the breakpoints.
// // https://github.com/facebook/jest/issues/6683 // // https://github.com/facebook/jest/issues/6683

View File

@ -236,13 +236,12 @@ describe("MongoProxyClient", () => {
}); });
it("returns a production endpoint", () => { it("returns a production endpoint", () => {
const endpoint = getEndpoint(); const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer"); expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
}); });
it("returns a development endpoint", () => { it("returns a development endpoint", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" }); const endpoint = getEndpoint("https://localhost:1234");
const endpoint = getEndpoint();
expect(endpoint).toEqual("https://localhost:1234/api/mongo/explorer"); expect(endpoint).toEqual("https://localhost:1234/api/mongo/explorer");
}); });
@ -250,7 +249,7 @@ describe("MongoProxyClient", () => {
updateUserContext({ updateUserContext({
authType: AuthType.EncryptedToken, authType: AuthType.EncryptedToken,
}); });
const endpoint = getEndpoint(); const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer"); expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
}); });
}); });

View File

@ -1,5 +1,6 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos"; import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import queryString from "querystring"; import queryString from "querystring";
import { allowedMongoProxyEndpoints, validateEndpoint } from "Utils/EndpointValidation";
import { AuthType } from "../AuthType"; import { AuthType } from "../AuthType";
import { configContext } from "../ConfigContext"; import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
@ -336,14 +337,17 @@ export function createMongoCollectionWithProxy(
} }
export function getFeatureEndpointOrDefault(feature: string): string { export function getFeatureEndpointOrDefault(feature: string): string {
return hasFlag(userContext.features.mongoProxyAPIs, feature) const endpoint =
? getEndpoint(userContext.features.mongoProxyEndpoint) hasFlag(userContext.features.mongoProxyAPIs, feature) &&
: getEndpoint(); validateEndpoint(userContext.features.mongoProxyEndpoint, allowedMongoProxyEndpoints)
? userContext.features.mongoProxyEndpoint
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
return getEndpoint(endpoint);
} }
export function getEndpoint(customEndpoint?: string): string { export function getEndpoint(endpoint: string): string {
let url = customEndpoint ? customEndpoint : configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT; let url = endpoint + "/api/mongo/explorer";
url += "/api/mongo/explorer";
if (userContext.authType === AuthType.EncryptedToken) { if (userContext.authType === AuthType.EncryptedToken) {
url = url.replace("api/mongo", "api/guest/mongo"); url = url.replace("api/mongo", "api/guest/mongo");

View File

@ -1,4 +1,16 @@
import { JunoEndpoints } from "Common/Constants"; import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedArmEndpoints,
allowedBackendEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMongoBackendEndpoints,
allowedMsalRedirectEndpoints,
validateEndpoint,
} from "Utils/EndpointValidation";
export enum Platform { export enum Platform {
Portal = "Portal", Portal = "Portal",
@ -8,7 +20,7 @@ export enum Platform {
export interface ConfigContext { export interface ConfigContext {
platform: Platform; platform: Platform;
allowedParentFrameOrigins: string[]; allowedParentFrameOrigins: ReadonlyArray<string>;
gitSha?: string; gitSha?: string;
proxyPath?: string; proxyPath?: string;
AAD_ENDPOINT: string; AAD_ENDPOINT: string;
@ -30,7 +42,6 @@ export interface ConfigContext {
isTerminalEnabled: boolean; isTerminalEnabled: boolean;
hostedExplorerURL: string; hostedExplorerURL: string;
armAPIVersion?: string; armAPIVersion?: string;
allowedJunoOrigins: string[];
msalRedirectURI?: string; msalRedirectURI?: string;
} }
@ -44,8 +55,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`, `^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`, `^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
`^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$`, `^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$`,
], ], // Webpack injects this at build time
// Webpack injects this at build time
gitSha: process.env.GIT_SHA, gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/", hostedExplorerURL: "https://cosmos.azure.com/",
AAD_ENDPOINT: "https://login.microsoftonline.com/", AAD_ENDPOINT: "https://login.microsoftonline.com/",
@ -61,14 +71,6 @@ let configContext: Readonly<ConfigContext> = {
JUNO_ENDPOINT: "https://tools.cosmos.azure.com", JUNO_ENDPOINT: "https://tools.cosmos.azure.com",
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com", BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
isTerminalEnabled: false, isTerminalEnabled: false,
allowedJunoOrigins: [
JunoEndpoints.Test,
JunoEndpoints.Test2,
JunoEndpoints.Test3,
JunoEndpoints.Prod,
JunoEndpoints.Stage,
"https://localhost",
],
}; };
export function resetConfigContext(): void { export function resetConfigContext(): void {
@ -79,6 +81,50 @@ export function resetConfigContext(): void {
} }
export function updateConfigContext(newContext: Partial<ConfigContext>): void { export function updateConfigContext(newContext: Partial<ConfigContext>): void {
if (!newContext) {
return;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, allowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
}
if (!validateEndpoint(newContext.AAD_ENDPOINT, allowedAadEndpoints)) {
delete newContext.AAD_ENDPOINT;
}
if (!validateEndpoint(newContext.EMULATOR_ENDPOINT, allowedEmulatorEndpoints)) {
delete newContext.EMULATOR_ENDPOINT;
}
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, allowedGraphEndpoints)) {
delete newContext.GRAPH_ENDPOINT;
}
if (!validateEndpoint(newContext.ARCADIA_ENDPOINT, allowedArcadiaEndpoints)) {
delete newContext.ARCADIA_ENDPOINT;
}
if (!validateEndpoint(newContext.BACKEND_ENDPOINT, allowedBackendEndpoints)) {
delete newContext.BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_BACKEND_ENDPOINT, allowedMongoBackendEndpoints)) {
delete newContext.MONGO_BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.JUNO_ENDPOINT, allowedJunoOrigins)) {
delete newContext.JUNO_ENDPOINT;
}
if (!validateEndpoint(newContext.hostedExplorerURL, allowedHostedExplorerEndpoints)) {
delete newContext.hostedExplorerURL;
}
if (!validateEndpoint(newContext.msalRedirectURI, allowedMsalRedirectEndpoints)) {
delete newContext.msalRedirectURI;
}
Object.assign(configContext, newContext); Object.assign(configContext, newContext);
} }
@ -102,18 +148,8 @@ export async function initializeConfiguration(): Promise<ConfigContext> {
}); });
if (response.status === 200) { if (response.status === 200) {
try { try {
const { allowedParentFrameOrigins, allowedJunoOrigins, ...externalConfig } = await response.json(); const { ...externalConfig } = await response.json();
Object.assign(configContext, externalConfig); updateConfigContext(externalConfig);
if (allowedParentFrameOrigins && allowedParentFrameOrigins.length > 0) {
updateConfigContext({
allowedParentFrameOrigins: [...configContext.allowedParentFrameOrigins, ...allowedParentFrameOrigins],
});
}
if (allowedJunoOrigins && allowedJunoOrigins.length > 0) {
updateConfigContext({
allowedJunoOrigins: [...configContext.allowedJunoOrigins, ...allowedJunoOrigins],
});
}
} catch (error) { } catch (error) {
console.error("Unable to parse json in config file"); console.error("Unable to parse json in config file");
console.error(error); console.error(error);

View File

@ -1,8 +1,10 @@
import { Link } from "@fluentui/react/lib/Link"; import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { IGalleryItem } from "Juno/JunoClient";
import * as ko from "knockout"; import * as ko from "knockout";
import React from "react"; import React from "react";
import _ from "underscore"; import _ from "underscore";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointValidation";
import shallow from "zustand/shallow"; import shallow from "zustand/shallow";
import { AuthType } from "../AuthType"; import { AuthType } from "../AuthType";
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
@ -24,7 +26,6 @@ import * as ViewModels from "../Contracts/ViewModels";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { useSidePanel } from "../hooks/useSidePanel"; import { useSidePanel } from "../hooks/useSidePanel";
import { useTabs } from "../hooks/useTabs"; import { useTabs } from "../hooks/useTabs";
import { IGalleryItem } from "../Juno/JunoClient";
import { PhoenixClient } from "../Phoenix/PhoenixClient"; import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings"; import * as ExplorerSettings from "../Shared/ExplorerSettings";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
@ -50,7 +51,7 @@ import * as FileSystemUtil from "./Notebook/FileSystemUtil";
import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; import { SnapshotRequest } from "./Notebook/NotebookComponent/types";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import type NotebookManager from "./Notebook/NotebookManager"; import type NotebookManager from "./Notebook/NotebookManager";
import type { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookPaneContent } from "./Notebook/NotebookManager";
import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookUtil } from "./Notebook/NotebookUtil";
import { useNotebook } from "./Notebook/useNotebook"; import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
@ -178,7 +179,11 @@ export default class Explorer {
this.resourceTree = new ResourceTreeAdapter(this); this.resourceTree = new ResourceTreeAdapter(this);
// Override notebook server parameters from URL parameters // Override notebook server parameters from URL parameters
if (userContext.features.notebookServerUrl && userContext.features.notebookServerToken) { if (
userContext.features.notebookServerUrl &&
validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) &&
userContext.features.notebookServerToken
) {
useNotebook.getState().setNotebookServerInfo({ useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl, notebookServerEndpoint: userContext.features.notebookServerUrl,
authToken: userContext.features.notebookServerToken, authToken: userContext.features.notebookServerToken,
@ -190,19 +195,6 @@ export default class Explorer {
useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath); useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath);
} }
if (userContext.features.livyEndpoint) {
useNotebook.getState().setSparkClusterConnectionInfo({
userName: undefined,
password: undefined,
endpoints: [
{
endpoint: userContext.features.livyEndpoint,
kind: DataModels.SparkClusterEndpointKind.Livy,
},
],
});
}
this.refreshExplorer(); this.refreshExplorer();
} }
@ -422,7 +414,10 @@ export default class Explorer {
connectionStatus.status = ConnectionStatusType.Connected; connectionStatus.status = ConnectionStatusType.Connected;
useNotebook.getState().setConnectionInfo(connectionStatus); useNotebook.getState().setConnectionInfo(connectionStatus);
useNotebook.getState().setNotebookServerInfo({ useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.data.notebookServerUrl, notebookServerEndpoint:
(validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) &&
userContext.features.notebookServerUrl) ||
connectionInfo.data.notebookServerUrl,
authToken: userContext.features.notebookServerToken || connectionInfo.data.notebookAuthToken, authToken: userContext.features.notebookServerToken || connectionInfo.data.notebookAuthToken,
forwardingId: connectionInfo.data.forwardingId, forwardingId: connectionInfo.data.forwardingId,
}); });

View File

@ -1,4 +1,5 @@
import ko from "knockout"; import ko from "knockout";
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation";
import { GetGithubClientId } from "Utils/GitHubUtils"; import { GetGithubClientId } from "Utils/GitHubUtils";
import { HttpHeaders, HttpStatusCodes } from "../Common/Constants"; import { HttpHeaders, HttpStatusCodes } from "../Common/Constants";
import { configContext } from "../ConfigContext"; import { configContext } from "../ConfigContext";
@ -484,7 +485,7 @@ export class JunoClient {
// public for tests // public for tests
public static getJunoEndpoint(): string { public static getJunoEndpoint(): string {
const junoEndpoint = userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT; const junoEndpoint = userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT;
if (configContext.allowedJunoOrigins.indexOf(new URL(junoEndpoint).origin) === -1) { if (!validateEndpoint(junoEndpoint, allowedJunoOrigins)) {
const error = `${junoEndpoint} not allowed as juno endpoint`; const error = `${junoEndpoint} not allowed as juno endpoint`;
console.error(error); console.error(error);
throw new Error(error); throw new Error(error);

View File

@ -1,5 +1,6 @@
import promiseRetry, { AbortError } from "p-retry"; import promiseRetry, { AbortError } from "p-retry";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation";
import { import {
Areas, Areas,
ConnectionStatusType, ConnectionStatusType,
@ -154,7 +155,7 @@ export class PhoenixClient {
public static getPhoenixEndpoint(): string { public static getPhoenixEndpoint(): string {
const phoenixEndpoint = const phoenixEndpoint =
userContext.features.phoenixEndpoint ?? userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT; userContext.features.phoenixEndpoint ?? userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT;
if (configContext.allowedJunoOrigins.indexOf(new URL(phoenixEndpoint).origin) === -1) { if (!validateEndpoint(phoenixEndpoint, allowedJunoOrigins)) {
const error = `${phoenixEndpoint} not allowed as juno endpoint`; const error = `${phoenixEndpoint} not allowed as juno endpoint`;
console.error(error); console.error(error);
throw new Error(error); throw new Error(error);

View File

@ -23,7 +23,6 @@ export type Features = {
readonly hostedDataExplorer: boolean; readonly hostedDataExplorer: boolean;
readonly junoEndpoint?: string; readonly junoEndpoint?: string;
readonly phoenixEndpoint?: string; readonly phoenixEndpoint?: string;
readonly livyEndpoint?: string;
readonly notebookBasePath?: string; readonly notebookBasePath?: string;
readonly notebookServerToken?: string; readonly notebookServerToken?: string;
readonly notebookServerUrl?: string; readonly notebookServerUrl?: string;
@ -72,7 +71,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
mongoProxyAPIs: get("mongoproxyapis"), mongoProxyAPIs: get("mongoproxyapis"),
junoEndpoint: get("junoendpoint"), junoEndpoint: get("junoendpoint"),
phoenixEndpoint: get("phoenixendpoint"), phoenixEndpoint: get("phoenixendpoint"),
livyEndpoint: get("livyendpoint"),
notebookBasePath: get("notebookbasepath"), notebookBasePath: get("notebookbasepath"),
notebookServerToken: get("notebookservertoken"), notebookServerToken: get("notebookservertoken"),
notebookServerUrl: get("notebookserverurl"), notebookServerUrl: get("notebookserverurl"),

View File

@ -0,0 +1,83 @@
import { JunoEndpoints } from "Common/Constants";
import * as Logger from "../Common/Logger";
export function validateEndpoint(
endpointToValidate: string | undefined,
allowedEndpoints: ReadonlyArray<string>
): boolean {
try {
return validateEndpointInternal(
endpointToValidate,
allowedEndpoints.map((e) => e)
);
} catch (reason) {
Logger.logError(`${endpointToValidate} not allowed`, "validateEndpoint");
Logger.logError(`${JSON.stringify(reason)}`, "validateEndpoint");
return false;
}
}
function validateEndpointInternal(
endpointToValidate: string | undefined,
allowedEndpoints: ReadonlyArray<string>
): boolean {
if (endpointToValidate === undefined) {
return false;
}
const originToValidate: string = new URL(endpointToValidate).origin;
const allowedOrigins: string[] = allowedEndpoints.map((allowedEndpoint) => new URL(allowedEndpoint).origin) || [];
const valid = allowedOrigins.indexOf(originToValidate) >= 0;
if (!valid) {
throw new Error(
`${endpointToValidate} is not an allowed endpoint. Allowed endpoints are ${allowedArmEndpoints.toString()}`
);
}
return valid;
}
export const allowedArmEndpoints: ReadonlyArray<string> = [
"https://management.azure.com",
"https://management.usgovcloudapi.net",
"https://management.chinacloudapi.cn",
];
export const allowedAadEndpoints: ReadonlyArray<string> = ["https://login.microsoftonline.com/"];
export const allowedBackendEndpoints: ReadonlyArray<string> = [
"https://main.documentdb.ext.azure.com",
"https://localhost:12901",
"https://localhost:1234",
];
export const allowedMongoProxyEndpoints: ReadonlyArray<string> = [
"https://main.documentdb.ext.azure.com",
"https://localhost:12901",
];
export const allowedEmulatorEndpoints: ReadonlyArray<string> = ["https://localhost:8081"];
export const allowedMongoBackendEndpoints: ReadonlyArray<string> = ["https://localhost:1234"];
export const allowedGraphEndpoints: ReadonlyArray<string> = ["https://graph.windows.net"];
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = ["https://cosmos.azure.com/"];
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = [
"https://cosmos-explorer-preview.azurewebsites.net/",
];
export const allowedJunoOrigins: ReadonlyArray<string> = [
JunoEndpoints.Test,
JunoEndpoints.Test2,
JunoEndpoints.Test3,
JunoEndpoints.Prod,
JunoEndpoints.Stage,
"https://localhost",
];
export const allowedNotebookServerUrls: ReadonlyArray<string> = [];

View File

@ -4,7 +4,7 @@ export function isInvalidParentFrameOrigin(event: MessageEvent): boolean {
return !isValidOrigin(configContext.allowedParentFrameOrigins, event); return !isValidOrigin(configContext.allowedParentFrameOrigins, event);
} }
function isValidOrigin(allowedOrigins: string[], event: MessageEvent): boolean { function isValidOrigin(allowedOrigins: ReadonlyArray<string>, event: MessageEvent): boolean {
const eventOrigin = (event && event.origin) || ""; const eventOrigin = (event && event.origin) || "";
const windowOrigin = (window && window.origin) || ""; const windowOrigin = (window && window.origin) || "";
if (eventOrigin === windowOrigin) { if (eventOrigin === windowOrigin) {