Use window messaging to pass sensitive data to terminal iframe (#929)

* Use window messaging to pass sensitive data to terminal iframe

* Address feedback

* Format

* Update

* Add tests
This commit is contained in:
Tanuj Mittal 2021-07-12 14:48:13 -07:00 committed by GitHub
parent cfce78242c
commit 854bd2c149
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 198 additions and 206 deletions

View File

@ -1,154 +1,90 @@
import { shallow } from "enzyme";
import React from "react";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import { NotebookTerminalComponent } from "./NotebookTerminalComponent"; import { NotebookTerminalComponent, NotebookTerminalComponentProps } from "./NotebookTerminalComponent";
const createTestDatabaseAccount = (): DataModels.DatabaseAccount => { const testAccount: DataModels.DatabaseAccount = {
return { id: "id",
id: "testId", kind: "kind",
kind: "testKind", location: "location",
location: "testLocation", name: "name",
name: "testName", properties: {
properties: { documentEndpoint: "https://testDocumentEndpoint.azure.com/",
cassandraEndpoint: null, },
documentEndpoint: "https://testDocumentEndpoint.azure.com/", type: "type",
gremlinEndpoint: null,
tableEndpoint: null,
},
type: "testType",
};
}; };
const createTestMongo32DatabaseAccount = (): DataModels.DatabaseAccount => { const testMongo32Account: DataModels.DatabaseAccount = {
return { ...testAccount,
id: "testId",
kind: "testKind",
location: "testLocation",
name: "testName",
properties: {
cassandraEndpoint: null,
documentEndpoint: "https://testDocumentEndpoint.azure.com/",
gremlinEndpoint: null,
tableEndpoint: null,
},
type: "testType",
};
}; };
const createTestMongo36DatabaseAccount = (): DataModels.DatabaseAccount => { const testMongo36Account: DataModels.DatabaseAccount = {
return { ...testAccount,
id: "testId", properties: {
kind: "testKind", mongoEndpoint: "https://testMongoEndpoint.azure.com/",
location: "testLocation", },
name: "testName",
properties: {
cassandraEndpoint: null,
documentEndpoint: "https://testDocumentEndpoint.azure.com/",
gremlinEndpoint: null,
tableEndpoint: null,
mongoEndpoint: "https://testMongoEndpoint.azure.com/",
},
type: "testType",
};
}; };
const createTestCassandraDatabaseAccount = (): DataModels.DatabaseAccount => { const testCassandraAccount: DataModels.DatabaseAccount = {
return { ...testAccount,
id: "testId", properties: {
kind: "testKind", cassandraEndpoint: "https://testCassandraEndpoint.azure.com/",
location: "testLocation", },
name: "testName",
properties: {
cassandraEndpoint: "https://testCassandraEndpoint.azure.com/",
documentEndpoint: null,
gremlinEndpoint: null,
tableEndpoint: null,
},
type: "testType",
};
}; };
const createTerminal = (): NotebookTerminalComponent => { const testNotebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo = {
return new NotebookTerminalComponent({ authToken: "authToken",
notebookServerInfo: { notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com",
authToken: "testAuthToken",
notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/",
},
databaseAccount: createTestDatabaseAccount(),
});
}; };
const createMongo32Terminal = (): NotebookTerminalComponent => { const testMongoNotebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo = {
return new NotebookTerminalComponent({ authToken: "authToken",
notebookServerInfo: { notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/mongo",
authToken: "testAuthToken",
notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/mongo",
},
databaseAccount: createTestMongo32DatabaseAccount(),
});
}; };
const createMongo36Terminal = (): NotebookTerminalComponent => { const testCassandraNotebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo = {
return new NotebookTerminalComponent({ authToken: "authToken",
notebookServerInfo: { notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/cassandra",
authToken: "testAuthToken",
notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/mongo",
},
databaseAccount: createTestMongo36DatabaseAccount(),
});
};
const createCassandraTerminal = (): NotebookTerminalComponent => {
return new NotebookTerminalComponent({
notebookServerInfo: {
authToken: "testAuthToken",
notebookServerEndpoint: "https://testNotebookServerEndpoint.azure.com/cassandra",
},
databaseAccount: createTestCassandraDatabaseAccount(),
});
}; };
describe("NotebookTerminalComponent", () => { describe("NotebookTerminalComponent", () => {
it("getTerminalParams: Test for terminal", () => { it("renders terminal", () => {
const terminal: NotebookTerminalComponent = createTerminal(); const props: NotebookTerminalComponentProps = {
const params: Map<string, string> = terminal.getTerminalParams(); databaseAccount: testAccount,
notebookServerInfo: testNotebookServerInfo,
};
expect(params).toEqual( const wrapper = shallow(<NotebookTerminalComponent {...props} />);
new Map<string, string>([["terminal", "true"]]) expect(wrapper).toMatchSnapshot();
);
}); });
it("getTerminalParams: Test for Mongo 3.2 terminal", () => { it("renders mongo 3.2 shell", () => {
const terminal: NotebookTerminalComponent = createMongo32Terminal(); const props: NotebookTerminalComponentProps = {
const params: Map<string, string> = terminal.getTerminalParams(); databaseAccount: testMongo32Account,
notebookServerInfo: testMongoNotebookServerInfo,
};
expect(params).toEqual( const wrapper = shallow(<NotebookTerminalComponent {...props} />);
new Map<string, string>([ expect(wrapper).toMatchSnapshot();
["terminal", "true"],
["terminalEndpoint", new URL(terminal.props.databaseAccount.properties.documentEndpoint).host],
])
);
}); });
it("getTerminalParams: Test for Mongo 3.6 terminal", () => { it("renders mongo 3.6 shell", () => {
const terminal: NotebookTerminalComponent = createMongo36Terminal(); const props: NotebookTerminalComponentProps = {
const params: Map<string, string> = terminal.getTerminalParams(); databaseAccount: testMongo36Account,
notebookServerInfo: testMongoNotebookServerInfo,
};
expect(params).toEqual( const wrapper = shallow(<NotebookTerminalComponent {...props} />);
new Map<string, string>([ expect(wrapper).toMatchSnapshot();
["terminal", "true"],
["terminalEndpoint", new URL(terminal.props.databaseAccount.properties.mongoEndpoint).host],
])
);
}); });
it("getTerminalParams: Test for Cassandra terminal", () => { it("renders cassandra shell", () => {
const terminal: NotebookTerminalComponent = createCassandraTerminal(); const props: NotebookTerminalComponentProps = {
const params: Map<string, string> = terminal.getTerminalParams(); databaseAccount: testCassandraAccount,
notebookServerInfo: testCassandraNotebookServerInfo,
};
expect(params).toEqual( const wrapper = shallow(<NotebookTerminalComponent {...props} />);
new Map<string, string>([ expect(wrapper).toMatchSnapshot();
["terminal", "true"],
["terminalEndpoint", new URL(terminal.props.databaseAccount.properties.cassandraEndpoint).host],
])
);
}); });
}); });

View File

@ -2,12 +2,12 @@
* Wrapper around Notebook server terminal * Wrapper around Notebook server terminal
*/ */
import postRobot from "post-robot";
import * as React from "react"; import * as React from "react";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as StringUtils from "../../../Utils/StringUtils"; import { TerminalProps } from "../../../Terminal/TerminalProps";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import { TerminalQueryParams } from "../../../Common/Constants"; import * as StringUtils from "../../../Utils/StringUtils";
import { handleError } from "../../../Common/ErrorHandlingUtils";
export interface NotebookTerminalComponentProps { export interface NotebookTerminalComponentProps {
notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo; notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo;
@ -15,79 +15,69 @@ export interface NotebookTerminalComponentProps {
} }
export class NotebookTerminalComponent extends React.Component<NotebookTerminalComponentProps> { export class NotebookTerminalComponent extends React.Component<NotebookTerminalComponentProps> {
private terminalWindow: Window;
constructor(props: NotebookTerminalComponentProps) { constructor(props: NotebookTerminalComponentProps) {
super(props); super(props);
} }
componentDidMount(): void {
this.sendPropsToTerminalFrame();
}
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<div className="notebookTerminalContainer"> <div className="notebookTerminalContainer">
<iframe <iframe
title="Terminal to Notebook Server" title="Terminal to Notebook Server"
src={NotebookTerminalComponent.createNotebookAppSrc(this.props.notebookServerInfo, this.getTerminalParams())} onLoad={(event) => this.handleFrameLoad(event)}
src="terminal.html"
/> />
</div> </div>
); );
} }
public getTerminalParams(): Map<string, string> { handleFrameLoad(event: React.SyntheticEvent<HTMLIFrameElement, Event>): void {
let params: Map<string, string> = new Map<string, string>(); this.terminalWindow = (event.target as HTMLIFrameElement).contentWindow;
params.set(TerminalQueryParams.Terminal, "true"); this.sendPropsToTerminalFrame();
const terminalEndpoint: string = this.tryGetTerminalEndpoint();
if (terminalEndpoint) {
params.set(TerminalQueryParams.TerminalEndpoint, terminalEndpoint);
}
return params;
} }
public tryGetTerminalEndpoint(): string | null { sendPropsToTerminalFrame(): void {
let terminalEndpoint: string | null; if (!this.terminalWindow) {
return;
}
const notebookServerEndpoint: string = this.props.notebookServerInfo.notebookServerEndpoint; const props: TerminalProps = {
terminalEndpoint: this.tryGetTerminalEndpoint(),
notebookServerEndpoint: this.props.notebookServerInfo?.notebookServerEndpoint,
authToken: this.props.notebookServerInfo?.authToken,
subscriptionId: userContext.subscriptionId,
apiType: userContext.apiType,
authType: userContext.authType,
databaseAccount: userContext.databaseAccount,
};
postRobot.send(this.terminalWindow, "props", props, {
domain: window.location.origin,
});
}
public tryGetTerminalEndpoint(): string | undefined {
let terminalEndpoint: string | undefined;
const notebookServerEndpoint = this.props.notebookServerInfo?.notebookServerEndpoint;
if (StringUtils.endsWith(notebookServerEndpoint, "mongo")) { if (StringUtils.endsWith(notebookServerEndpoint, "mongo")) {
let mongoShellEndpoint: string = this.props.databaseAccount.properties.mongoEndpoint; // mongoEndpoint is only available for Mongo 3.6 and higher, fallback to documentEndpoint otherwise
if (!mongoShellEndpoint) { terminalEndpoint =
// mongoEndpoint is only available for Mongo 3.6 and higher. this.props.databaseAccount?.properties.mongoEndpoint || this.props.databaseAccount?.properties.documentEndpoint;
// Fallback to documentEndpoint otherwise.
mongoShellEndpoint = this.props.databaseAccount.properties.documentEndpoint;
}
terminalEndpoint = mongoShellEndpoint;
} else if (StringUtils.endsWith(notebookServerEndpoint, "cassandra")) { } else if (StringUtils.endsWith(notebookServerEndpoint, "cassandra")) {
terminalEndpoint = this.props.databaseAccount.properties.cassandraEndpoint; terminalEndpoint = this.props.databaseAccount?.properties.cassandraEndpoint;
} }
if (terminalEndpoint) { if (terminalEndpoint) {
return new URL(terminalEndpoint).host; return new URL(terminalEndpoint).host;
} }
return null;
}
public static createNotebookAppSrc( return undefined;
serverInfo: DataModels.NotebookWorkspaceConnectionInfo,
params: Map<string, string>
): string {
if (!serverInfo.notebookServerEndpoint) {
handleError(
"Notebook server endpoint not defined. Terminal will fail to connect to jupyter server.",
"NotebookTerminalComponent/createNotebookAppSrc"
);
return "";
}
params.set(TerminalQueryParams.Server, serverInfo.notebookServerEndpoint);
if (serverInfo.authToken && serverInfo.authToken.length > 0) {
params.set(TerminalQueryParams.Token, serverInfo.authToken);
}
params.set(TerminalQueryParams.SubscriptionId, userContext.subscriptionId);
let result: string = "terminal.html?";
for (let key of params.keys()) {
result += `${key}=${encodeURIComponent(params.get(key))}&`;
}
return result;
} }
} }

