aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichał Gołębiowski-Owczarek <m.goleb@gmail.com>2021-09-30 16:00:24 +0200
committerGitHub <noreply@github.com>2021-09-30 16:00:24 +0200
commitde5398a6ad088dc006b46c6a870a2a053f4cd663 (patch)
tree8141154c5f8f2bdc9fb92cf3ad58befa07a9a0dc
parent1019074f7b1df96ee9d6409ada3dc0562046f6c7 (diff)
downloadjquery-de5398a6ad088dc006b46c6a870a2a053f4cd663.tar.gz
jquery-de5398a6ad088dc006b46c6a870a2a053f4cd663.zip
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
-rw-r--r--src/core.js16
-rw-r--r--src/core/init.js54
-rw-r--r--src/core/isArrayLike.js17
-rw-r--r--src/core/isObviousHtml.js7
-rw-r--r--src/core/parseHTML.js5
-rw-r--r--src/manipulation/buildFragment.js3
-rw-r--r--test/.eslintrc.json1
-rw-r--r--test/data/csp.include.html2
-rw-r--r--test/data/mock.php10
-rw-r--r--test/data/trusted-html.html75
-rw-r--r--test/middleware-mockserver.js10
-rw-r--r--test/unit/manipulation.js16
12 files changed, 170 insertions, 46 deletions
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;
+
+ // 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
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 @@
<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>
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 @@
+<!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>
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 );
+ } );
+ }
+);