From de5398a6ad088dc006b46c6a870a2a053f4cd663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Go=C5=82=C4=99biowski-Owczarek?= Date: Thu, 30 Sep 2021 16:00:24 +0200 Subject: [PATCH] Core:Manipulation: Add basic TrustedHTML support 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 --- src/core.js | 16 +------ src/core/init.js | 54 ++++++++++++---------- src/core/isArrayLike.js | 17 +++++++ src/core/isObviousHtml.js | 7 +++ src/core/parseHTML.js | 5 ++- src/manipulation/buildFragment.js | 3 +- test/.eslintrc.json | 1 + test/data/csp.include.html | 2 +- test/data/mock.php | 10 ++++- test/data/trusted-html.html | 75 +++++++++++++++++++++++++++++++ test/middleware-mockserver.js | 10 ++++- test/unit/manipulation.js | 16 +++++++ 12 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 src/core/isArrayLike.js create mode 100644 src/core/isObviousHtml.js create mode 100644 test/data/trusted-html.html diff --git a/src/core.js b/src/core.js index 31d749dd1..4f5731ae1 100644 --- a/src/core.js +++ b/src/core.js @@ -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; diff --git a/src/core/init.js b/src/core/init.js index a97fc1060..8fc24d8dd 100644 --- a/src/core/init.js +++ b/src/core/init.js @@ -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; - // Assume that strings that start and end with <> are HTML and skip the regex check + // 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 { + + // 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 index 000000000..988c483d3 --- /dev/null +++ b/src/core/isArrayLike.js @@ -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 index 000000000..976f81219 --- /dev/null +++ b/src/core/isObviousHtml.js @@ -0,0 +1,7 @@ +function isObviousHtml( input ) { + return input[ 0 ] === "<" && + input[ input.length - 1 ] === ">" && + input.length >= 3; +} + +export default isObviousHtml; diff --git a/src/core/parseHTML.js b/src/core/parseHTML.js index 15278fa02..b522a5f7b 100644 --- a/src/core/parseHTML.js +++ b/src/core/parseHTML.js @@ -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" ) { diff --git a/src/manipulation/buildFragment.js b/src/manipulation/buildFragment.js index 9ac71acc9..d6f8e5783 100644 --- a/src/manipulation/buildFragment.js +++ b/src/manipulation/buildFragment.js @@ -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 diff --git a/test/.eslintrc.json b/test/.eslintrc.json index f52842efa..a5509180d 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -14,6 +14,7 @@ "require": false, "Promise": false, "Symbol": false, + "trustedTypes": false, "QUnit": false, "ajaxTest": false, "testIframe": false, diff --git a/test/data/csp.include.html b/test/data/csp.include.html index 17e2ef0d8..59b87f212 100644 --- a/test/data/csp.include.html +++ b/test/data/csp.include.html @@ -3,7 +3,7 @@ CSP Test Page - + diff --git a/test/data/mock.php b/test/data/mock.php index d0ed6f2c1..268ad06ef 100644 --- a/test/data/mock.php +++ b/test/data/mock.php @@ -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 index 000000000..063779a62 --- /dev/null +++ b/test/data/trusted-html.html @@ -0,0 +1,75 @@ + + + + + body + + +
+ + + + + diff --git a/test/middleware-mockserver.js b/test/middleware-mockserver.js index 04b01d652..0bd44f95b 100644 --- a/test/middleware-mockserver.js +++ b/test/middleware-mockserver.js @@ -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" } ); diff --git a/test/unit/manipulation.js b/test/unit/manipulation.js index 8262516a9..30bf169ac 100644 --- a/test/unit/manipulation.js +++ b/test/unit/manipulation.js @@ -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 ); + } ); + } +);