Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 0a5c935

Browse files
committed
feat($compile): add support for arbitrary property and event bindings
Fixes #16428 Closes #16614
1 parent f92f819 commit 0a5c935

File tree

8 files changed

+1392
-76
lines changed

8 files changed

+1392
-76
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@ngdoc error
2+
@name $compile:ctxoverride
3+
@fullName DOM Property Security Context Override
4+
@description
5+
6+
This error occurs when the security context for a property is defined via {@link ng.$compileProvider#addPropertySecurityContext addPropertySecurityContext()} multiple times under different security contexts.
7+
8+
For example:
9+
10+
```js
11+
$compileProvider.addPropertySecurityContext("my-element", "src", $sce.MEDIA_URL);
12+
$compileProvider.addPropertySecurityContext("my-element", "src", $sce.RESOURCE_URL); //throws
13+
```
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
@ngdoc error
22
@name $compile:nodomevents
3-
@fullName Interpolated Event Attributes
3+
@fullName Event Attribute/Property Binding
44
@description
55

6-
This error occurs when one tries to create a binding for event handler attributes like `onclick`, `onload`, `onsubmit`, etc.
6+
This error occurs when one tries to create a binding for event handler attributes or properties like `onclick`, `onload`, `onsubmit`, etc.
77

8-
There is no practical value in binding to these attributes and doing so only exposes your application to security vulnerabilities like XSS.
9-
For these reasons binding to event handler attributes (all attributes that start with `on` and `formaction` attribute) is not supported.
8+
There is no practical value in binding to these attributes/properties and doing so only exposes your application to security vulnerabilities like XSS.
9+
For these reasons binding to event handler attributes and properties (`formaction` and all starting with `on`) is not supported.
1010

1111

1212
An example code that would allow XSS vulnerability by evaluating user input in the window context could look like this:
@@ -17,4 +17,4 @@ An example code that would allow XSS vulnerability by evaluating user input in t
1717

1818
Since the `onclick` evaluates the value as JavaScript code in the window context, setting the `username` model to a value like `javascript:alert('PWND')` would result in script injection when the `div` is clicked.
1919

20-
20+
Please use the `ng-*` or `ng-on-*` versions instead (such as `ng-click` or `ng-on-click` rather than `onclick`).

src/.eslintrc.json

+6
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,15 @@
171171
/* ng/q.js */
172172
"markQExceptionHandled": false,
173173

174+
/* sce.js */
175+
"SCE_CONTEXTS": false,
176+
174177
/* ng/directive/directives.js */
175178
"ngDirective": false,
176179

180+
/* ng/directive/ngEventDirs.js */
181+
"createEventDirective": false,
182+
177183
/* ng/directive/input.js */
178184
"VALID_CLASS": false,
179185
"INVALID_CLASS": false,

src/ng/compile.js

+193-34
Original file line numberDiff line numberDiff line change
@@ -1586,6 +1586,91 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
15861586
return cssClassDirectivesEnabledConfig;
15871587
};
15881588

