From f5da8bb276c343c3ebee293ccdfa195268d78bdb Mon Sep 17 00:00:00 2001 From: Armando Trejo Oliver Date: Mon, 24 Jan 2022 13:06:43 -0800 Subject: [PATCH] Validate endpoints from feature flags (#1196) Validate endpoints from feature flags --- .vscode/launch.json | 6 +- src/Common/MongoProxyClient.test.ts | 7 +-- src/Common/MongoProxyClient.ts | 16 +++-- src/ConfigContext.ts | 86 ++++++++++++++++++-------- src/Explorer/Explorer.tsx | 29 ++++----- src/Juno/JunoClient.ts | 3 +- src/Phoenix/PhoenixClient.ts | 3 +- src/Platform/Hosted/extractFeatures.ts | 2 - src/Utils/EndpointValidation.ts | 83 +++++++++++++++++++++++++ src/Utils/MessageValidation.ts | 2 +- 10 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 src/Utils/EndpointValidation.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 46e9150b3..bcb47a377 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,8 @@ "--inspect-brk", "${workspaceRoot}/node_modules/jest/bin/jest.js", "--runInBand", - "--coverage", "false" + "--coverage", + "false" ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", @@ -26,7 +27,8 @@ "--inspect-brk", "${workspaceRoot}/node_modules/jest/bin/jest.js", "${fileBasenameNoExtension}", - "--coverage", "false", + "--coverage", + "false", // "--watch", // // --no-cache only used to make --watch work. Otherwise jest ignores the breakpoints. // // https://github.com/facebook/jest/issues/6683 diff --git a/src/Common/MongoProxyClient.test.ts b/src/Common/MongoProxyClient.test.ts index 1c49141a0..7edae316b 100644 --- a/src/Common/MongoProxyClient.test.ts +++ b/src/Common/MongoProxyClient.test.ts @@ -236,13 +236,12 @@ describe("MongoProxyClient", () => { }); 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"); }); it("returns a development endpoint", () => { - updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" }); - const endpoint = getEndpoint(); + const endpoint = getEndpoint("https://localhost:1234"); expect(endpoint).toEqual("https://localhost:1234/api/mongo/explorer"); }); @@ -250,7 +249,7 @@ describe("MongoProxyClient", () => { updateUserContext({ 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"); }); }); diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index 668a0ab16..1a0c75a4a 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -1,5 +1,6 @@ import { Constants as CosmosSDKConstants } from "@azure/cosmos"; import queryString from "querystring"; +import { allowedMongoProxyEndpoints, validateEndpoint } from "Utils/EndpointValidation"; import { AuthType } from "../AuthType"; import { configContext } from "../ConfigContext"; import * as DataModels from "../Contracts/DataModels"; @@ -336,14 +337,17 @@ export function createMongoCollectionWithProxy( } export function getFeatureEndpointOrDefault(feature: string): string { - return hasFlag(userContext.features.mongoProxyAPIs, feature) - ? getEndpoint(userContext.features.mongoProxyEndpoint) - : getEndpoint(); + const endpoint = + hasFlag(userContext.features.mongoProxyAPIs, feature) && + validateEndpoint(userContext.features.mongoProxyEndpoint, allowedMongoProxyEndpoints) + ? userContext.features.mongoProxyEndpoint + : configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT; + + return getEndpoint(endpoint); } -export function getEndpoint(customEndpoint?: string): string { - let url = customEndpoint ? customEndpoint : configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT; - url += "/api/mongo/explorer"; +export function getEndpoint(endpoint: string): string { + let url = endpoint + "/api/mongo/explorer"; if (userContext.authType === AuthType.EncryptedToken) { url = url.replace("api/mongo", "api/guest/mongo"); diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 61780c128..d63301a61 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -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 { Portal = "Portal", @@ -8,7 +20,7 @@ export enum Platform { export interface ConfigContext { platform: Platform; - allowedParentFrameOrigins: string[]; + allowedParentFrameOrigins: ReadonlyArray; gitSha?: string; proxyPath?: string; AAD_ENDPOINT: string; @@ -30,7 +42,6 @@ export interface ConfigContext { isTerminalEnabled: boolean; hostedExplorerURL: string; armAPIVersion?: string; - allowedJunoOrigins: string[]; msalRedirectURI?: string; } @@ -44,8 +55,7 @@ let configContext: Readonly = { `^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`, `^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.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, hostedExplorerURL: "https://cosmos.azure.com/", AAD_ENDPOINT: "https://login.microsoftonline.com/", @@ -61,14 +71,6 @@ let configContext: Readonly = { JUNO_ENDPOINT: "https://tools.cosmos.azure.com", BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com", isTerminalEnabled: false, - allowedJunoOrigins: [ - JunoEndpoints.Test, - JunoEndpoints.Test2, - JunoEndpoints.Test3, - JunoEndpoints.Prod, - JunoEndpoints.Stage, - "https://localhost", - ], }; export function resetConfigContext(): void { @@ -79,6 +81,50 @@ export function resetConfigContext(): void { } export function updateConfigContext(newContext: Partial): 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); } @@ -102,18 +148,8 @@ export async function initializeConfiguration(): Promise { }); if (response.status === 200) { try { - const { allowedParentFrameOrigins, allowedJunoOrigins, ...externalConfig } = await response.json(); - Object.assign(configContext, externalConfig); - if (allowedParentFrameOrigins && allowedParentFrameOrigins.length > 0) { - updateConfigContext({ - allowedParentFrameOrigins: [...configContext.allowedParentFrameOrigins, ...allowedParentFrameOrigins], - }); - } - if (allowedJunoOrigins && allowedJunoOrigins.length > 0) { - updateConfigContext({ - allowedJunoOrigins: [...configContext.allowedJunoOrigins, ...allowedJunoOrigins], - }); - } + const { ...externalConfig } = await response.json(); + updateConfigContext(externalConfig); } catch (error) { console.error("Unable to parse json in config file"); console.error(error); diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 730bcd9e3..08e9e8c98 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -1,8 +1,10 @@ import { Link } from "@fluentui/react/lib/Link"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; +import { IGalleryItem } from "Juno/JunoClient"; import * as ko from "knockout"; import React from "react"; import _ from "underscore"; +import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointValidation"; import shallow from "zustand/shallow"; import { AuthType } from "../AuthType"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; @@ -24,7 +26,6 @@ import * as ViewModels from "../Contracts/ViewModels"; import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; import { useSidePanel } from "../hooks/useSidePanel"; import { useTabs } from "../hooks/useTabs"; -import { IGalleryItem } from "../Juno/JunoClient"; import { PhoenixClient } from "../Phoenix/PhoenixClient"; import * as ExplorerSettings from "../Shared/ExplorerSettings"; import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; @@ -50,7 +51,7 @@ import * as FileSystemUtil from "./Notebook/FileSystemUtil"; import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import type NotebookManager from "./Notebook/NotebookManager"; -import type { NotebookPaneContent } from "./Notebook/NotebookManager"; +import { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import { useNotebook } from "./Notebook/useNotebook"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; @@ -178,7 +179,11 @@ export default class Explorer { this.resourceTree = new ResourceTreeAdapter(this); // 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({ notebookServerEndpoint: userContext.features.notebookServerUrl, authToken: userContext.features.notebookServerToken, @@ -190,19 +195,6 @@ export default class Explorer { 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(); } @@ -422,7 +414,10 @@ export default class Explorer { connectionStatus.status = ConnectionStatusType.Connected; useNotebook.getState().setConnectionInfo(connectionStatus); 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, forwardingId: connectionInfo.data.forwardingId, }); diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index 540c339e8..8296500cc 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -1,4 +1,5 @@ import ko from "knockout"; +import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation"; import { GetGithubClientId } from "Utils/GitHubUtils"; import { HttpHeaders, HttpStatusCodes } from "../Common/Constants"; import { configContext } from "../ConfigContext"; @@ -484,7 +485,7 @@ export class JunoClient { // public for tests public static getJunoEndpoint(): string { 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`; console.error(error); throw new Error(error); diff --git a/src/Phoenix/PhoenixClient.ts b/src/Phoenix/PhoenixClient.ts index 55bd8a226..e703f48e5 100644 --- a/src/Phoenix/PhoenixClient.ts +++ b/src/Phoenix/PhoenixClient.ts @@ -1,5 +1,6 @@ import promiseRetry, { AbortError } from "p-retry"; import { Action } from "Shared/Telemetry/TelemetryConstants"; +import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation"; import { Areas, ConnectionStatusType, @@ -154,7 +155,7 @@ export class PhoenixClient { public static getPhoenixEndpoint(): string { const phoenixEndpoint = 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`; console.error(error); throw new Error(error); diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 1f17d0aa9..4b498c03b 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -23,7 +23,6 @@ export type Features = { readonly hostedDataExplorer: boolean; readonly junoEndpoint?: string; readonly phoenixEndpoint?: string; - readonly livyEndpoint?: string; readonly notebookBasePath?: string; readonly notebookServerToken?: string; readonly notebookServerUrl?: string; @@ -72,7 +71,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear mongoProxyAPIs: get("mongoproxyapis"), junoEndpoint: get("junoendpoint"), phoenixEndpoint: get("phoenixendpoint"), - livyEndpoint: get("livyendpoint"), notebookBasePath: get("notebookbasepath"), notebookServerToken: get("notebookservertoken"), notebookServerUrl: get("notebookserverurl"), diff --git a/src/Utils/EndpointValidation.ts b/src/Utils/EndpointValidation.ts new file mode 100644 index 000000000..0e3479d97 --- /dev/null +++ b/src/Utils/EndpointValidation.ts @@ -0,0 +1,83 @@ +import { JunoEndpoints } from "Common/Constants"; +import * as Logger from "../Common/Logger"; + +export function validateEndpoint( + endpointToValidate: string | undefined, + allowedEndpoints: ReadonlyArray +): 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 +): 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 = [ + "https://​management.azure.com", + "https://​management.usgovcloudapi.net", + "https://management.chinacloudapi.cn", +]; + +export const allowedAadEndpoints: ReadonlyArray = ["https://login.microsoftonline.com/"]; + +export const allowedBackendEndpoints: ReadonlyArray = [ + "https://main.documentdb.ext.azure.com", + "https://localhost:12901", + "https://localhost:1234", +]; + +export const allowedMongoProxyEndpoints: ReadonlyArray = [ + "https://main.documentdb.ext.azure.com", + "https://localhost:12901", +]; + +export const allowedEmulatorEndpoints: ReadonlyArray = ["https://localhost:8081"]; + +export const allowedMongoBackendEndpoints: ReadonlyArray = ["https://localhost:1234"]; + +export const allowedGraphEndpoints: ReadonlyArray = ["https://graph.windows.net"]; + +export const allowedArcadiaEndpoints: ReadonlyArray = ["https://workspaceartifacts.projectarcadia.net"]; + +export const allowedHostedExplorerEndpoints: ReadonlyArray = ["https://cosmos.azure.com/"]; + +export const allowedMsalRedirectEndpoints: ReadonlyArray = [ + "https://cosmos-explorer-preview.azurewebsites.net/", +]; + +export const allowedJunoOrigins: ReadonlyArray = [ + JunoEndpoints.Test, + JunoEndpoints.Test2, + JunoEndpoints.Test3, + JunoEndpoints.Prod, + JunoEndpoints.Stage, + "https://localhost", +]; + +export const allowedNotebookServerUrls: ReadonlyArray = []; diff --git a/src/Utils/MessageValidation.ts b/src/Utils/MessageValidation.ts index 06aaee206..891c06369 100644 --- a/src/Utils/MessageValidation.ts +++ b/src/Utils/MessageValidation.ts @@ -4,7 +4,7 @@ export function isInvalidParentFrameOrigin(event: MessageEvent): boolean { return !isValidOrigin(configContext.allowedParentFrameOrigins, event); } -function isValidOrigin(allowedOrigins: string[], event: MessageEvent): boolean { +function isValidOrigin(allowedOrigins: ReadonlyArray, event: MessageEvent): boolean { const eventOrigin = (event && event.origin) || ""; const windowOrigin = (window && window.origin) || ""; if (eventOrigin === windowOrigin) {