]> source.dussan.org Git - jquery.git/commitdiff
Core:Manipulation: Add basic TrustedHTML support
authorMichał Gołębiowski-Owczarek <m.goleb@gmail.com>
Thu, 30 Sep 2021 14:00:24 +0000 (16:00 +0200)
committerGitHub <noreply@github.com>
Thu, 30 Sep 2021 14:00:24 +0000 (16:00 +0200)
This ensures HTML wrapped in TrustedHTML can be used as an input to jQuery
manipulation methods in a way that doesn't violate the
`require-trusted-types-for` Content Security Policy directive.
This commit builds on previous work needed for trusted types support, including
gh-4642 and gh-4724.

One restriction is that while any TrustedHTML wrapper should work as input
for jQuery methods like `.html()` or `.append()`, for passing directly to the
`jQuery` factory the string must start with `<` and end with `>`; no trailing
or leading whitespaces are allowed. This is necessary as we cannot parse out
a part of the input for further construction; that would violate the CSP rule -
and that's what's done to HTML input not matching these constraints.

No trusted types API is used explicitly in source; the majority of the work is
ensuring we don't pass the input converted to string to APIs that would
eventually assign it to `innerHTML`. This extra cautiousness is caused by the
API being Blink-only, at least for now.

The ban on passing strings to `innerHTML` means support tests relying on such
assignments are impossible. We don't currently have such tests on the `main`
branch but we used to have many of them in the 3.x & older lines. If there's
a need to re-add such a test, we'll need an escape hatch to skip them for apps
needing CSP-enforced TrustedHTML.

See https://web.dev/trusted-types/ for more information about TrustedHTML.

Fixes gh-4409
Closes gh-4927
Ref gh-4642
Ref gh-4724

12 files changed:
src/core.js
src/core/init.js
src/core/isArrayLike.js [new file with mode: 0644]
src/core/isObviousHtml.js [new file with mode: 0644]
src/core/parseHTML.js
src/manipulation/buildFragment.js
test/.eslintrc.json
test/data/csp.include.html
test/data/mock.php
test/data/trusted-html.html [new file with mode: 0644]
test/middleware-mockserver.js
test/unit/manipulation.js

index 31d749dd1f150fec6c726ce791dc48d1993bf7bf..4f5731ae1b90656f1655449deb0b4fdd4b84d4ca 100644 (file)
@@ -10,9 +10,8 @@ import hasOwn from "./var/hasOwn.js";
 import fnToString from "./var/fnToString.js";
 import ObjectFunctionString from "./var/ObjectFunctionString.js";
 import support from "./var/support.js";
-import isWindow from "./var/isWindow.js";
+import isArrayLike from "./core/isArrayLike.js";
 import DOMEval from "./core/DOMEval.js";
-import toType from "./core/toType.js";
 
 var version = "@VERSION",
 
@@ -398,17 +397,4 @@ jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symb
                class2type[ "[object " + name + "]" ] = name.toLowerCase();
        } );
 
-function isArrayLike( obj ) {
-
-       var length = !!obj && obj.length,
-               type = toType( obj );
-
-       if ( typeof obj === "function" || isWindow( obj ) ) {
-               return false;
-       }
-
-       return type === "array" || length === 0 ||
-               typeof length === "number" && length > 0 && ( length - 1 ) in obj;
-}
-
 export default jQuery;
index a97fc1060914c64ea12382d96b5dce0f279fe4e1..8fc24d8dd6b54aae34e626dfe72d5a7ebdffe6fc 100644 (file)
@@ -2,6 +2,7 @@
 import jQuery from "../core.js";
 import document from "../var/document.js";
 import rsingleTag from "./var/rsingleTag.js";
+import isObviousHtml from "./isObviousHtml.js";
 
 import "../traversing/findFilter.js";
 
@@ -26,20 +27,41 @@ var rootjQuery,
                // so migrate can support jQuery.sub (gh-2101)
                root = root || rootjQuery;
 