1589+
1590+
/**
1591+
* The security context of DOM Properties.
1592+
* @private
1593+
*/
1594+
var PROP_CONTEXTS = createMap();
1595+
1596+
/**
1597+
* @ngdoc method
1598+
* @name $compileProvider#addPropertySecurityContext
1599+
* @description
1600+
*
1601+
* Defines the security context for DOM properties bound by ng-prop-*.
1602+
*
1603+
* @param {string} elementName The element name or '*' to match any element.
1604+
* @param {string} propertyName The DOM property name.
1605+
* @param {string} ctx The {@link $sce} security context in which this value is safe for use, e.g. `$sce.URL`
1606+
* @returns {object} `this` for chaining
1607+
*/
1608+
this.addPropertySecurityContext = function(elementName, propertyName, ctx) {
1609+
var key = (elementName.toLowerCase() + '|' + propertyName.toLowerCase());
1610+
1611+
if (key in PROP_CONTEXTS && PROP_CONTEXTS[key] !== ctx) {
1612+
throw $compileMinErr('ctxoverride', 'Property context \'{0}.{1}\' already set to \'{2}\', cannot override to \'{3}\'.', elementName, propertyName, PROP_CONTEXTS[key], ctx);
1613+
}
1614+
1615+
PROP_CONTEXTS[key] = ctx;
1616+
return this;
1617+
};
1618+
1619+
/* Default property contexts.
1620+
*
1621+
* Copy of https://github.com/angular/angular/blob/6.0.6/packages/compiler/src/schema/dom_security_schema.ts#L31-L58
1622+
* Changing:
1623+
* - SecurityContext.* => SCE_CONTEXTS/$sce.*
1624+
* - STYLE => CSS
1625+
* - various URL => MEDIA_URL
1626+
* - *|formAction, form|action URL => RESOURCE_URL (like the attribute)
1627+
*/
1628+
(function registerNativePropertyContexts() {
1629+
function registerContext(ctx, values) {
1630+
forEach(values, function(v) { PROP_CONTEXTS[v.toLowerCase()] = ctx; });
1631+
}
1632+
1633+
registerContext(SCE_CONTEXTS.HTML, [
1634+
'iframe|srcdoc',
1635+
'*|innerHTML',
1636+
'*|outerHTML'
1637+
]);
1638+
registerContext(SCE_CONTEXTS.CSS, ['*|style']);
1639+
registerContext(SCE_CONTEXTS.URL, [
1640+
'area|href', 'area|ping',
1641+
'a|href', 'a|ping',
1642+
'blockquote|cite',
1643+
'body|background',
1644+
'del|cite',
1645+
'input|src',
1646+
'ins|cite',
1647+
'q|cite'
1648+
]);
1649+
registerContext(SCE_CONTEXTS.MEDIA_URL, [
1650+
'audio|src',
1651+
'img|src', 'img|srcset',
1652+
'source|src', 'source|srcset',
1653+
'track|src',
1654+
'video|src', 'video|poster'
1655+
]);
1656+
registerContext(SCE_CONTEXTS.RESOURCE_URL, [
1657+
'*|formAction',
1658+
'applet|code', 'applet|codebase',
1659+
'base|href',
1660+
'embed|src',
1661+
'frame|src',
1662+
'form|action',
1663+
'head|profile',
1664+
'html|manifest',
1665+
'iframe|src',
1666+
'link|href',
1667+
'media|src',
1668+
'object|codebase', 'object|data',
1669+
'script|src'
1670+
]);
1671+
})();
1672+
1673+
15891674
this.$get = [
15901675
'$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse',
15911676
'$controller', '$rootScope', '$sce', '$animate',
@@ -1631,12 +1716,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
16311716
}
16321717

16331718

1634-
function sanitizeSrcset(value) {
1719+
function sanitizeSrcset(value, invokeType) {
16351720
if (!value) {
16361721
return value;
16371722
}
16381723
if (!isString(value)) {
1639-
throw $compileMinErr('srcset', 'Can\'t pass trusted values to `$set(\'srcset\', value)`: "{0}"', value.toString());
1724+
throw $compileMinErr('srcset', 'Can\'t pass trusted values to `{0}`: "{1}"', invokeType, value.toString());
16401725
}
16411726

16421727
// Such values are a bit too complex to handle automatically inside $sce.
@@ -1916,7 +2001,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
19162001
: function denormalizeTemplate(template) {
19172002
return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol);
19182003
},
1919-
NG_ATTR_BINDING = /^ngAttr[A-Z]/;
2004+
NG_PREFIX_BINDING = /^ng(Attr|Prop|On)([A-Z].*)$/;
19202005
var MULTI_ELEMENT_DIR_RE = /^(.+)Start$/;
19212006

19222007
compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) {
@@ -2252,43 +2337,66 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
22522337
directiveNormalize(nodeName), 'E', maxPriority, ignoreDirective);
22532338

