CVE-2021-32798: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: The Jupyter notebook is a web-based notebook environment for interactive computing. In affected versions untrusted notebook can execute code on load. Jupyter Notebook uses a deprecated version of Google Caja to sanitize user inputs. A public Caja bypass can be used to trigger an XSS when a victim opens a malicious ipynb document in Jupyter Notebook. The XSS allows an attacker to execute arbitrary code on the victim computer using Jupyter APIs.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 9.6
Published: 2021-08-09T21:15Z
Repository: https://github.com/jupyter/notebook
Language: JavaScript
Files changed: bower.json, notebook/static/base/js/namespace.js, notebook/static/base/js/security.js, package.json, setupbase.py, webpack.config.js
Commit date: 2021-08-05 12:50:10 -0500
Commit message: Merge pull request from GHSA-hwvq-6gjx-j797

* sanitizer fix

* Pass sanitizer options explicitly
Diff: diff --git a/bower.json b/bower.json
index 5f72f6ff04..393db02ad1 100644
--- a/bower.json
+++ b/bower.json
@@ -7,11 +7,10 @@
     "bootstrap-tour": "0.9.0",
     "codemirror": "components/codemirror#5.56.0+components1",
     "create-react-class": "https://cdn.jsdelivr.net/npm/create-react-class@15.6.3/create-react-class.min.js",
     "es6-promise": "~1.0",
     "font-awesome": "components/font-awesome#~4.7.0",
-    "google-caja": "5669",
     "jed": "~1.1.1",
     "jquery": "components/jquery#~3.5.0",
     "jquery-typeahead": "~2.10.6",
     "jquery-ui": "components/jqueryui#~1.12",
     "marked": "~0.7",
diff --git a/notebook/static/base/js/namespace.js b/notebook/static/base/js/namespace.js
index 56b1b271cf..5eeb1ebd0d 100644
--- a/notebook/static/base/js/namespace.js
+++ b/notebook/static/base/js/namespace.js
@@ -71,11 +71,11 @@ define(function(){
     jglobal('MenuBar', 'notebook/js/menubar');
 
     // tree
     jglobal('SessionList','tree/js/sessionlist');
 
-    Jupyter.version = "6.4.0";
+    Jupyter.version = "6.5.0.dev0";
     Jupyter._target = '_blank';
 
     return Jupyter;
 });
 
