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

Backport #13341 on branch 3.6.x (Add user configuration for additional schemes for the sanitizer plugin) #13419

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/apputils-extension/schema/sanitizer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"title": "HTML Sanitizer",
"description": "HTML Sanitizer settings.",
"jupyter.lab.setting-icon": "ui-components:html5",
"additionalProperties": false,
"properties": {
"allowedSchemes": {
"title": "Allowed URL Scheme",
"description": "Scheme allowed by the HTML sanitizer.",
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
},
"default": ["http", "https", "ftp", "mailto", "tel"]
}
},
"type": "object"
}
33 changes: 30 additions & 3 deletions packages/apputils-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import {
defaultSanitizer,
Dialog,
ICommandPalette,
ISanitizer,
Expand All @@ -23,6 +22,7 @@ import {
IWindowResolver,
MainAreaWidget,
Printing,
Sanitizer,
sessionContextDialogs,
WindowResolver
} from '@jupyterlab/apputils';
Expand Down Expand Up @@ -609,8 +609,35 @@ const sanitizer: JupyterFrontEndPlugin<ISanitizer> = {
id: '@jupyter/apputils-extension:sanitizer',
autoStart: true,
provides: ISanitizer,
activate: () => {
return defaultSanitizer;
requires: [ISettingRegistry],
activate: (app: JupyterFrontEnd, settings: ISettingRegistry): ISanitizer => {
const sanitizer = new Sanitizer();
const loadSetting = (setting: ISettingRegistry.ISettings): void => {
const allowedSchemes = setting.get('allowedSchemes').composite as Array<
string
>;

if (allowedSchemes) {
sanitizer.setAllowedSchemes(allowedSchemes);
}
};

// Wait for the application to be restored and
// for the settings for this plugin to be loaded
settings
.load('@jupyterlab/apputils-extension:sanitizer')
.then(setting => {
// Read the settings
loadSetting(setting);

// Listen for your plugin setting changes using Signal
setting.changed.connect(loadSetting);
})
.catch(reason => {
console.error(`Failed to load sanitizer settings:`, reason);
});

return sanitizer;
}
};

Expand Down
14 changes: 14 additions & 0 deletions packages/apputils/src/sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,16 @@ export class Sanitizer implements ISanitizer {
return sanitize(dirty, { ...this._options, ...(options || {}) });
}

/**
* Set the allowed schemes
*
* @param scheme Allowed schemes
*/
setAllowedSchemes(scheme: Array<string>): void {
// Force copy of `scheme`
this._options.allowedSchemes = [...scheme];
}

private _options: sanitize.IOptions = {
// HTML tags that are allowed to be used. Tags were extracted from Google Caja
allowedTags: [
Expand Down Expand Up @@ -945,6 +955,7 @@ export class Sanitizer implements ISanitizer {
// Set the "disabled" attribute for <input> tags.
input: sanitize.simpleTransform('input', { disabled: 'disabled' })
},
allowedSchemes: [...sanitize.defaults.allowedSchemes],
allowedSchemesByTag: {
// Allow 'attachment:' img src (used for markdown cell attachments).
img: sanitize.defaults.allowedSchemes.concat(['attachment'])
Expand All @@ -959,5 +970,8 @@ export class Sanitizer implements ISanitizer {

/**
* The default instance of an `ISanitizer` meant for use by user code.
*
* @deprecated It will be removed in JupyterLab v4. You should request the `ISanitizer` in
* your plugin instead.
*/
export const defaultSanitizer: ISanitizer = new Sanitizer();
71 changes: 34 additions & 37 deletions packages/apputils/test/sanitizer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,106 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { defaultSanitizer } from '@jupyterlab/apputils';
import { Sanitizer } from '@jupyterlab/apputils';
const sanitizer = new Sanitizer();

describe('defaultSanitizer', () => {
describe('sanitizer', () => {
describe('#sanitize()', () => {
it('should allow h1 tags', () => {
const h1 = '<h1>foo</h1>';
expect(defaultSanitizer.sanitize(h1)).toBe(h1);
expect(sanitizer.sanitize(h1)).toBe(h1);
});

it('should allow h2 tags', () => {
const h2 = '<h2>foo</h2>';
expect(defaultSanitizer.sanitize(h2)).toBe(h2);
expect(sanitizer.sanitize(h2)).toBe(h2);
});

it('should not allow svg tags', () => {
const svg = '<svg>foo</svg>';
expect(defaultSanitizer.sanitize(svg)).toBe('foo');
expect(sanitizer.sanitize(svg)).toBe('foo');
});

it('should allow img tags and some attributes', () => {
const img =
'<img src="smiley.gif" alt="Smiley face" height="42" width="42" />';
expect(defaultSanitizer.sanitize(img)).toBe(img);
expect(sanitizer.sanitize(img)).toBe(img);
});

it('should allow span tags and class attribute', () => {
const span = '<span class="foo">bar</span>';
expect(defaultSanitizer.sanitize(span)).toBe(span);
expect(sanitizer.sanitize(span)).toBe(span);
});

it('should set the rel attribute for <a> tags to "nofollow', () => {
const a = '<a rel="foo" href="bar">Baz</a>';
const expected = a.replace('foo', 'nofollow');
expect(defaultSanitizer.sanitize(a)).toBe(expected);
expect(sanitizer.sanitize(a)).toBe(expected);
});

it('should allow the `data-commandlinker-command` attribute for button tags', () => {
const button =
'<button data-commandlinker-command="terminal:create-new" onClick={some evil code}>Create Terminal</button>';
const expectedButton =
'<button data-commandlinker-command="terminal:create-new">Create Terminal</button>';
expect(defaultSanitizer.sanitize(button)).toBe(expectedButton);
expect(sanitizer.sanitize(button)).toBe(expectedButton);
});

it('should allow the class attribute for code tags', () => {
const code = '<code class="foo">bar</code>';
expect(defaultSanitizer.sanitize(code)).toBe(code);
expect(sanitizer.sanitize(code)).toBe(code);
});

it('should allow the class attribute for div tags', () => {
const div = '<div class="foo">bar</div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow the class attribute for p tags', () => {
const p = '<p class="foo">bar</p>';
expect(defaultSanitizer.sanitize(p)).toBe(p);
expect(sanitizer.sanitize(p)).toBe(p);
});

it('should allow the class attribute for pre tags', () => {
const pre = '<pre class="foo">bar</pre>';
expect(defaultSanitizer.sanitize(pre)).toBe(pre);
expect(sanitizer.sanitize(pre)).toBe(pre);
});

it('should strip script tags', () => {
const script = '<script>alert("foo")</script>';
expect(defaultSanitizer.sanitize(script)).toBe('');
expect(sanitizer.sanitize(script)).toBe('');
});

it('should strip iframe tags', () => {
const script = '<iframe src=""></iframe>';
expect(defaultSanitizer.sanitize(script)).toBe('');
expect(sanitizer.sanitize(script)).toBe('');
});

it('should strip link tags', () => {
const link = '<link rel="stylesheet" type="text/css" href="theme.css">';
expect(defaultSanitizer.sanitize(link)).toBe('');
expect(sanitizer.sanitize(link)).toBe('');
});

it('should pass through simple well-formed whitelisted markup', () => {
const div = '<div><p>Hello <b>there</b></p></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow video tags with some attributes', () => {
const video =
'<video src="my/video.mp4" height="42" width="42"' +
' autoplay controls loop muted></video>';
expect(defaultSanitizer.sanitize(video)).toBe(video);
expect(sanitizer.sanitize(video)).toBe(video);
});

it('should allow audio tags with some attributes', () => {
const audio =
'<audio src="my/audio.ogg autoplay loop ' + 'controls muted"></audio>';
expect(defaultSanitizer.sanitize(audio)).toBe(audio);
expect(sanitizer.sanitize(audio)).toBe(audio);
});

it('should allow input tags but disable them', () => {
const html = defaultSanitizer.sanitize(
'<input type="checkbox" checked />'
);
const html = sanitizer.sanitize('<input type="checkbox" checked />');
const div = document.createElement('div');
let input: HTMLInputElement;

Expand All @@ -115,75 +114,73 @@ describe('defaultSanitizer', () => {

it('should allow harmless inline CSS', () => {
const div = '<div style="color:green"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow abbreviated floats in CSS', () => {
const div = '<div style="color:rgba(255,0,0,.8)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow background CSS line-gradient with directional', () => {
const div =
'<div style="background:linear-gradient(to left top, blue, red)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow background CSS line-gradient with angle', () => {
const div =
'<div style="background:linear-gradient(0deg, blue, green 40%, red)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow fully specified background CSS line-gradient', () => {
const div =
'<div style="background:linear-gradient(red 0%, orange 10% 30%, yellow 50% 70%, green 90% 100%)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow simple background CSS radial-gradient', () => {
const div =
'<div style="background:radial-gradient(#e66465, #9198e5)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('should allow fully specified background CSS radial-gradient', () => {
const div =
'<div style="background:radial-gradient(ellipse farthest-corner at 90% 90%, red, yellow 10%, #1e90ff 50%, beige)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(div);
expect(sanitizer.sanitize(div)).toBe(div);
});

it('strip incorrect CSS line-gradient', () => {
const div =
'<div style="background:linear-gradient(http://example.com)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe('<div></div>');
expect(sanitizer.sanitize(div)).toBe('<div></div>');
});

it("should strip 'content' properties from inline CSS", () => {
const div = '<div style="color: green; content: attr(title)"></div>';
expect(defaultSanitizer.sanitize(div)).toBe(
'<div style="color:green"></div>'
);
expect(sanitizer.sanitize(div)).toBe('<div style="color:green"></div>');
});

it("should strip 'counter-increment' properties from inline CSS", () => {
const div = '<div style="counter-increment: example-counter;"></div>';
expect(defaultSanitizer.sanitize(div)).toBe('<div></div>');
expect(sanitizer.sanitize(div)).toBe('<div></div>');
});

it("should strip 'counter-reset' properties from inline CSS", () => {
const div = '<div style="counter-reset: chapter-count 0;"></div>';
expect(defaultSanitizer.sanitize(div)).toBe('<div></div>');
expect(sanitizer.sanitize(div)).toBe('<div></div>');
});

it("should strip 'widows' properties from inline CSS", () => {
const div = '<div style="widows: 2;"></div>';
expect(defaultSanitizer.sanitize(div)).toBe('<div></div>');
expect(sanitizer.sanitize(div)).toBe('<div></div>');
});

it("should strip 'orphans' properties from inline CSS", () => {
const div = '<div style="orphans: 3;"></div>';
expect(defaultSanitizer.sanitize(div)).toBe('<div></div>');
expect(sanitizer.sanitize(div)).toBe('<div></div>');
});
});
});
1 change: 1 addition & 0 deletions packages/completer-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
"dependencies": {
"@jupyterlab/application": "^3.6.0-alpha.3",
"@jupyterlab/apputils": "^3.6.0-alpha.3",
"@jupyterlab/completer": "^3.6.0-alpha.3",
"@jupyterlab/console": "^3.6.0-alpha.3",
"@jupyterlab/fileeditor": "^3.6.0-alpha.3",
Expand Down
18 changes: 15 additions & 3 deletions packages/completer-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { ISanitizer } from '@jupyterlab/apputils';
import {
Completer,
CompleterModel,
Expand Down Expand Up @@ -52,7 +53,11 @@ const manager: JupyterFrontEndPlugin<ICompletionManager> = {
id: '@jupyterlab/completer-extension:manager',
autoStart: true,
provides: ICompletionManager,
activate: (app: JupyterFrontEnd): ICompletionManager => {
optional: [ISanitizer],
activate: (
app: JupyterFrontEnd,
sanitizer: ISanitizer | null
): ICompletionManager => {
const handlers: { [id: string]: CompletionHandler } = {};

app.commands.addCommand(CommandIDs.invoke, {
Expand Down Expand Up @@ -86,11 +91,18 @@ const manager: JupyterFrontEndPlugin<ICompletionManager> = {
return {
register: (
completable: ICompletionManager.ICompletable,
renderer: Completer.IRenderer = Completer.defaultRenderer
renderer: Completer.IRenderer = Completer.getDefaultRenderer(
sanitizer ?? undefined
)
): ICompletionManager.ICompletableAttributes => {
const { connector, editor, parent } = completable;
const model = new CompleterModel();
const completer = new Completer({ editor, model, renderer });
const completer = new Completer({
editor,
model,
renderer,
sanitizer: sanitizer ?? undefined
});
const handler = new CompletionHandler({
completer,
connector
Expand Down
1 change: 1 addition & 0 deletions packages/completer-extension/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
@import url('~@lumino/widgets/style/index.css');
@import url('~@jupyterlab/apputils/style/index.css');
@import url('~@jupyterlab/application/style/index.css');
@import url('~@jupyterlab/completer/style/index.css');
@import url('~@jupyterlab/console/style/index.css');
Expand Down
1 change: 1 addition & 0 deletions packages/completer-extension/style/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
import '@lumino/widgets/style/index.js';
import '@jupyterlab/apputils/style/index.js';
import '@jupyterlab/application/style/index.js';
import '@jupyterlab/completer/style/index.js';
import '@jupyterlab/console/style/index.js';
Expand Down