View File

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotebookTerminalComponent renders cassandra shell 1`] = `
<div
className="notebookTerminalContainer"
>
<iframe
onLoad={[Function]}
src="terminal.html"
title="Terminal to Notebook Server"
/>
</div>
`;
exports[`NotebookTerminalComponent renders mongo 3.2 shell 1`] = `
<div
className="notebookTerminalContainer"
>
<iframe
onLoad={[Function]}
src="terminal.html"
title="Terminal to Notebook Server"
/>
</div>
`;
exports[`NotebookTerminalComponent renders mongo 3.6 shell 1`] = `
<div
className="notebookTerminalContainer"
>
<iframe
onLoad={[Function]}
src="terminal.html"
title="Terminal to Notebook Server"
/>
</div>
`;
exports[`NotebookTerminalComponent renders terminal 1`] = `
<div
className="notebookTerminalContainer"
>
<iframe
onLoad={[Function]}
src="terminal.html"
title="Terminal to Notebook Server"
/>
</div>
`;

View File

@ -0,0 +1,13 @@
import { AuthType } from "../AuthType";
import * as DataModels from "../Contracts/DataModels";
import { ApiType } from "../UserContext";
export interface TerminalProps {
authToken: string;
notebookServerEndpoint: string;
terminalEndpoint: string;
databaseAccount: DataModels.DatabaseAccount;
authType: AuthType;
apiType: ApiType;
subscriptionId: string;
}

View File

@ -1,43 +1,36 @@
import { ServerConnection } from "@jupyterlab/services"; import { ServerConnection } from "@jupyterlab/services";
import "@jupyterlab/terminal/style/index.css"; import "@jupyterlab/terminal/style/index.css";
import { HttpHeaders, TerminalQueryParams } from "../Common/Constants"; import postRobot from "post-robot";
import { HttpHeaders } from "../Common/Constants";
import { Action } from "../Shared/Telemetry/TelemetryConstants"; import { Action } from "../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { updateUserContext } from "../UserContext"; import { updateUserContext } from "../UserContext";
import "./index.css"; import "./index.css";
import { JupyterLabAppFactory } from "./JupyterLabAppFactory"; import { JupyterLabAppFactory } from "./JupyterLabAppFactory";
import { TerminalProps } from "./TerminalProps";
const getUrlVars = (): { [key: string]: string } => { const createServerSettings = (props: TerminalProps): ServerConnection.ISettings => {
const vars: { [key: string]: string } = {};
window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, (_m, key, value): string => {
vars[key] = decodeURIComponent(value);
return value;
});
return vars;
};
const createServerSettings = (urlVars: { [key: string]: string }): ServerConnection.ISettings => {
let body: BodyInit | undefined; let body: BodyInit | undefined;
let headers: HeadersInit | undefined; let headers: HeadersInit | undefined;
if (urlVars.hasOwnProperty(TerminalQueryParams.TerminalEndpoint)) { if (props.terminalEndpoint) {
body = JSON.stringify({ body = JSON.stringify({
endpoint: urlVars[TerminalQueryParams.TerminalEndpoint], endpoint: props.terminalEndpoint,
}); });
headers = { headers = {
[HttpHeaders.contentType]: "application/json", [HttpHeaders.contentType]: "application/json",
}; };
} }
const server = urlVars[TerminalQueryParams.Server]; const server = props.notebookServerEndpoint;
let options: Partial<ServerConnection.ISettings> = { let options: Partial<ServerConnection.ISettings> = {
baseUrl: server, baseUrl: server,
init: { body, headers }, init: { body, headers },
fetch: window.parent.fetch, fetch: window.parent.fetch,
}; };
if (urlVars.hasOwnProperty(TerminalQueryParams.Token)) { if (props.authToken) {
options = { options = {
baseUrl: server, baseUrl: server,
token: urlVars[TerminalQueryParams.Token], token: props.authToken,
appendToken: true, appendToken: true,
init: { body, headers }, init: { body, headers },
fetch: window.parent.fetch, fetch: window.parent.fetch,
@ -47,30 +40,41 @@ const createServerSettings = (urlVars: { [key: string]: string }): ServerConnect
return ServerConnection.makeSettings(options); return ServerConnection.makeSettings(options);
}; };
const main = async (): Promise<void> => { const initTerminal = async (props: TerminalProps) => {
const urlVars = getUrlVars(); // Initialize userContext (only properties which are needed by TelemetryProcessor)
// Initialize userContext. Currently only subscriptionId is required by TelemetryProcessor
updateUserContext({ updateUserContext({
subscriptionId: urlVars[TerminalQueryParams.SubscriptionId], subscriptionId: props.subscriptionId,
apiType: props.apiType,
authType: props.authType,
databaseAccount: props.databaseAccount,
}); });
const serverSettings = createServerSettings(urlVars); const serverSettings = createServerSettings(props);
const data = { baseUrl: serverSettings.baseUrl }; const data = { baseUrl: serverSettings.baseUrl };
const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data); const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data);
try { try {
if (urlVars.hasOwnProperty(TerminalQueryParams.Terminal)) { await JupyterLabAppFactory.createTerminalApp(serverSettings);
await JupyterLabAppFactory.createTerminalApp(serverSettings);
} else {
throw new Error("Only terminal is supported");
}
TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime); TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime);
} catch (error) { } catch (error) {
TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime); TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime);
} }
}; };
const main = async (): Promise<void> => {
postRobot.on(
"props",
{
window: window.parent,
domain: window.location.origin,
},
async (event) => {
// Typescript definition for event is wrong. So read props by casting to <any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props = (event as any).data as TerminalProps;
await initTerminal(props);
}
);
};
window.addEventListener("load", main); window.addEventListener("load", main);