-               // Handle HTML strings
-               if ( typeof selector === "string" ) {
-                       if ( selector[ 0 ] === "<" &&
-                               selector[ selector.length - 1 ] === ">" &&
-                               selector.length >= 3 ) {
+               // HANDLE: $(DOMElement)
+               if ( selector.nodeType ) {
+                       this[ 0 ] = selector;
+                       this.length = 1;
+                       return this;
+
+               // HANDLE: $(function)
+               // Shortcut for document ready
+               } else if ( typeof selector === "function" ) {
+                       return root.ready !== undefined ?
+                               root.ready( selector ) :
+
+                               // Execute immediately if ready is not present
+                               selector( jQuery );
+
+               } else {
 
-                               // Assume that strings that start and end with <> are HTML and skip the regex check
+                       // Handle obvious HTML strings
+                       match = selector + "";
+                       if ( isObviousHtml( match ) ) {
+
+                               // Assume that strings that start and end with <> are HTML and skip
+                               // the regex check. This also handles browser-supported HTML wrappers
+                               // like TrustedHTML.
                                match = [ null, selector, null ];
 
-                       } else {
+                       // Handle HTML strings or selectors
+                       } else if ( typeof selector === "string" ) {
                                match = rquickExpr.exec( selector );
+                       } else {
+                               return jQuery.makeArray( selector, this );
                        }
 
                        // Match html or make sure no context is specified for #id
+                       // Note: match[1] may be a string or a TrustedHTML wrapper
                        if ( match && ( match[ 1 ] || !context ) ) {
 
                                // HANDLE: $(html) -> $(array)
@@ -84,7 +106,7 @@ var rootjQuery,
                                        return this;
                                }
 
-                       // HANDLE: $(expr, $(...))
+                       // HANDLE: $(expr) & $(expr, $(...))
                        } else if ( !context || context.jquery ) {
                                return ( context || root ).find( selector );
 
@@ -93,24 +115,8 @@ var rootjQuery,
                        } else {
                                return this.constructor( context ).find( selector );
                        }
-
-               // HANDLE: $(DOMElement)
-               } else if ( selector.nodeType ) {
-                       this[ 0 ] = selector;
-                       this.length = 1;
-                       return this;
-
-               // HANDLE: $(function)
-               // Shortcut for document ready
-               } else if ( typeof selector === "function" ) {
-                       return root.ready !== undefined ?
-                               root.ready( selector ) :
-
-                               // Execute immediately if ready is not present
-                               selector( jQuery );
                }
 
-               return jQuery.makeArray( selector, this );
        };
 
 // Give the init function the jQuery prototype for later instantiation
diff --git a/src/core/isArrayLike.js b/src/core/isArrayLike.js
new file mode 100644 (file)
index 0000000..988c483
--- /dev/null
@@ -0,0 +1,17 @@
+import toType from "./toType.js";
+import isWindow from "../var/isWindow.js";
+
+function isArrayLike( obj ) {
+
+       var length = !!obj && obj.length,
+               type = toType( obj );
+
+       if ( typeof obj === "function" || isWindow( obj ) ) {
+               return false;
+       }
+
+       return type === "array" || length === 0 ||
+               typeof length === "number" && length > 0 && ( length - 1 ) in obj;
+}
+
+export default isArrayLike;
diff --git a/src/core/isObviousHtml.js b/src/core/isObviousHtml.js
new file mode 100644 (file)
index 0000000..976f812
--- /dev/null
@@ -0,0 +1,7 @@
+function isObviousHtml( input ) {
+       return input[ 0 ] === "<" &&
+               input[ input.length - 1 ] === ">" &&
+               input.length >= 3;
+}
+
+export default isObviousHtml;
index 15278fa0242ab5eb06c2ddf4e61cb227fb95524e..b522a5f7be4484f111d1c1a970aa20cb021bc031 100644 (file)
@@ -2,13 +2,14 @@ import jQuery from "../core.js";
 import document from "../var/document.js";
 import rsingleTag from "./var/rsingleTag.js";
 import buildFragment from "../manipulation/buildFragment.js";
+import isObviousHtml from "./isObviousHtml.js";
 
