diff --git a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx index 28b8b51b3..2abbc3618 100644 --- a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx +++ b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx @@ -277,6 +277,10 @@ export class NotebookComponentBootstrapper { return selectors.notebook.isDirty(content.model as Immutable.RecordOf); } + public isNotebookUntrusted(): boolean { + return NotebookUtil.isNotebookUntrusted(this.getStore().getState(), this.contentRef); + } + /** * For display purposes, only return non-killed kernels */ diff --git a/src/Explorer/Notebook/NotebookComponent/VirtualCommandBarComponent.tsx b/src/Explorer/Notebook/NotebookComponent/VirtualCommandBarComponent.tsx index 3c28ba9da..719e42930 100644 --- a/src/Explorer/Notebook/NotebookComponent/VirtualCommandBarComponent.tsx +++ b/src/Explorer/Notebook/NotebookComponent/VirtualCommandBarComponent.tsx @@ -1,12 +1,14 @@ import { AppState, ContentRef, selectors } from "@nteract/core"; import * as React from "react"; import { connect } from "react-redux"; +import { NotebookUtil } from "../NotebookUtil"; import * as NteractUtil from "../NTeractUtil"; interface VirtualCommandBarComponentProps { kernelSpecName: string; kernelStatus: string; currentCellType: string; + isNotebookUntrusted: boolean; onRender: () => void; } @@ -20,7 +22,8 @@ class VirtualCommandBarComponent extends React.Component { return ( <>
+
diff --git a/src/Explorer/Notebook/NotebookRenderer/Prompt.less b/src/Explorer/Notebook/NotebookRenderer/Prompt.less index af04d2f32..ec840fe7b 100644 --- a/src/Explorer/Notebook/NotebookRenderer/Prompt.less +++ b/src/Explorer/Notebook/NotebookRenderer/Prompt.less @@ -19,6 +19,12 @@ } } +.disabledRunCellButton { + .runCellButton .ms-Button-flexContainer .ms-Button-icon { + color: @BaseMediumHigh; + } +} + .greyStopButton { .runCellButton .ms-Button-flexContainer .ms-Button-icon { color: @BaseMediumHigh; diff --git a/src/Explorer/Notebook/NotebookRenderer/Prompt.tsx b/src/Explorer/Notebook/NotebookRenderer/Prompt.tsx index b8d95fa67..71c3d750a 100644 --- a/src/Explorer/Notebook/NotebookRenderer/Prompt.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/Prompt.tsx @@ -5,6 +5,7 @@ import { Dispatch } from "redux"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import * as cdbActions from "../NotebookComponent/actions"; import { CdbAppState } from "../NotebookComponent/types"; +import { NotebookUtil } from "../NotebookUtil"; export interface PassedPromptProps { id: string; @@ -12,6 +13,7 @@ export interface PassedPromptProps { status?: string; executionCount?: number; isHovered?: boolean; + isRunDisabled?: boolean; runCell?: () => void; stopCell?: () => void; } @@ -20,6 +22,7 @@ interface ComponentProps { id: string; contentRef: ContentRef; isHovered?: boolean; + isNotebookUntrusted?: boolean; children: (props: PassedPromptProps) => React.ReactNode; } @@ -47,6 +50,7 @@ export class PromptPure extends React.Component { runCell: this.props.executeCell, stopCell: this.props.stopExecution, isHovered: this.props.isHovered, + isRunDisabled: this.props.isNotebookUntrusted, })}
); @@ -75,6 +79,7 @@ const makeMapStateToProps = (_state: CdbAppState, ownProps: ComponentProps): ((s status, executionCount, isHovered, + isNotebookUntrusted: NotebookUtil.isNotebookUntrusted(state, contentRef), }; }; return mapStateToProps; diff --git a/src/Explorer/Notebook/NotebookRenderer/PromptContent.test.tsx b/src/Explorer/Notebook/NotebookRenderer/PromptContent.test.tsx new file mode 100644 index 000000000..83dc8a256 --- /dev/null +++ b/src/Explorer/Notebook/NotebookRenderer/PromptContent.test.tsx @@ -0,0 +1,27 @@ +import { shallow } from "enzyme"; +import { PassedPromptProps } from "./Prompt"; +import { promptContent } from "./PromptContent"; + +describe("PromptContent", () => { + it("renders for busy status", () => { + const props: PassedPromptProps = { + id: "id", + contentRef: "contentRef", + status: "busy", + }; + const wrapper = shallow(promptContent(props)); + + expect(wrapper).toMatchSnapshot(); + }); + + it("renders when hovered", () => { + const props: PassedPromptProps = { + id: "id", + contentRef: "contentRef", + isHovered: true, + }; + const wrapper = shallow(promptContent(props)); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx b/src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx index 06db03b53..618f9d590 100644 --- a/src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx @@ -1,5 +1,6 @@ import { IconButton, Spinner, SpinnerSize } from "@fluentui/react"; import * as React from "react"; +import { NotebookUtil } from "../NotebookUtil"; import { PassedPromptProps } from "./Prompt"; import "./Prompt.less"; @@ -23,15 +24,18 @@ export const promptContent = (props: PassedPromptProps): JSX.Element => {
); } else if (props.isHovered) { - const playButtonText = "Run cell"; + const playButtonText = props.isRunDisabled ? NotebookUtil.UntrustedNotebookRunHint : "Run cell"; return ( - +
+ +
); } else { return
{promptText(props)}
; diff --git a/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx b/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx index 7c0fa74ee..eaa2447a6 100644 --- a/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx @@ -36,6 +36,7 @@ interface StateProps { cellIdAbove: CellId; cellIdBelow: CellId; hasCodeOutput: boolean; + isNotebookUntrusted: boolean; } class BaseToolbar extends React.PureComponent { @@ -43,12 +44,16 @@ class BaseToolbar extends React.PureComponent { this.props.executeCell(); this.props.traceNotebookTelemetry(Action.NotebooksExecuteCellFromMenu, ActionModifiers.Mark); @@ -223,6 +228,7 @@ const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state cellIdAbove, cellIdBelow, hasCodeOutput: cellType === "code" && NotebookUtil.hasCodeCellOutput(cell as ImmutableCodeCell), + isNotebookUntrusted: NotebookUtil.isNotebookUntrusted(state, ownProps.contentRef), }; }; return mapStateToProps; diff --git a/src/Explorer/Notebook/NotebookRenderer/__snapshots__/PromptContent.test.tsx.snap b/src/Explorer/Notebook/NotebookRenderer/__snapshots__/PromptContent.test.tsx.snap new file mode 100644 index 000000000..78383efbc --- /dev/null +++ b/src/Explorer/Notebook/NotebookRenderer/__snapshots__/PromptContent.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromptContent renders for busy status 1`] = ` +
+ + +
+`; + +exports[`PromptContent renders when hovered 1`] = ` +
+ +
+`; diff --git a/src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx b/src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx index c92932395..98e79d23e 100644 --- a/src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx @@ -4,6 +4,7 @@ import Immutable from "immutable"; import React from "react"; import { connect } from "react-redux"; import { Dispatch } from "redux"; +import { NotebookUtil } from "../../../NotebookUtil"; interface ComponentProps { contentRef: ContentRef; @@ -14,6 +15,7 @@ interface StateProps { cellMap: Immutable.Map; cellOrder: Immutable.List; focusedCell?: string | null; + isNotebookUntrusted: boolean; } interface DispatchProps { @@ -59,8 +61,13 @@ export class KeyboardShortcuts extends React.Component { cellOrder, focusedCell, cellMap, + isNotebookUntrusted, } = this.props; + if (isNotebookUntrusted) { + return; + } + let ctrlKeyPressed = e.ctrlKey; // Allow cmd + enter (macOS) to operate like ctrl + enter if (process.platform === "darwin") { @@ -125,6 +132,7 @@ export const makeMapStateToProps = (_state: AppState, ownProps: ComponentProps) cellOrder, cellMap, focusedCell, + isNotebookUntrusted: NotebookUtil.isNotebookUntrusted(state, contentRef), }; }; return mapStateToProps; diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index 40897f397..b060533d7 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -1,4 +1,5 @@ import { ImmutableCodeCell, ImmutableNotebook } from "@nteract/commutable"; +import { AppState, selectors } from "@nteract/core"; import domtoimage from "dom-to-image"; import Html2Canvas from "html2canvas"; import path from "path"; @@ -11,6 +12,8 @@ import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentI export type FileType = "directory" | "file" | "notebook"; // Utilities for notebooks export class NotebookUtil { + public static UntrustedNotebookRunHint = "Please trust notebook first before running any code cells"; + /** * It's a notebook file if the filename ends with .ipynb. */ @@ -153,6 +156,16 @@ export class NotebookUtil { ); } + public static isNotebookUntrusted(state: AppState, contentRef: string): boolean { + const content = selectors.content(state, { contentRef }); + if (content?.type === "notebook") { + const metadata = selectors.notebook.metadata(content.model); + return metadata.getIn(["untrusted"]) as boolean; + } + + return false; + } + /** * Find code cells with display * @param notebookObject diff --git a/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.test.tsx b/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.test.tsx new file mode 100644 index 000000000..b775456ef --- /dev/null +++ b/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.test.tsx @@ -0,0 +1,31 @@ +import { shallow } from "enzyme"; +import React from "react"; +import { SecurityWarningBar } from "./SecurityWarningBar"; + +describe("SecurityWarningBar", () => { + it("renders if notebook is untrusted", () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + it("renders if notebook is trusted", () => { + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.tsx b/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.tsx new file mode 100644 index 000000000..49575d0d7 --- /dev/null +++ b/src/Explorer/Notebook/SecurityWarningBar/SecurityWarningBar.tsx @@ -0,0 +1,93 @@ +import { MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react"; +import { actions, AppState } from "@nteract/core"; +import React from "react"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; +import { NotebookUtil } from "../NotebookUtil"; + +export interface SecurityWarningBarPureProps { + contentRef: string; +} + +interface SecurityWarningBarDispatchProps { + markNotebookAsTrusted: (contentRef: string) => void; + saveNotebook: (contentRef: string) => void; +} + +type SecurityWarningBarProps = SecurityWarningBarPureProps & StateProps & SecurityWarningBarDispatchProps; + +interface SecurityWarningBarState { + isBarDismissed: boolean; +} + +export class SecurityWarningBar extends React.Component { + constructor(props: SecurityWarningBarProps) { + super(props); + + this.state = { + isBarDismissed: false, + }; + } + + render(): JSX.Element { + return this.props.isNotebookUntrusted && !this.state.isBarDismissed ? ( + this.setState({ isBarDismissed: true })} + dismissButtonAriaLabel="Close" + actions={ + { + this.props.markNotebookAsTrusted(this.props.contentRef); + this.props.saveNotebook(this.props.contentRef); + }} + > + Trust Notebook + + } + > + {" "} + This notebook was downloaded from the public gallery. Running code cells from a notebook authored by someone + else may involve security risks. + + ) : ( + <> + ); + } +} + +interface StateProps { + isNotebookUntrusted: boolean; +} + +interface InitialProps { + contentRef: string; +} + +// Redux +const makeMapStateToProps = (state: AppState, initialProps: InitialProps) => { + const mapStateToProps = (state: AppState): StateProps => ({ + isNotebookUntrusted: NotebookUtil.isNotebookUntrusted(state, initialProps.contentRef), + }); + return mapStateToProps; +}; + +const makeMapDispatchToProps = () => { + const mapDispatchToProps = (dispatch: Dispatch): SecurityWarningBarDispatchProps => { + return { + markNotebookAsTrusted: (contentRef: string) => { + return dispatch( + actions.deleteMetadataField({ + contentRef, + field: "untrusted", + }) + ); + }, + saveNotebook: (contentRef: string) => dispatch(actions.save({ contentRef })), + }; + }; + return mapDispatchToProps; +}; + +export default connect(makeMapStateToProps, makeMapDispatchToProps)(SecurityWarningBar); diff --git a/src/Explorer/Notebook/SecurityWarningBar/__snapshots__/SecurityWarningBar.test.tsx.snap b/src/Explorer/Notebook/SecurityWarningBar/__snapshots__/SecurityWarningBar.test.tsx.snap new file mode 100644 index 000000000..ac69a5b49 --- /dev/null +++ b/src/Explorer/Notebook/SecurityWarningBar/__snapshots__/SecurityWarningBar.test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SecurityWarningBar renders if notebook is trusted 1`] = ``; + +exports[`SecurityWarningBar renders if notebook is untrusted 1`] = ` + + Trust Notebook + + } + dismissButtonAriaLabel="Close" + isMultiline={false} + messageBarType={5} + onDismiss={[Function]} +> + + This notebook was downloaded from the public gallery. Running code cells from a notebook authored by someone else may involve security risks. + +`; diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 01aab3c6a..66d894cb3 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -24,6 +24,7 @@ import * as CdbActions from "../Notebook/NotebookComponent/actions"; import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter"; import { CdbAppState, SnapshotRequest } from "../Notebook/NotebookComponent/types"; import { NotebookContentItem } from "../Notebook/NotebookContentItem"; +import { NotebookUtil } from "../Notebook/NotebookUtil"; import { useNotebook } from "../Notebook/useNotebook"; import NotebookTabBase, { NotebookTabBaseOptions } from "./NotebookTabBase"; @@ -87,11 +88,13 @@ export default class NotebookTabV2 extends NotebookTabBase { protected getTabsButtons(): CommandButtonComponentProps[] { const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs(); + const isNotebookUntrusted = this.notebookComponentAdapter.isNotebookUntrusted(); + + const runBtnTooltip = isNotebookUntrusted ? NotebookUtil.UntrustedNotebookRunHint : undefined; const saveLabel = "Save"; const copyToLabel = "Copy to ..."; const publishLabel = "Publish to gallery"; - const workspaceLabel = "No Workspace"; const kernelLabel = "No Kernel"; const runLabel = "Run"; const runActiveCellLabel = "Run Active Cell"; @@ -108,8 +111,6 @@ export default class NotebookTabV2 extends NotebookTabBase { const copyLabel = "Copy"; const cutLabel = "Cut"; const pasteLabel = "Paste"; - const undoLabel = "Undo"; - const redoLabel = "Redo"; const cellCodeType = "code"; const cellMarkdownType = "markdown"; const cellRawType = "raw"; @@ -190,9 +191,10 @@ export default class NotebookTabV2 extends NotebookTabBase { this.traceTelemetry(Action.ExecuteCell); }, commandButtonLabel: runLabel, + tooltipText: runBtnTooltip, ariaLabel: runLabel, hasPopup: false, - disabled: false, + disabled: isNotebookUntrusted, children: [ { iconSrc: RunIcon, diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts index 16a41c6d3..d39a74a04 100644 --- a/src/Utils/GalleryUtils.ts +++ b/src/Utils/GalleryUtils.ts @@ -243,6 +243,11 @@ export function downloadItem( const notebook = JSON.parse(response.data) as Notebook; removeNotebookViewerLink(notebook, data.newCellId); + if (!data.isSample) { + const metadata = notebook.metadata as { [name: string]: unknown }; + metadata.untrusted = true; + } + await container.importAndOpenContent(data.name, JSON.stringify(notebook)); logConsoleInfo(`Successfully downloaded ${name} to My Notebooks`);