Skip to content

Commit 129d5dc

Browse files
committed
feat($compile): add support for arbitrary DOM property and event bindings
Properties: Previously only arbitrary DOM attribute bindings were supported via interpolation such as `my-attribute="{{expression}}"` or `ng-attr-my-attribute="{{expression}}"`, and only a set of distinct properties could be bound. `ng-prop-*` adds support for binding expressions to any DOM properties. For example `ng-prop-foo="x"` will assign the value of the expression `x` to the `foo` property, and re-assign whenever the expression `x` changes. Events: Previously only a distinct set of DOM events could be bound using directives such as `ng-click`, `ng-blur` etc. `ng-on-*` adds support for binding expressions to any DOM event. For example `ng-on-bar="barOccured($event)"` will add a listener to the "bar" event and invoke the `barOccured($event)` expression. Since HTML attributes are case-insensitive, property and event names are specified in snake_case for `ng-prop-*` and `ng-on-*`. For example, to bind property `fooBar` use `ng-prop-foo_bar`, to listen to event `fooBar` use `ng-on-foo_bar`. Fixes angular#16428 Fixes angular#16235 Closes angular#16614
1 parent 4f68389 commit 129d5dc

File tree

8 files changed

+1392
-76
lines changed

8 files changed

+1392
-76
lines changed

Diff for: docs/content/error/$compile/ctxoverride.ngdoc

+13
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+
```

Diff for: docs/content/error/$compile/nodomevents.ngdoc

+5-5
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`).

Diff for: 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,

Diff for: 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') {
@@ -3332,42 +3440,95 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
33323440
}
33333441

33343442

3335-
function getTrustedContext(node, attrNormalizedName) {
3443+
function getTrustedAttrContext(nodeName, attrNormalizedName) {
33363444
if (attrNormalizedName === 'srcdoc') {
33373445
return $sce.HTML;
33383446
}
3339-
var tag = nodeName_(node);
3340-
// All tags with src attributes require a RESOURCE_URL value, except for
3341-
// img and various html5 media tags, which require the MEDIA_URL context.
3447+
// All nodes with src attributes require a RESOURCE_URL value, except for
3448+
// img and various html5 media nodes, which require the MEDIA_URL context.
33423449
if (attrNormalizedName === 'src' || attrNormalizedName === 'ngSrc') {
3343-
if (['img', 'video', 'audio', 'source', 'track'].indexOf(tag) === -1) {
3450+
if (['img', 'video', 'audio', 'source', 'track'].indexOf(nodeName) === -1) {
33443451
return $sce.RESOURCE_URL;
33453452
}
33463453
return $sce.MEDIA_URL;
33473454
} else if (attrNormalizedName === 'xlinkHref') {
33483455
// Some xlink:href are okay, most aren't
3349-
if (tag === 'image') return $sce.MEDIA_URL;
3350-
if (tag === 'a') return $sce.URL;
3456+
if (nodeName === 'image') return $sce.MEDIA_URL;
3457+
if (nodeName === 'a') return $sce.URL;
33513458
return $sce.RESOURCE_URL;
33523459
} else if (
33533460
// Formaction
3354-
(tag === 'form' && attrNormalizedName === 'action') ||
3461+
(nodeName === 'form' && attrNormalizedName === 'action') ||
33553462
// If relative URLs can go where they are not expected to, then
33563463
// all sorts of trust issues can arise.
3357-
(tag === 'base' && attrNormalizedName === 'href') ||
3464+
(nodeName === 'base' && attrNormalizedName === 'href') ||
33583465
// links can be stylesheets or imports, which can run script in the current origin
3359-
(tag === 'link' && attrNormalizedName === 'href')
3466+
(nodeName === 'link' && attrNormalizedName === 'href')
33603467
) {
33613468
return $sce.RESOURCE_URL;
3362-
} else if (tag === 'a' && (attrNormalizedName === 'href' ||
3469+
} else if (nodeName === 'a' && (attrNormalizedName === 'href' ||
33633470
attrNormalizedName === 'ngHref')) {
33643471
return $sce.URL;
33653472
}
33663473
}
33673474

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

33693529
function addAttrInterpolateDirective(node, directives, value, name, isNgAttr) {
3370-
var trustedContext = getTrustedContext(node, name);
3530+
var nodeName = nodeName_(node);
3531+
var trustedContext = getTrustedAttrContext(nodeName, name);
33713532
var mustHaveExpression = !isNgAttr;
33723533
var allOrNothing = ALL_OR_NOTHING_ATTRS[name] || isNgAttr;
33733534

@@ -3376,16 +3537,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
33763537
// no interpolation found -> ignore
33773538
if (!interpolateFn) return;
33783539

3379-
if (name === 'multiple' && nodeName_(node) === 'select') {
3540+
if (name === 'multiple' && nodeName === 'select') {
33803541
throw $compileMinErr('selmulti',
33813542
'Binding to the \'multiple\' attribute is not supported. Element: {0}',
33823543
startingTag(node));
33833544
}
33843545

33853546
if (EVENT_HANDLER_ATTR_REGEXP.test(name)) {
3386-
throw $compileMinErr('nodomevents',
3387-
'Interpolations for HTML DOM event attributes are disallowed. Please use the ' +
3388-
'ng- versions (such as ng-click instead of onclick) instead.');
3547+
throw $compileMinErr('nodomevents', 'Interpolations for HTML DOM event attributes are disallowed');
33893548
}
33903549

33913550
directives.push({

0 commit comments

Comments
 (0)