-// Argument "data" should be string of html
+// Argument "data" should be string of html or a TrustedHTML wrapper of obvious HTML
 // context (optional): If specified, the fragment will be created in this context,
 // defaults to document
 // keepScripts (optional): If true, will include scripts passed in the html string
 jQuery.parseHTML = function( data, context, keepScripts ) {
-       if ( typeof data !== "string" ) {
+       if ( typeof data !== "string" && !isObviousHtml( data + "" ) ) {
                return [];
        }
        if ( typeof context === "boolean" ) {
index 9ac71acc932e26b1ec41c0493f302c18dc946110..d6f8e57832ac4079eefd2ffb344465eede527d23 100644 (file)
@@ -7,6 +7,7 @@ import rscriptType from "./var/rscriptType.js";
 import wrapMap from "./wrapMap.js";
 import getAll from "./getAll.js";
 import setGlobalEval from "./setGlobalEval.js";
+import isArrayLike from "../core/isArrayLike.js";
 
 var rhtml = /<|&#?\w+;/;
 
@@ -23,7 +24,7 @@ function buildFragment( elems, context, scripts, selection, ignored ) {
                if ( elem || elem === 0 ) {
 
                        // Add nodes directly
-                       if ( toType( elem ) === "object" ) {
+                       if ( toType( elem ) === "object" && ( elem.nodeType || isArrayLike( elem ) ) ) {
                                jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );
 
                        // Convert non-html into a text node
index f52842efa3a1ea61631bf1a898ed2b5a796785f4..a5509180d1758e2c8413defe7b35503cb4528b01 100644 (file)
@@ -14,6 +14,7 @@
                "require": false,
                "Promise": false,
                "Symbol": false,
+               "trustedTypes": false,
                "QUnit": false,
                "ajaxTest": false,
                "testIframe": false,
index 17e2ef0d85216c70415bdbbe21f125e270b980dc..59b87f212ab708c8e2d3790c941c1eda31cfc8a6 100644 (file)
@@ -3,7 +3,7 @@
 <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <title>CSP Test Page</title>
-       <script src="../jquery.js"></script>
+       <script src="../../dist/jquery.min.js"></script>
        <script src="iframeTest.js"></script>
        <script src="support/csp.js"></script>
        <script src="support/getComputedSupport.js"></script>
index d0ed6f2c1415ccd772423c5f72eb00d7cca10972..268ad06efefdc46b42f6eb1de48786663da088e5 100644 (file)
@@ -215,7 +215,7 @@ QUnit.assert.ok( true, "mock executed");';
        }
 
        protected function cspFrame( $req ) {
-               header( "Content-Security-Policy: default-src 'self'; report-uri ./mock.php?action=cspLog" );
+               header( "Content-Security-Policy: default-src 'self'; require-trusted-types-for 'script'; report-uri ./mock.php?action=cspLog" );
                header( 'Content-type: text/html' );
                echo file_get_contents( __DIR__ . '/csp.include.html' );
        }
@@ -228,7 +228,7 @@ QUnit.assert.ok( true, "mock executed");';
        }
 
        protected function cspAjaxScript( $req ) {
-               header( "Content-Security-Policy: script-src 'self'; report-uri /base/test/data/mock.php?action=cspLog" );
+               header( "Content-Security-Policy: script-src 'self'; report-uri ./mock.php?action=cspLog" );
                header( 'Content-type: text/html' );
                echo file_get_contents( __DIR__ . '/csp-ajax-script.html' );
        }
@@ -241,6 +241,12 @@ QUnit.assert.ok( true, "mock executed");';
                file_put_contents( $this->cspFile, '' );
        }
 
+       protected function trustedHtml( $req ) {
+               header( "Content-Security-Policy: require-trusted-types-for 'script'; report-uri ./mock.php?action=cspLog" );
+               header( 'Content-type: text/html' );
+               echo file_get_contents( __DIR__ . '/trusted-html.html' );
+       }
+
        protected function errorWithScript( $req ) {
                header( 'HTTP/1.0 404 Not Found' );
                if ( isset( $req->query['withScriptContentType'] ) ) {
diff --git a/test/data/trusted-html.html b/test/data/trusted-html.html
new file mode 100644 (file)
index 0000000..063779a
--- /dev/null
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html>
+<head>
+       <meta charset=utf-8 />
+       <title>body</title>
+</head>
+<body>
+<div id="qunit-fixture"></div>
+<script src="../../dist/jquery.min.js"></script>
+<script src="iframeTest.js"></script>
+<script>
+       var i, input, elem, tags, policy,
+               results = [],
+               inputs = [
+                       [ "<div></div>", "<div class='test'></div>", [ "div" ] ],
+                       [ "<div></div>", "<div class='test'></div><span class='test'></span>",
+                               [ "div", "span" ] ],
+                       [ "<table></table>", "<td class='test'></td>", [ "td" ] ],
+                       [ "<select></select>", "<option class='test'></option>", [ "option" ] ]
+               ];
+
+       function runTests( messagePrefix, getHtmlWrapper ) {
+               for ( i = 0; i < inputs.length; i++ ) {
+                       input = inputs[ i ];
+                       elem = jQuery( getHtmlWrapper( input[ 0 ] ) );
+                       elem.append( getHtmlWrapper( input[ 1 ] ) );
+                       tags = elem.find( ".test" ).toArray().map( function( node ) {
+                               return node.nodeName.toLowerCase();
+                       } );
+                       results.push( {
+                               actual: tags,
+                               expected: input[ 2 ],
+                               message: messagePrefix + ": " + input[ 2 ].join( ", " )
+                       } );
+               }
+
+               elem = jQuery( getHtmlWrapper( "<div></div>" ) );
+               elem.append( getHtmlWrapper( "text content" ) );
+               results.push( {
+                       actual: elem.html(),
+                       expected: "text content",
+                       message: messagePrefix + ": text content properly appended"
+               } );
+       }
+
+       if ( typeof trustedTypes !== "undefined" ) {
+               policy = trustedTypes.createPolicy( "jquery-test-policy", {
+                       createHTML: function( html ) {
+                               return html;
+                       }
+               } );
+
+               runTests( "TrustedHTML", function wrapInTrustedHtml( input ) {
+                       return policy.createHTML( input );
+               } );
+       } else {
+
+               // No TrustedHTML support so let's at least run tests with object wrappers
+               // with a proper `toString` function. This also shows that jQuery support
+               // of TrustedHTML is generic and would work with similar APIs out of the box
+               // as well. Ideally, we'd run these tests in browsers with TrustedHTML support
+               // as well but due to the CSP TrustedHTML enforcement these tests would fail.
+               runTests( "Object wrapper", function( input ) {
+                       return {
+                               toString: function toString() {
+                                       return input;
+                               }
+                       };
+               } );
+       }
+
+       startIframeTest( results );
+</script>
+</body>
+</html>
index 04b01d652ca28247279e0aac3f2ab924bbcd8462..0bd44f95b7d4f5285cd62c1021aecc3aeb55a401 100644 (file)
@@ -222,7 +222,7 @@ var mocks = {
        cspFrame: function( req, resp ) {
                resp.writeHead( 200, {
                        "Content-Type": "text/html",
-                       "Content-Security-Policy": "default-src 'self'; report-uri /base/test/data/mock.php?action=cspLog"
+                       "Content-Security-Policy": "default-src 'self'; require-trusted-types-for 'script'; report-uri /base/test/data/mock.php?action=cspLog"
                } );
                var body = fs.readFileSync( __dirname + "/data/csp.include.html" ).toString();
                resp.end( body );
@@ -256,6 +256,14 @@ var mocks = {
                resp.writeHead( 200 );
                resp.end();
        },
+       trustedHtml: function( req, resp ) {
+               resp.writeHead( 200, {
+                       "Content-Type": "text/html",
+                       "Content-Security-Policy": "require-trusted-types-for 'script'; report-uri /base/test/data/mock.php?action=cspLog"
+               } );
+               var body = fs.readFileSync( __dirname + "/data/trusted-html.html" ).toString();
+               resp.end( body );
+       },
        errorWithScript: function( req, resp ) {
                if ( req.query.withScriptContentType ) {
                        resp.writeHead( 404, { "Content-Type": "application/javascript" } );
index 8262516a9ef8cc9a2a1a36dfff44a5b3b13b8a66..30bf169ac46377a62dbd33195eff541897ee9873 100644 (file)
@@ -3008,3 +3008,19 @@ QUnit.test( "Works with invalid attempts to close the table wrapper", function(
        assert.strictEqual( elem[ 0 ].nodeName.toLowerCase(), "td", "First element is td" );
        assert.strictEqual( elem[ 1 ].nodeName.toLowerCase(), "td", "Second element is td" );
 } );
+
+// Test trustedTypes support in browsers where they're supported (currently Chrome 83+).
+// Browsers with no TrustedHTML support still run tests on object wrappers with
+// a proper `toString` function.
+testIframe(
+       "Basic TrustedHTML support (gh-4409)",
+       "mock.php?action=trustedHtml",
+       function( assert, jQuery, window, document, test ) {
+
+               assert.expect( 5 );
+
+               test.forEach( function( result ) {
+                       assert.deepEqual( result.actual, result.expected, result.message );
+               } );
+       }
+);