22542339
// iterate over the attributes
2255-
for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes,
2340+
for (var attr, name, nName, value, ngPrefixMatch, nAttrs = node.attributes,
22562341
j = 0, jj = nAttrs && nAttrs.length; j < jj; j++) {
22572342
var attrStartName = false;
22582343
var attrEndName = false;
22592344

2345+
var isNgAttr = false, isNgProp = false, isNgEvent = false;
2346+
var multiElementMatch;
2347+
22602348
attr = nAttrs[j];
22612349
name = attr.name;
22622350
value = attr.value;
22632351

2264-
// support ngAttr attribute binding
2265-
ngAttrName = directiveNormalize(name);
2266-
isNgAttr = NG_ATTR_BINDING.test(ngAttrName);
2267-
if (isNgAttr) {
2352+
nName = directiveNormalize(name.toLowerCase());
2353+
2354+
// Support ng-attr-*, ng-prop-* and ng-on-*
2355+
if ((ngPrefixMatch = nName.match(NG_PREFIX_BINDING))) {
2356+
isNgAttr = ngPrefixMatch[1] === 'Attr';
2357+
isNgProp = ngPrefixMatch[1] === 'Prop';
2358+
isNgEvent = ngPrefixMatch[1] === 'On';
2359+
2360+
// Normalize the non-prefixed name
22682361
name = name.replace(PREFIX_REGEXP, '')
2269-
.substr(8).replace(/_(.)/g, function(match, letter) {
2362+
.toLowerCase()
2363+
.substr(4 + ngPrefixMatch[1].length).replace(/_(.)/g, function(match, letter) {
22702364
return letter.toUpperCase();
22712365
});
2272-
}
22732366

2274-
var multiElementMatch = ngAttrName.match(MULTI_ELEMENT_DIR_RE);
2275-
if (multiElementMatch && directiveIsMultiElement(multiElementMatch[1])) {
2367+
// Support *-start / *-end multi element directives
2368+
} else if ((multiElementMatch = nName.match(MULTI_ELEMENT_DIR_RE)) && directiveIsMultiElement(multiElementMatch[1])) {
22762369
attrStartName = name;
22772370
attrEndName = name.substr(0, name.length - 5) + 'end';
22782371
name = name.substr(0, name.length - 6);
22792372
}
22802373

2281-
nName = directiveNormalize(name.toLowerCase());
2282-
attrsMap[nName] = name;
2283-
if (isNgAttr || !attrs.hasOwnProperty(nName)) {
2374+
if (isNgProp || isNgEvent) {
2375+
attrs[nName] = value;
2376+
attrsMap[nName] = attr.name;
2377+
2378+
if (isNgProp) {
2379+
addPropertyDirective(node, directives, nName, name);
2380+
} else {
2381+
addEventDirective(directives, nName, name);
2382+
}
2383+
} else {
2384+
// Update nName for cases where a prefix was removed
2385+
// NOTE: the .toLowerCase() is unnecessary and causes https://github.com/angular/angular.js/issues/16624 for ng-attr-*
2386+
nName = directiveNormalize(name.toLowerCase());
2387+
attrsMap[nName] = name;
2388+
2389+
if (isNgAttr || !attrs.hasOwnProperty(nName)) {
22842390
attrs[nName] = value;
22852391
if (getBooleanAttrName(node, nName)) {
22862392
attrs[nName] = true; // presence means true
22872393
}
2394+
}
2395+
2396+
addAttrInterpolateDirective(node, directives, value, nName, isNgAttr);
2397+
addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName,
2398+
attrEndName);
22882399
}
2289-
addAttrInterpolateDirective(node, directives, value, nName, isNgAttr);
2290-
addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName,
2291-
attrEndName);
22922400
}
22932401

22942402
if (nodeName === 'input' && node.getAttribute('type') === 'hidden') {
@@ -3325,42 +3433,95 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
33253433
}
33263434

33273435

3328-
function getTrustedContext(node, attrNormalizedName) {
3436+
function getTrustedAttrContext(nodeName, attrNormalizedName) {
33293437
if (attrNormalizedName === 'srcdoc') {
33303438
return $sce.HTML;
33313439
}
3332-
var tag = nodeName_(node);
3333-
// All tags with src attributes require a RESOURCE_URL value, except for
3334-
// img and various html5 media tags, which require the MEDIA_URL context.
3440+
// All nodes with src attributes require a RESOURCE_URL value, except for
3441+
// img and various html5 media nodes, which require the MEDIA_URL context.
33353442
if (attrNormalizedName === 'src' || attrNormalizedName === 'ngSrc') {
3336-
if (['img', 'video', 'audio', 'source', 'track'].indexOf(tag) === -1) {
3443+
if (['img', 'video', 'audio', 'source', 'track'].indexOf(nodeName) === -1) {
33373444
return $sce.RESOURCE_URL;
33383445
}
33393446
return $sce.MEDIA_URL;
33403447
} else if (attrNormalizedName === 'xlinkHref') {
33413448
// Some xlink:href are okay, most aren't
3342-
if (tag === 'image') return $sce.MEDIA_URL;
3343-
if (tag === 'a') return $sce.URL;
3449+
if (nodeName === 'image') return $sce.MEDIA_URL;
3450+
if (nodeName === 'a') return $sce.URL;
33443451
return $sce.RESOURCE_URL;
33453452
} else if (
33463453
// Formaction
3347-
(tag === 'form' && attrNormalizedName === 'action') ||
3454+
(nodeName === 'form' && attrNormalizedName === 'action') ||
33483455
// If relative URLs can go where they are not expected to, then
33493456
// all sorts of trust issues can arise.
3350-
(tag === 'base' && attrNormalizedName === 'href') ||
3457+
(nodeName === 'base' && attrNormalizedName === 'href') ||
33513458
// links can be stylesheets or imports, which can run script in the current origin
3352-
(tag === 'link' && attrNormalizedName === 'href')
3459+
(nodeName === 'link' && attrNormalizedName === 'href')
33533460
) {
33543461
return $sce.RESOURCE_URL;
3355-
} else if (tag === 'a' && (attrNormalizedName === 'href' ||
3462+
} else if (nodeName === 'a' && (attrNormalizedName === 'href' ||
33563463
attrNormalizedName === 'ngHref')) {
33573464
return $sce.URL;
33583465
}
33593466
}
33603467

3468+
function getTrustedPropContext(nodeName, propNormalizedName) {
3469+
var prop = propNormalizedName.toLowerCase();
3470+
return PROP_CONTEXTS[nodeName + '|' + prop] || PROP_CONTEXTS['*|' + prop];
3471+
}
3472+
3473+
function sanitizeSrcsetPropertyValue(value) {
3474+
return sanitizeSrcset($sce.valueOf(value), 'ng-prop-srcset');
3475+
}
3476+
function addPropertyDirective(node, directives, attrName, propName) {
3477+
if (EVENT_HANDLER_ATTR_REGEXP.test(propName)) {
3478+
throw $compileMinErr('nodomevents', 'Property bindings for HTML DOM event properties are disallowed');
3479+
}
3480+
3481+
var nodeName = nodeName_(node);
3482+
var trustedContext = getTrustedPropContext(nodeName, propName);
3483+
3484+
var sanitizer = identity;
3485+
// Sanitize img[srcset] + source[srcset] values.
3486+
if (propName === 'srcset' && (nodeName === 'img' || nodeName === 'source')) {
3487+
sanitizer = sanitizeSrcsetPropertyValue;
3488+
} else if (trustedContext) {
3489+
sanitizer = $sce.getTrusted.bind($sce, trustedContext);
3490+
}
3491+
3492+
directives.push({
3493+
priority: 100,
3494+
compile: function ngPropCompileFn(_, attr) {
3495+
var ngPropGetter = $parse(attr[attrName]);
3496+
var ngPropWatch = $parse(attr[attrName], function sceValueOf(val) {
3497+
// Unwrap the value to compare the actual inner safe value, not the wrapper object.
3498+
return $sce.valueOf(val);
3499+
});
3500+
3501+
return {
3502+
pre: function ngPropPreLinkFn(scope, $element) {
3503+
function applyPropValue() {
3504+
var propValue = ngPropGetter(scope);
3505+
$element.prop(propName, sanitizer(propValue));
3506+
}
3507+
3508+
applyPropValue();
3509+
scope.$watch(ngPropWatch, applyPropValue);
3510+
}
3511+
};
3512+
}
3513+
});
3514+
}
3515+
3516+
function addEventDirective(directives, attrName, eventName) {
3517+
directives.push(
3518+
createEventDirective($parse, $rootScope, $exceptionHandler, attrName, eventName, /*forceAsync=*/false)
3519+
);
3520+
}
33613521

33623522
function addAttrInterpolateDirective(node, directives, value, name, isNgAttr) {
3363-
var trustedContext = getTrustedContext(node, name);
3523+
var nodeName = nodeName_(node);
3524+
var trustedContext = getTrustedAttrContext(nodeName, name);
33643525
var mustHaveExpression = !isNgAttr;
33653526
var allOrNothing = ALL_OR_NOTHING_ATTRS[name] || isNgAttr;
33663527

@@ -3369,16 +3530,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
33693530
// no interpolation found -> ignore
33703531
if (!interpolateFn) return;
33713532

3372-
if (name === 'multiple' && nodeName_(node) === 'select') {
3533+
if (name === 'multiple' && nodeName === 'select') {
33733534
throw $compileMinErr('selmulti',
33743535
'Binding to the \'multiple\' attribute is not supported. Element: {0}',
33753536
startingTag(node));
33763537
}
33773538

33783539
if (EVENT_HANDLER_ATTR_REGEXP.test(name)) {
3379-
throw $compileMinErr('nodomevents',
3380-
'Interpolations for HTML DOM event attributes are disallowed. Please use the ' +
3381-
'ng- versions (such as ng-click instead of onclick) instead.');
3540+
throw $compileMinErr('nodomevents', 'Interpolations for HTML DOM event attributes are disallowed');
33823541
}
33833542

33843543
directives.push({

0 commit comments

Comments
 (0)