diff --git a/notebook/static/base/js/security.js b/notebook/static/base/js/security.js
index 195c0a5cb0..7d01321ce8 100644
--- a/notebook/static/base/js/security.js
+++ b/notebook/static/base/js/security.js
@@ -1,128 +1,28 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
 define([
     'jquery',
-    'components/google-caja/html-css-sanitizer-minified',
-], function($, sanitize) {
+    'components/sanitizer/index',
+], function($, sanitizer) {
     "use strict";
-    
+
     var noop = function (x) { return x; };
-    
-    var caja;
-    if (window && window.html) {
-        caja = window.html;
-        caja.html4 = window.html4;
-        caja.sanitizeStylesheet = window.sanitizeStylesheet;
-    }
-    
-    var sanitizeAttribs = function (tagName, attribs, opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
-        /**
-         * add trusting data-attributes to the default sanitizeAttribs from caja
-         * this function is mostly copied from the caja source
-         */
-        var ATTRIBS = caja.html4.ATTRIBS;
-        for (var i = 0; i < attribs.length; i += 2) {
-            var attribName = attribs[i];
-            if (attribName.substr(0,5) == 'data-') {
-                var attribKey = '*::' + attribName;
-                if (!ATTRIBS.hasOwnProperty(attribKey)) {
-                    ATTRIBS[attribKey] = 0;
-                }
-            }
-        }
-        // Caja doesn't allow data uri for img::src, see
-        // https://github.com/google/caja/issues/1558
-        // This is not a security issue for browser post ie6 though, so we
-        // disable the check
-        // https://www.owasp.org/index.php/Script_in_IMG_tags
-        ATTRIBS['img::src'] = 0;
-        return caja.sanitizeAttribs(tagName, attribs, opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger);
-    };
-    
-    var sanitize_css = function (css, tagPolicy) {
-        /**
-         * sanitize CSS
-         * like sanitize_html, but for CSS
-         * called by sanitize_stylesheets
-         */
-        return caja.sanitizeStylesheet(
-            window.location.pathname,
-            css,
-            {
-                containerClass: null,
-                idSuffix: '',
-                tagPolicy: tagPolicy,
-                virtualizeAttrName: noop
-            },
-            noop
-        );
-    };
-    
-    var sanitize_stylesheets = function (html, tagPolicy) {
-        /**
-         * sanitize just the css in style tags in a block of html
-         * called by sanitize_html, if allow_css is true
-         */
-        var h = $("<div/>").append(html);
-        var style_tags = h.find("style");
-        if (!style_tags.length) {
-            // no style tags to sanitize
-            return html;
-        }
-        style_tags.each(function(i, style) {
-            style.innerHTML = sanitize_css(style.innerHTML, tagPolicy);
-        });
-        return h.html();
-    };
-    
+    var defaultSanitizer = sanitizer.defaultSanitizer;
+
     var sanitize_html = function (html, allow_css) {
         /**
          * sanitize HTML
          * if allow_css is true (default: false), CSS is sanitized as well.
          * otherwise, CSS elements and attributes are simply removed.
          */
-        var html4 = caja.html4;
-
-        if (allow_css) {
-            // allow sanitization of style tags,
-            // not just scrubbing
-            html4.ELEMENTS.style &= ~html4.eflags.UNSAFE;
-            html4.ATTRIBS.style = html4.atype.STYLE;
-        } else {
-            // scrub all CSS
-            html4.ELEMENTS.style |= html4.eflags.UNSAFE;
-            html4.ATTRIBS.style = html4.atype.SCRIPT;
-        }
-        
-        var record_messages = function (msg, opts) {
-            console.log("HTML Sanitizer", msg, opts);
-        };
-        
-        var policy = function (tagName, attribs) {
-            if (!(html4.ELEMENTS[tagName] & html4.eflags.UNSAFE)) {
-                return {
-                    'attribs': sanitizeAttribs(tagName, attribs,
-                        noop, noop, record_messages)
-                    };
-            } else {
-                record_messages(tagName + " removed", {
-                  change: "removed",
-                  tagName: tagName
-                });
-            }
-        };
-        
-        var sanitized = caja.sanitizeWithPolicy(html, policy);
-        
-        if (allow_css) {
-            // sanitize style tags as stylesheets
-            sanitized = sanitize_stylesheets(sanitized, policy);
-        }
-        
-        return sanitized;
+         const options = {};
+         if (!allow_css) {
+             options.allowedStyles = {};
+         }
+        return defaultSanitizer.sanitize(html, options);
     };
 
     var sanitize_html_and_parse = function (html, allow_css) {
         /**
          * Sanitize HTML and parse it safely using jQuery.
@@ -139,13 +39,12 @@ define([
             return $.parseHTML(sanitized_html);
         } finally {
             $.htmlPrefilter = prev_htmlPrefilter;  // Set it back again
         }
     };
-    
+
     var security = {
-        caja: caja,
         sanitize_html_and_parse: sanitize_html_and_parse,
         sanitize_html: sanitize_html
     };
 
     return security;
diff --git a/package.json b/package.json
index 52be970aeb..6da0a80294 100644
--- a/package.json
+++ b/package.json
@@ -10,16 +10,20 @@
     "url": "https://github.com/jupyter/notebook.git"
   },
   "scripts": {
     "bower": "bower install",
     "build": "python setup.py js css",
+    "build:webpack": "webpack --mode development",
     "build:watch": "npm run watch",
     "watch": "onchange 'notebook/static/**/!(*.min).js' 'notebook/static/**/*.less' 'bower.json' -- npm run build"
   },
   "devDependencies": {
+    "@jupyterlab/apputils": "^3.1.3",
     "bower": "^1.8.8",
     "less": "~2",
     "onchange": "^6.0.0",
     "po2json": "^0.4.5",
-    "requirejs": "^2.3.6"
+    "requirejs": "^2.3.6",
+    "webpack": "^5.46.0",
+    "webpack-cli": "^4.7.2"
   }
 }
diff --git a/setupbase.py b/setupbase.py
index fb3327da30..8849a85e3b 100644
--- a/setupbase.py
+++ b/setupbase.py
@@ -135,11 +135,10 @@ def find_package_data():
         pjoin(components, "bootstrap-tour", "build", "js", "bootstrap-tour.min.js"),
         pjoin(components, "create-react-class", "index.js"),
         pjoin(components, "font-awesome", "css", "*.css"),
         pjoin(components, "es6-promise", "*.js"),
         pjoin(components, "font-awesome", "fonts", "*.*"),
-        pjoin(components, "google-caja", "html-css-sanitizer-minified.js"),
         pjoin(components, "jed", "jed.js"),
         pjoin(components, "jquery", "jquery.min.js"),
         pjoin(components, "jquery-typeahead", "dist", "jquery.typeahead.min.js"),
         pjoin(components, "jquery-typeahead", "dist", "jquery.typeahead.min.css"),
         pjoin(components, "jquery-ui", "jquery-ui.min.js"),
@@ -149,10 +148,11 @@ def find_package_data():
         pjoin(components, "react", "react.production.min.js"),
         pjoin(components, "react", "react-dom.production.min.js"),
         pjoin(components, "requirejs", "require.js"),
         pjoin(components, "requirejs-plugins", "src", "json.js"),
         pjoin(components, "requirejs-text", "text.js"),
+        pjoin(components, "sanitizer", "index.js"),
         pjoin(components, "underscore", "underscore-min.js"),
         pjoin(components, "moment", "moment.js"),
         pjoin(components, "moment", "min", "*.js"),
         pjoin(components, "xterm.js", "index.js"),
         pjoin(components, "xterm.js-css", "index.css"),
@@ -372,18 +372,25 @@ class Bower(Command):
     def finalize_options(self):
         self.force = bool(self.force)
     
     bower_dir = pjoin(static, 'components')
     node_modules = pjoin(repo_root, 'node_modules')
+    sanitizer_dir = pjoin(bower_dir, 'sanitizer')
     
     def should_run(self):
         if self.force:
             return True
         if not os.path.exists(self.bower_dir):
             return True
-        
-        return mtime(self.bower_dir) < mtime(pjoin(repo_root, 'bower.json'))
+        if not os.path.exists(self.sanitizer_dir):
+            return True
+
+        bower_stale = mtime(self.bower_dir) < mtime(pjoin(repo_root, 'bower.json'))
+        if bower_stale:
+            return True
+
+        return mtime(self.sanitizer_dir) < mtime(pjoin(repo_root, 'webpack.config.js'))
 
     def should_run_npm(self):
         if not which('npm'):
             print("npm unavailable", file=sys.stderr)
             return False
@@ -413,10 +420,12 @@ class Bower(Command):
         except OSError as e:
             print("Failed to run bower: %s" % e, file=sys.stderr)
             print("You can install js dependencies with `npm install`", file=sys.stderr)
             raise
         # self.npm_components()
+        if not os.path.exists(self.sanitizer_dir):
+            run(['npm', 'run', 'build:webpack'], cwd=repo_root, env=env)
         os.utime(self.bower_dir, None)
         # update package data in case this created new files
         update_package_data(self.distribution)
 
 
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000000..eb440b77d2
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,10 @@
+const path = require('path');
+
+module.exports = {
+  entry: '@jupyterlab/apputils/lib/sanitizer',
+  output: {
+    filename: 'index.js',
+    path: path.resolve(__dirname, 'notebook/static/components/sanitizer'),
+    libraryTarget: "amd"
+  }
+}

Vulnerable code:     "google-caja": "5669",
    Jupyter.version = "6.4.0";
    'components/google-caja/html-css-sanitizer-minified',
], function($, sanitize) {
    
    
    var caja;
    if (window && window.html) {
        caja = window.html;
        caja.html4 = window.html4;
        caja.sanitizeStylesheet = window.sanitizeStylesheet;
    }
    
    var sanitizeAttribs = function (tagName, attribs, opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
        /**
         * add trusting data-attributes to the default sanitizeAttribs from caja
         * this function is mostly copied from the caja source
         */
        var ATTRIBS = caja.html4.ATTRIBS;
        for (var i = 0; i < attribs.length; i += 2) {
            var attribName = attribs[i];
            if (attribName.substr(0,5) == 'data-') {
                var attribKey = '*::' + attribName;
                if (!ATTRIBS.hasOwnProperty(attribKey)) {
                    ATTRIBS[attribKey] = 0;
                }
            }
        }
        // Caja doesn't allow data uri for img::src, see
        // https://github.com/google/caja/issues/1558
        // This is not a security issue for browser post ie6 though, so we
        // disable the check
        // https://www.owasp.org/index.php/Script_in_IMG_tags
        ATTRIBS['img::src'] = 0;
        return caja.sanitizeAttribs(tagName, attribs, opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger);
    };
    
    var sanitize_css = function (css, tagPolicy) {
        /**
         * sanitize CSS
         * like sanitize_html, but for CSS
         * called by sanitize_stylesheets
         */
        return caja.sanitizeStylesheet(
            window.location.pathname,
            css,
            {
                containerClass: null,
                idSuffix: '',
                tagPolicy: tagPolicy,
                virtualizeAttrName: noop
            },
            noop
        );
    };
    
    var sanitize_stylesheets = function (html, tagPolicy) {
        /**
         * sanitize just the css in style tags in a block of html
         * called by sanitize_html, if allow_css is true
         */
        var h = $("<div/>").append(html);
        var style_tags = h.find("style");
        if (!style_tags.length) {
            // no style tags to sanitize
            return html;
        }
        style_tags.each(function(i, style) {
            style.innerHTML = sanitize_css(style.innerHTML, tagPolicy);
        });
        return h.html();
    };
    
        var html4 = caja.html4;

        if (allow_css) {
            // allow sanitization of style tags,
            // not just scrubbing
            html4.ELEMENTS.style &= ~html4.eflags.UNSAFE;
            html4.ATTRIBS.style = html4.atype.STYLE;
        } else {
            // scrub all CSS
            html4.ELEMENTS.style |= html4.eflags.UNSAFE;
            html4.ATTRIBS.style = html4.atype.SCRIPT;
        }
        
        var record_messages = function (msg, opts) {
            console.log("HTML Sanitizer", msg, opts);
        };
        
        var policy = function (tagName, attribs) {
            if (!(html4.ELEMENTS[tagName] & html4.eflags.UNSAFE)) {
                return {
                    'attribs': sanitizeAttribs(tagName, attribs,
                        noop, noop, record_messages)
                    };
            } else {
                record_messages(tagName + " removed", {
                  change: "removed",
                  tagName: tagName
                });
            }
        };
        
        var sanitized = caja.sanitizeWithPolicy(html, policy);
        
        if (allow_css) {
            // sanitize style tags as stylesheets
            sanitized = sanitize_stylesheets(sanitized, policy);
        }
        
        return sanitized;
    
        caja: caja,
    "requirejs": "^2.3.6"
        pjoin(components, "google-caja", "html-css-sanitizer-minified.js"),
        
        return mtime(self.bower_dir) < mtime(pjoin(repo_root, 'bower.json'))
Fixed code:     Jupyter.version = "6.5.0.dev0";
    'components/sanitizer/index',
], function($, sanitizer) {

    var defaultSanitizer = sanitizer.defaultSanitizer;

         const options = {};
         if (!allow_css) {
             options.allowedStyles = {};
         }
        return defaultSanitizer.sanitize(html, options);

    "build:webpack": "webpack --mode development",
    "@jupyterlab/apputils": "^3.1.3",
    "requirejs": "^2.3.6",
    "webpack": "^5.46.0",
    "webpack-cli": "^4.7.2"
        pjoin(components, "sanitizer", "index.js"),
    sanitizer_dir = pjoin(bower_dir, 'sanitizer')
        if not os.path.exists(self.sanitizer_dir):
            return True

        bower_stale = mtime(self.bower_dir) < mtime(pjoin(repo_root, 'bower.json'))
        if bower_stale:
            return True

        return mtime(self.sanitizer_dir) < mtime(pjoin(repo_root, 'webpack.config.js'))
        if not os.path.exists(self.sanitizer_dir):
            run(['npm', 'run', 'build:webpack'], cwd=repo_root, env=env)
const path = require('path');

module.exports = {
  entry: '@jupyterlab/apputils/lib/sanitizer',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'notebook/static/components/sanitizer'),
    libraryTarget: "amd"
  }
}