mirror of
https://github.com/jquery/jquery.git
synced 2024-11-23 02:54:22 +00:00
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
This commit is contained in:
parent
1019074f7b
commit
de5398a6ad
16
src/core.js
16
src/core.js
@ -10,9 +10,8 @@ import hasOwn from "./var/hasOwn.js";
|
|||||||
import fnToString from "./var/fnToString.js";
|
import fnToString from "./var/fnToString.js";
|
||||||
import ObjectFunctionString from "./var/ObjectFunctionString.js";
|
import ObjectFunctionString from "./var/ObjectFunctionString.js";
|
||||||
import support from "./var/support.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 DOMEval from "./core/DOMEval.js";
|
||||||
import toType from "./core/toType.js";
|
|
||||||
|
|
||||||
var version = "@VERSION",
|
var version = "@VERSION",
|
||||||
|
|
||||||
@ -398,17 +397,4 @@ jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symb
|
|||||||
class2type[ "[object " + name + "]" ] = name.toLowerCase();
|
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;
|
export default jQuery;
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import jQuery from "../core.js";
|
import jQuery from "../core.js";
|
||||||
import document from "../var/document.js";
|
import document from "../var/document.js";
|
||||||
import rsingleTag from "./var/rsingleTag.js";
|
import rsingleTag from "./var/rsingleTag.js";
|
||||||
|
import isObviousHtml from "./isObviousHtml.js";
|
||||||
|
|
||||||
import "../traversing/findFilter.js";
|
import "../traversing/findFilter.js";
|
||||||
|
|
||||||
@ -26,20 +27,41 @@ var rootjQuery,
|
|||||||
// so migrate can support jQuery.sub (gh-2101)
|
// so migrate can support jQuery.sub (gh-2101)
|
||||||
root = root || rootjQuery;
|
root = root || rootjQuery;
|
||||||
|
|
||||||
// Handle HTML strings
|
// HANDLE: $(DOMElement)
|
||||||
if ( typeof selector === "string" ) {
|
if ( selector.nodeType ) {
|
||||||
if ( selector[ 0 ] === "<" &&
|
this[ 0 ] = selector;
|
||||||
selector[ selector.length - 1 ] === ">" &&
|
this.length = 1;
|
||||||
selector.length >= 3 ) {
|
return this;
|
||||||
|
|
||||||
// Assume that strings that start and end with <> are HTML and skip the regex check
|
// HANDLE: $(function)
|
||||||
match = [ null, selector, null ];
|
// 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 {
|
} 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 ];
|
||||||
|
|
||||||
|
// Handle HTML strings or selectors
|
||||||
|
} else if ( typeof selector === "string" ) {
|
||||||
match = rquickExpr.exec( selector );
|
match = rquickExpr.exec( selector );
|
||||||
|
} else {
|
||||||
|
return jQuery.makeArray( selector, this );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match html or make sure no context is specified for #id
|
// 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 ) ) {
|
if ( match && ( match[ 1 ] || !context ) ) {
|
||||||
|
|
||||||
// HANDLE: $(html) -> $(array)
|
// HANDLE: $(html) -> $(array)
|
||||||
@ -84,7 +106,7 @@ var rootjQuery,
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HANDLE: $(expr, $(...))
|
// HANDLE: $(expr) & $(expr, $(...))
|
||||||
} else if ( !context || context.jquery ) {
|
} else if ( !context || context.jquery ) {
|
||||||
return ( context || root ).find( selector );
|
return ( context || root ).find( selector );
|
||||||
|
|
||||||
@ -93,24 +115,8 @@ var rootjQuery,
|
|||||||
} else {
|
} else {
|
||||||
return this.constructor( context ).find( selector );
|
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
|
// Give the init function the jQuery prototype for later instantiation
|
||||||
|
17
src/core/isArrayLike.js
Normal file
17
src/core/isArrayLike.js
Normal file
@ -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;
|
7
src/core/isObviousHtml.js
Normal file
7
src/core/isObviousHtml.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
function isObviousHtml( input ) {
|
||||||
|
return input[ 0 ] === "<" &&
|
||||||
|
input[ input.length - 1 ] === ">" &&
|
||||||
|
input.length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default isObviousHtml;
|
@ -2,13 +2,14 @@ import jQuery from "../core.js";
|
|||||||
import document from "../var/document.js";
|
import document from "../var/document.js";
|
||||||
import rsingleTag from "./var/rsingleTag.js";
|
import rsingleTag from "./var/rsingleTag.js";
|
||||||
import buildFragment from "../manipulation/buildFragment.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,
|
// context (optional): If specified, the fragment will be created in this context,
|
||||||
// defaults to document
|
// defaults to document
|
||||||
// keepScripts (optional): If true, will include scripts passed in the html string
|
// keepScripts (optional): If true, will include scripts passed in the html string
|
||||||
jQuery.parseHTML = function( data, context, keepScripts ) {
|
jQuery.parseHTML = function( data, context, keepScripts ) {
|
||||||
if ( typeof data !== "string" ) {
|
if ( typeof data !== "string" && !isObviousHtml( data + "" ) ) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if ( typeof context === "boolean" ) {
|
if ( typeof context === "boolean" ) {
|
||||||
|
@ -7,6 +7,7 @@ import rscriptType from "./var/rscriptType.js";
|
|||||||
import wrapMap from "./wrapMap.js";
|
import wrapMap from "./wrapMap.js";
|
||||||
import getAll from "./getAll.js";
|
import getAll from "./getAll.js";
|
||||||
import setGlobalEval from "./setGlobalEval.js";
|
import setGlobalEval from "./setGlobalEval.js";
|
||||||
|
import isArrayLike from "../core/isArrayLike.js";
|
||||||
|
|
||||||
var rhtml = /<|&#?\w+;/;
|
var rhtml = /<|&#?\w+;/;
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ function buildFragment( elems, context, scripts, selection, ignored ) {
|
|||||||
if ( elem || elem === 0 ) {
|
if ( elem || elem === 0 ) {
|
||||||
|
|
||||||
// Add nodes directly
|
// Add nodes directly
|
||||||
if ( toType( elem ) === "object" ) {
|
if ( toType( elem ) === "object" && ( elem.nodeType || isArrayLike( elem ) ) ) {
|
||||||
jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );
|
jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );
|
||||||
|
|
||||||
// Convert non-html into a text node
|
// Convert non-html into a text node
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"require": false,
|
"require": false,
|
||||||
"Promise": false,
|
"Promise": false,
|
||||||
"Symbol": false,
|
"Symbol": false,
|
||||||
|
"trustedTypes": false,
|
||||||
"QUnit": false,
|
"QUnit": false,
|
||||||
"ajaxTest": false,
|
"ajaxTest": false,
|
||||||
"testIframe": false,
|
"testIframe": false,
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
<title>CSP Test Page</title>
|
<title>CSP Test Page</title>
|
||||||
<script src="../jquery.js"></script>
|
<script src="../../dist/jquery.min.js"></script>
|
||||||
<script src="iframeTest.js"></script>
|
<script src="iframeTest.js"></script>
|
||||||
<script src="support/csp.js"></script>
|
<script src="support/csp.js"></script>
|
||||||
<script src="support/getComputedSupport.js"></script>
|
<script src="support/getComputedSupport.js"></script>
|
||||||
|
@ -215,7 +215,7 @@ QUnit.assert.ok( true, "mock executed");';
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function cspFrame( $req ) {
|
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' );
|
header( 'Content-type: text/html' );
|
||||||
echo file_get_contents( __DIR__ . '/csp.include.html' );
|
echo file_get_contents( __DIR__ . '/csp.include.html' );
|
||||||
}
|
}
|
||||||
@ -228,7 +228,7 @@ QUnit.assert.ok( true, "mock executed");';
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function cspAjaxScript( $req ) {
|
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' );
|
header( 'Content-type: text/html' );
|
||||||
echo file_get_contents( __DIR__ . '/csp-ajax-script.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, '' );
|
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 ) {
|
protected function errorWithScript( $req ) {
|
||||||
header( 'HTTP/1.0 404 Not Found' );
|
header( 'HTTP/1.0 404 Not Found' );
|
||||||
if ( isset( $req->query['withScriptContentType'] ) ) {
|
if ( isset( $req->query['withScriptContentType'] ) ) {
|
||||||
|
75
test/data/trusted-html.html
Normal file
75
test/data/trusted-html.html
Normal file
@ -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>
|
@ -222,7 +222,7 @@ var mocks = {
|
|||||||
cspFrame: function( req, resp ) {
|
cspFrame: function( req, resp ) {
|
||||||
resp.writeHead( 200, {
|
resp.writeHead( 200, {
|
||||||
"Content-Type": "text/html",
|
"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();
|
var body = fs.readFileSync( __dirname + "/data/csp.include.html" ).toString();
|
||||||
resp.end( body );
|
resp.end( body );
|
||||||
@ -256,6 +256,14 @@ var mocks = {
|
|||||||
resp.writeHead( 200 );
|
resp.writeHead( 200 );
|
||||||
resp.end();
|
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 ) {
|
errorWithScript: function( req, resp ) {
|
||||||
if ( req.query.withScriptContentType ) {
|
if ( req.query.withScriptContentType ) {
|
||||||
resp.writeHead( 404, { "Content-Type": "application/javascript" } );
|
resp.writeHead( 404, { "Content-Type": "application/javascript" } );
|
||||||
|
@ -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[ 0 ].nodeName.toLowerCase(), "td", "First element is td" );
|
||||||
assert.strictEqual( elem[ 1 ].nodeName.toLowerCase(), "td", "Second 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 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user