Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom widgets #47

Merged
merged 11 commits into from
Dec 3, 2020
22 changes: 21 additions & 1 deletion packages/jupyter-widgets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,27 @@ export default class MyNotebookApp extends ReactComponent {

## Documentation

We're working on adding more documentation for this component. Stay tuned by watching this repository!
The `jupyter-widgets` package supports two types of widgets:
- Standard widgets provided in the official [`jupyter-widgets/base`](https://www.npmjs.com/package/@jupyter-widgets/base) and [`jupyter-widgets/controls`](https://www.npmjs.com/package/@jupyter-widgets/controls) package
- [Custom Widgets](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Custom.html) or 3rd party widgets authored by the OSS community

The `WidgetDisplay` component has an additional prop named `customWidgetLoader` to provide custom loaders for fetching 3rd party widgets. A reference implementation for a custom loader which serves as the default for this package can be found in `widget-loader.ts`.

```typescript
customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise<any>;
```

### Custom Widgets

Since custom widgets are hosted on CDN, we set https://unkpg.com as our default CDN Base URL. The default base URL can be overriden by specifying another URL via the HTML attribute "data-jupyter-widgets-cdn" on any script tag of the page.

For instance if your JavaScript bundle is loaded as `bundle.js` on your page and you wanted to set [jsdelivr](https://www.jsdelivr.com) as your default CDN url for custom widgets, you could do the following:
```html
<script data-jupyter-widgets-cdn="https://cdn.jsdelivr.net/npm" src="bundle.js"></script>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rgbkrk Thoughts on this model for passing custom properties to the JavaScript assets?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting -- this makes it so a custom frontend can specify where custom widgets are allowed to be loaded from?

Probably worth noting that any output can add a tag to say to load other assets, so that's another vector for attack (though it means you need some HTML output already emitted in the document to use it).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting -- this makes it so a custom frontend can specify where custom widgets are allowed to be loaded from?

Correct. This allows you to set the CDN to use when loading widgets. The HTML widget manager does something similar (ref).

Probably worth noting that any output can add a tag to say to load other assets, so that's another vector for attack (though it means you need some HTML output already emitted in the document to use it).

Good point. The logic currently loads the the attribute from any script tag. I believe the way the for-loop is setup will cause it to favor the most recently inserted script tag which might be a problem.

@vivek1729 Thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point @rgbkrk. @captainsafia rightly pointed out that I followed the pattern that the HTML widget manager to override the default CDN URL. The logic of reading and looping over script tags is also based off the HTML Widget manager (ref).

Good point. The logic currently loads the the attribute from any script tag. I believe the way the for-loop is setup will cause it to favor the most recently inserted script tag which might be a problem.

Trying to understand how favoring the most recent added script tag might be a problem. Ideally, we'd hope that there's only 1 script tag that specifies the data-jupyter-widgets-cdn tag. Looping over script tags potentially opens up the possibility of script tags being added dynamically which can lead to the cdn URL being over-written. However, we should also note that the CDN url is set when the WidgetManager is getting constructed. Since the WidgetManager is a singleton, this would only happen once and it makes sense because we don't really want to have the base CDN URL change very often. It's more like a one-time setup configuration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WidgetManager gets instantiated once when the first widget is rendered onto the page. It's possible for a nefarious to render a HTML output (or inject HTML into the page in some other way) that renders a script tag with a malicious CDN onto the page then render a second output containing the ipywidget.

@jasongrout Do you have any thoughts on this since I believe you implemented the original code in the HTML manager?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@captainsafia, thanks for the additional details. How do you suggest we address this concern?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, I'm thinking that we:

  • Merge this PR so we can get the basic groundwork in
  • Ship an alpha release of the package
  • Open an issue to address the security issue, particularly use the alpha package to create an MVP of what the attack would look like, also see if we can get some insight from the ipywidgets folks on this
  • Followup on the issue from Add support for Output widgets #3 as needed

If that sounds good to you we can move forward with it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@captainsafia , this sounds like a good plan to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll proceed with the PR when you get a chance to do a final review and approve.

```
Note: Custom widgets are fetched and loaded using the [requireJS](https://requirejs.org/) library. Please ensure that the library is loaded on your page and that the `require` and `define` APIs are available on the `window` object. We attempt to detect the presence of these APIs and emit a warning that custom widgets won't work when `requirejs` is missing.



## Support

Expand Down
78 changes: 70 additions & 8 deletions packages/jupyter-widgets/__tests__/manager/manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
import { IntSliderView } from "@jupyter-widgets/controls";
import { Map } from "immutable";

import { ManagerActions } from "../../src/manager/index";
import { WidgetManager } from "../../src/manager/widget-manager";
import * as customWidgetLoader from "../../src/manager/widget-loader";

// A mock valid module representing a custom widget
const mockFooModule = {
"foo" : "bar"
};
// Mock implementation of the core require API
const mockRequireJS = jest.fn((modules, ready, errCB) => ready(mockFooModule));
(window as any).requirejs = mockRequireJS;
(window as any).requirejs.config = jest.fn();

// Manager actions passed as the third arg when instantiating the WidgetManager class
const mockManagerActions: ManagerActions["actions"] = {
appendOutput: jest.fn(),
clearOutput: jest.fn(),
updateCellStatus: jest.fn(),
promptInputRequest: jest.fn()
};

// Default modelById stub
const mockModelById = (id: string) => undefined;

describe("WidgetManager", () => {
describe("loadClass", () => {
beforeAll(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});

it("returns a class if it exists", () => {
const modelById = (id: string) => undefined;
const manager = new WidgetManager(null, modelById);
const manager = new WidgetManager(null, mockModelById, mockManagerActions);
const view = manager.loadClass(
"IntSliderView",
"@jupyter-widgets/controls",
"1.5.0"
);
expect(view).not.toBe(null);
});

it("Returns a valid module class successfully from CDN for custom widgets", () => {
const manager = new WidgetManager(null, mockModelById, mockManagerActions);
const requireLoaderSpy = jest.spyOn(customWidgetLoader, "requireLoader");

return manager.loadClass(
"foo",
"fooModule",
"1.1.0"
).then(view => {
expect(requireLoaderSpy).toHaveBeenCalledTimes(1);
// Get the second arg to Monaco.editor.create call
const mockLoaderArgs = requireLoaderSpy.mock.calls[0];
expect(mockLoaderArgs).not.toBe(null);
expect(mockLoaderArgs.length).toBe(2);
expect(mockLoaderArgs[0]).toBe("fooModule");
expect(mockLoaderArgs[1]).toBe("1.1.0");
expect(view).not.toBe(null);
expect(view).toBe(mockFooModule["foo"]);
});
});

it("Returns an error if the class does not exist on the module", () => {
const manager = new WidgetManager(null, mockModelById, mockManagerActions);
const requireLoaderSpy = jest.spyOn(customWidgetLoader, "requireLoader");

return manager.loadClass(
"INVALID_CLASS",
"fooModule",
"1.1.0"
).catch(error => {
expect(requireLoaderSpy).toHaveBeenCalledTimes(1);
expect(error).toBe("Class INVALID_CLASS not found in module [email protected]");
});
});
});

describe("create_view", () => {
it("returns a widget mounted on the provided element", async () => {
const modelById = (id: string) => undefined;
const manager = new WidgetManager(null, modelById);
const manager = new WidgetManager(null, mockModelById, mockManagerActions);
const model = {
_dom_classes: [],
_model_module: "@jupyter-widgets/controls",
Expand Down Expand Up @@ -99,7 +162,7 @@ describe("WidgetManager", () => {
const model = id === "layout_id" ? layoutModel : styleModel;
return Promise.resolve(Map({ state: Map(model) }));
};
const manager = new WidgetManager(null, modelById);
const manager = new WidgetManager(null, modelById, mockManagerActions);
const widget = await manager.new_widget_from_state_and_id(
model,
"test_model_id"
Expand All @@ -119,11 +182,10 @@ describe("WidgetManager", () => {
});
});
it("can update class properties via method", () => {
const modelById = (id: string) => undefined;
const manager = new WidgetManager(null, modelById);
const manager = new WidgetManager(null, mockModelById, mockManagerActions);
expect(manager.kernel).toBeNull();
const newKernel = { channels: { next: jest.fn() } };
manager.update(newKernel, modelById, {});
manager.update(newKernel, mockModelById, mockManagerActions);
expect(manager.kernel).toBe(newKernel);
});
});
63 changes: 63 additions & 0 deletions packages/jupyter-widgets/__tests__/manager/widget-loader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { requireLoader } from "../../src/manager/widget-loader";

// A mock valid module representing a custom widget
const mockModule = {
"foo" : "bar"
};
// Info representing an invalid module for testing the failure case
const invalidModule = {
name: "invalid_module",
version: "1.0",
url: "https://unpkg.com/[email protected]/dist/index.js"
};
// Mock implementation of the core require API
const mockRequireJS = jest.fn((modules, ready, errCB) => {
if(modules.length > 0 && modules[0] === invalidModule.url){
errCB(new Error("Whoops!"));
}
else {
ready(mockModule);
}
});

// Callback binding
(window as any).requirejs = mockRequireJS;
(window as any).requirejs.config = jest.fn();

describe("requireLoader", () => {
beforeAll(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it("Returns a module if linked to a valid CDN URL", () => {
return requireLoader("foo", "1.0.0").then(mod => {
expect(mockRequireJS).toHaveBeenCalledTimes(1);
const moduleURLs = mockRequireJS.mock.calls[0][0];
expect(moduleURLs).not.toBe(null);
expect(moduleURLs.length).toBe(1);
expect(moduleURLs[0]).toBe("https://unpkg.com/[email protected]/dist/index.js");
expect(mod).toEqual(mockModule);
});
});

it("Returns a module even when module version is missing", () => {
return requireLoader("foo", undefined).then(mod => {
expect(mockRequireJS).toHaveBeenCalledTimes(1);
const moduleURLs = mockRequireJS.mock.calls[0][0];
expect(moduleURLs).not.toBe(null);
expect(moduleURLs.length).toBe(1);
expect(moduleURLs[0]).toBe("https://unpkg.com/foo/dist/index.js");
expect(mod).toEqual(mockModule);
});
});

it("Calls the error callback if an error is encountered during the module loading", () => {
const {name, version} = invalidModule;
return requireLoader(name, version).catch((error: Error) => {
expect(mockRequireJS).toHaveBeenCalledTimes(1);
expect(error.message).toBe("Whoops!");
});
});
});
2 changes: 2 additions & 0 deletions packages/jupyter-widgets/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Props {
| null;
id: CellId;
contentRef: ContentRef;
customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise<any>;
}

interface State {
Expand Down Expand Up @@ -72,6 +73,7 @@ export class WidgetDisplay extends React.Component<Props, State> {
contentRef={this.props.contentRef}
modelById={this.props.modelById}
kernel={this.props.kernel}
customWidgetLoader={this.props.customWidgetLoader}
/>
);
} else {
Expand Down
4 changes: 3 additions & 1 deletion packages/jupyter-widgets/src/manager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface OwnProps {
model_id: string;
id: CellId;
contentRef: ContentRef;
customWidgetLoader?: (moduleName: string, moduleVersion: string) => Promise<any>;
}

type Props = ConnectedProps & OwnProps & ManagerActions;
Expand Down Expand Up @@ -68,7 +69,8 @@ class Manager extends React.Component<Props> {
Manager.manager = new WidgetManager(
this.props.kernel,
this.props.modelById,
this.props.actions
this.props.actions,
this.props.customWidgetLoader
);
} else {
Manager.manager.update(
Expand Down
4 changes: 2 additions & 2 deletions packages/jupyter-widgets/src/manager/widget-comms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,13 @@ export function request_state(kernel: any, comm_id: string): Promise<any> {
.pipe(childOf(message))
.subscribe((reply: any) => {
// if we get a comm message back, it is the state we requested
if (reply.msg_type === "comm_msg") {
if (reply.header?.msg_type === "comm_msg") {
replySubscription.unsubscribe();
return resolve(reply);
}
// otherwise, if we havent gotten a comm message and it goes idle, it wasn't found
else if (
reply.msg_type === "status" &&
reply.header?.msg_type === "status" &&
reply.content.execution_state === "idle"
) {
replySubscription.unsubscribe();
Expand Down
108 changes: 108 additions & 0 deletions packages/jupyter-widgets/src/manager/widget-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Several functions in this file are based off the html-manager in jupyter-widgets project -
* https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/html-manager/src/libembed-amd.ts
*/

import * as base from "@jupyter-widgets/base";
import * as controls from "@jupyter-widgets/controls";

const requireJSMissingErrorMessage = "Requirejs is needed, please ensure it is loaded on the page. Docs - https://requirejs.org/docs/api.html";
let cdn = "https://unpkg.com";

/**
* Constructs a well formed module URL for requireJS
* mapping the modulename and version from the base CDN URL
* @param moduleName Name of the module corresponding to the widget package
* @param moduleVersion Module version returned from kernel
*/
function moduleNameToCDNUrl(moduleName: string, moduleVersion: string): string {
let packageName = moduleName;
let fileName = "index.js"; // default filename
// if a '/' is present, like 'foo/bar', packageName is changed to 'foo', and path to 'bar'
// We first find the first '/'
let index = moduleName.indexOf('/');
if (index !== -1 && moduleName[0] === '@') {
// if we have a namespace, it's a different story
// @foo/bar/baz should translate to @foo/bar and baz
// so we find the 2nd '/'
index = moduleName.indexOf('/', index + 1);
}
if (index !== -1) {
fileName = moduleName.substr(index + 1);
packageName = moduleName.substr(0, index);
}
const moduleNameString = moduleVersion ? `${packageName}@${moduleVersion}` : packageName;
return `${cdn}/${moduleNameString}/dist/${fileName}`;
}

/**
* Load a package using requirejs and return a promise
*
* @param pkg Package name or names to load
*/
function requirePromise(pkg: string | string[]): Promise<any> {
return new Promise((resolve, reject) => {
const require = (window as any).requirejs;
if (require === undefined) {
reject(requireJSMissingErrorMessage);
}
else {
// tslint:disable-next-line: non-literal-require
require(pkg, resolve, reject);
}
});
};

/**
* Initialize dependencies that need to be preconfigured for requireJS module loading
* Here, we define the jupyter-base, controls package that most 3rd party widgets depend on
*/
export function initRequireDeps(){
vivek1729 marked this conversation as resolved.
Show resolved Hide resolved
// Export the following for `requirejs`.
// tslint:disable-next-line: no-any no-function-expression no-empty
const define = (window as any).define || function () {};
define("@jupyter-widgets/controls", () => controls);
define("@jupyter-widgets/base", () => base);
}

/**
* Overrides the default CDN base URL by querying the DOM for script tags
* By default, the CDN service used is unpkg.com. However, this default can be
* overriden by specifying another URL via the HTML attribute
* "data-jupyter-widgets-cdn" on a script tag of the page.
*/
export function overrideCDNBaseURL(){
// find the data-cdn for any script tag
const scripts = document.getElementsByTagName("script");
Array.prototype.forEach.call(scripts, (script: HTMLScriptElement) => {
cdn = script.getAttribute("data-jupyter-widgets-cdn") || cdn;
});
// Remove Single/consecutive trailing slashes from the URL to sanitize it
cdn = cdn.replace(/\/+$/, "");
}

/**
* Load an amd module from a specified CDN
*
* @param moduleName The name of the module to load.
* @param moduleVersion The semver range for the module, if loaded from a CDN.
*
* By default, the CDN service used is unpkg.com. However, this default can be
* overriden by specifying another URL via the HTML attribute
* "data-jupyter-widgets-cdn" on a script tag of the page.
*
* The semver range is only used with the CDN.
*/
export function requireLoader(moduleName: string, moduleVersion: string): Promise<any> {
const require = (window as any).requirejs;
if (require === undefined) {
vivek1729 marked this conversation as resolved.
Show resolved Hide resolved
return Promise.reject(new Error(requireJSMissingErrorMessage));
}
else {
const conf: { paths: { [key: string]: string } } = { paths: {} };
const moduleCDN = moduleNameToCDNUrl(moduleName, moduleVersion);
conf.paths[moduleName] = moduleCDN;
require.config(conf);
return requirePromise([moduleCDN]);
}
}
Loading