From 7f7ab85c3130e88e305cd5e0f5007d348b2b60e8 Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Mon, 25 Jun 2018 21:23:50 -0700 Subject: [PATCH] feat($compile): add support for arbitrary DOM property and event bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. For properties or events using mixed case, underscores can be used before capital letters. For example `ng-prop-my_prop="x"` will bind `x` to the `myProp` property, and `ng-on-my_custom_event="x()"` will invoke `x()` on the `myCustomEvent` event. Fixes #16428 Fixes #16235 Closes #16614 --- docs/content/error/$compile/ctxoverride.ngdoc | 13 + docs/content/error/$compile/nodomevents.ngdoc | 10 +- src/.eslintrc.json | 6 + src/ng/compile.js | 227 ++++- src/ng/directive/ngEventDirs.js | 62 +- test/ng/compileSpec.js | 156 +++- test/ng/ngOnSpec.js | 158 ++++ test/ng/ngPropSpec.js | 836 ++++++++++++++++++ 8 files changed, 1392 insertions(+), 76 deletions(-) create mode 100644 docs/content/error/$compile/ctxoverride.ngdoc create mode 100644 test/ng/ngOnSpec.js create mode 100644 test/ng/ngPropSpec.js diff --git a/docs/content/error/$compile/ctxoverride.ngdoc b/docs/content/error/$compile/ctxoverride.ngdoc new file mode 100644 index 000000000000..839b304d47a3 --- /dev/null +++ b/docs/content/error/$compile/ctxoverride.ngdoc @@ -0,0 +1,13 @@ +@ngdoc error +@name $compile:ctxoverride +@fullName DOM Property Security Context Override +@description + +This error occurs when the security context for a property is defined via {@link ng.$compileProvider#addPropertySecurityContext addPropertySecurityContext()} multiple times under different security contexts. + +For example: + +```js +$compileProvider.addPropertySecurityContext("my-element", "src", $sce.MEDIA_URL); +$compileProvider.addPropertySecurityContext("my-element", "src", $sce.RESOURCE_URL); //throws +``` diff --git a/docs/content/error/$compile/nodomevents.ngdoc b/docs/content/error/$compile/nodomevents.ngdoc index ed1888c73956..283bd76fa669 100644 --- a/docs/content/error/$compile/nodomevents.ngdoc +++ b/docs/content/error/$compile/nodomevents.ngdoc @@ -1,12 +1,12 @@ @ngdoc error @name $compile:nodomevents -@fullName Interpolated Event Attributes +@fullName Event Attribute/Property Binding @description -This error occurs when one tries to create a binding for event handler attributes like `onclick`, `onload`, `onsubmit`, etc. +This error occurs when one tries to create a binding for event handler attributes or properties like `onclick`, `onload`, `onsubmit`, etc. -There is no practical value in binding to these attributes and doing so only exposes your application to security vulnerabilities like XSS. -For these reasons binding to event handler attributes (all attributes that start with `on` and `formaction` attribute) is not supported. +There is no practical value in binding to these attributes/properties and doing so only exposes your application to security vulnerabilities like XSS. +For these reasons binding to event handler attributes and properties (`formaction` and all starting with `on`) is not supported. 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 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. - +Please use the `ng-*` or `ng-on-*` versions instead (such as `ng-click` or `ng-on-click` rather than `onclick`). diff --git a/src/.eslintrc.json b/src/.eslintrc.json index cf2331ef6f98..dca5e4b006ef 100644 --- a/src/.eslintrc.json +++ b/src/.eslintrc.json @@ -171,9 +171,15 @@ /* ng/q.js */ "markQExceptionHandled": false, + /* sce.js */ + "SCE_CONTEXTS": false, + /* ng/directive/directives.js */ "ngDirective": false, + /* ng/directive/ngEventDirs.js */ + "createEventDirective": false, + /* ng/directive/input.js */ "VALID_CLASS": false, "INVALID_CLASS": false, diff --git a/src/ng/compile.js b/src/ng/compile.js index 73684da77a09..e259cb3efcbe 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1586,6 +1586,91 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { return cssClassDirectivesEnabledConfig; }; + + /** + * The security context of DOM Properties. + * @private + */ + var PROP_CONTEXTS = createMap(); + + /** + * @ngdoc method + * @name $compileProvider#addPropertySecurityContext + * @description + * + * Defines the security context for DOM properties bound by ng-prop-*. + * + * @param {string} elementName The element name or '*' to match any element. + * @param {string} propertyName The DOM property name. + * @param {string} ctx The {@link $sce} security context in which this value is safe for use, e.g. `$sce.URL` + * @returns {object} `this` for chaining + */ + this.addPropertySecurityContext = function(elementName, propertyName, ctx) { + var key = (elementName.toLowerCase() + '|' + propertyName.toLowerCase()); + + if (key in PROP_CONTEXTS && PROP_CONTEXTS[key] !== ctx) { + throw $compileMinErr('ctxoverride', 'Property context \'{0}.{1}\' already set to \'{2}\', cannot override to \'{3}\'.', elementName, propertyName, PROP_CONTEXTS[key], ctx); + } + + PROP_CONTEXTS[key] = ctx; + return this; + }; + + /* Default property contexts. + * + * Copy of https://github.com/angular/angular/blob/6.0.6/packages/compiler/src/schema/dom_security_schema.ts#L31-L58 + * Changing: + * - SecurityContext.* => SCE_CONTEXTS/$sce.* + * - STYLE => CSS + * - various URL => MEDIA_URL + * - *|formAction, form|action URL => RESOURCE_URL (like the attribute) + */ + (function registerNativePropertyContexts() { + function registerContext(ctx, values) { + forEach(values, function(v) { PROP_CONTEXTS[v.toLowerCase()] = ctx; }); + } + + registerContext(SCE_CONTEXTS.HTML, [ + 'iframe|srcdoc', + '*|innerHTML', + '*|outerHTML' + ]); + registerContext(SCE_CONTEXTS.CSS, ['*|style']); + registerContext(SCE_CONTEXTS.URL, [ + 'area|href', 'area|ping', + 'a|href', 'a|ping', + 'blockquote|cite', + 'body|background', + 'del|cite', + 'input|src', + 'ins|cite', + 'q|cite' + ]); + registerContext(SCE_CONTEXTS.MEDIA_URL, [ + 'audio|src', + 'img|src', 'img|srcset', + 'source|src', 'source|srcset', + 'track|src', + 'video|src', 'video|poster' + ]); + registerContext(SCE_CONTEXTS.RESOURCE_URL, [ + '*|formAction', + 'applet|code', 'applet|codebase', + 'base|href', + 'embed|src', + 'frame|src', + 'form|action', + 'head|profile', + 'html|manifest', + 'iframe|src', + 'link|href', + 'media|src', + 'object|codebase', 'object|data', + 'script|src' + ]); + })(); + + this.$get = [ '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$sce', '$animate', @@ -1631,12 +1716,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } - function sanitizeSrcset(value) { + function sanitizeSrcset(value, invokeType) { if (!value) { return value; } if (!isString(value)) { - throw $compileMinErr('srcset', 'Can\'t pass trusted values to `$set(\'srcset\', value)`: "{0}"', value.toString()); + throw $compileMinErr('srcset', 'Can\'t pass trusted values to `{0}`: "{1}"', invokeType, value.toString()); } // Such values are a bit too complex to handle automatically inside $sce. @@ -1916,7 +2001,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { : function denormalizeTemplate(template) { return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol); }, - NG_ATTR_BINDING = /^ngAttr[A-Z]/; + NG_PREFIX_BINDING = /^ng(Attr|Prop|On)([A-Z].*)$/; var MULTI_ELEMENT_DIR_RE = /^(.+)Start$/; compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) { @@ -2252,43 +2337,66 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { directiveNormalize(nodeName), 'E', maxPriority, ignoreDirective); // iterate over the attributes - for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes, + for (var attr, name, nName, value, ngPrefixMatch, nAttrs = node.attributes, j = 0, jj = nAttrs && nAttrs.length; j < jj; j++) { var attrStartName = false; var attrEndName = false; + var isNgAttr = false, isNgProp = false, isNgEvent = false; + var multiElementMatch; + attr = nAttrs[j]; name = attr.name; value = attr.value; - // support ngAttr attribute binding - ngAttrName = directiveNormalize(name); - isNgAttr = NG_ATTR_BINDING.test(ngAttrName); - if (isNgAttr) { + nName = directiveNormalize(name.toLowerCase()); + + // Support ng-attr-*, ng-prop-* and ng-on-* + if ((ngPrefixMatch = nName.match(NG_PREFIX_BINDING))) { + isNgAttr = ngPrefixMatch[1] === 'Attr'; + isNgProp = ngPrefixMatch[1] === 'Prop'; + isNgEvent = ngPrefixMatch[1] === 'On'; + + // Normalize the non-prefixed name name = name.replace(PREFIX_REGEXP, '') - .substr(8).replace(/_(.)/g, function(match, letter) { + .toLowerCase() + .substr(4 + ngPrefixMatch[1].length).replace(/_(.)/g, function(match, letter) { return letter.toUpperCase(); }); - } - var multiElementMatch = ngAttrName.match(MULTI_ELEMENT_DIR_RE); - if (multiElementMatch && directiveIsMultiElement(multiElementMatch[1])) { + // Support *-start / *-end multi element directives + } else if ((multiElementMatch = nName.match(MULTI_ELEMENT_DIR_RE)) && directiveIsMultiElement(multiElementMatch[1])) { attrStartName = name; attrEndName = name.substr(0, name.length - 5) + 'end'; name = name.substr(0, name.length - 6); } - nName = directiveNormalize(name.toLowerCase()); - attrsMap[nName] = name; - if (isNgAttr || !attrs.hasOwnProperty(nName)) { + if (isNgProp || isNgEvent) { + attrs[nName] = value; + attrsMap[nName] = attr.name; + + if (isNgProp) { + addPropertyDirective(node, directives, nName, name); + } else { + addEventDirective(directives, nName, name); + } + } else { + // Update nName for cases where a prefix was removed + // NOTE: the .toLowerCase() is unnecessary and causes https://github.com/angular/angular.js/issues/16624 for ng-attr-* + nName = directiveNormalize(name.toLowerCase()); + attrsMap[nName] = name; + + if (isNgAttr || !attrs.hasOwnProperty(nName)) { attrs[nName] = value; if (getBooleanAttrName(node, nName)) { attrs[nName] = true; // presence means true } + } + + addAttrInterpolateDirective(node, directives, value, nName, isNgAttr); + addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, + attrEndName); } - addAttrInterpolateDirective(node, directives, value, nName, isNgAttr); - addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, - attrEndName); } if (nodeName === 'input' && node.getAttribute('type') === 'hidden') { @@ -3332,42 +3440,95 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } - function getTrustedContext(node, attrNormalizedName) { + function getTrustedAttrContext(nodeName, attrNormalizedName) { if (attrNormalizedName === 'srcdoc') { return $sce.HTML; } - var tag = nodeName_(node); - // All tags with src attributes require a RESOURCE_URL value, except for - // img and various html5 media tags, which require the MEDIA_URL context. + // All nodes with src attributes require a RESOURCE_URL value, except for + // img and various html5 media nodes, which require the MEDIA_URL context. if (attrNormalizedName === 'src' || attrNormalizedName === 'ngSrc') { - if (['img', 'video', 'audio', 'source', 'track'].indexOf(tag) === -1) { + if (['img', 'video', 'audio', 'source', 'track'].indexOf(nodeName) === -1) { return $sce.RESOURCE_URL; } return $sce.MEDIA_URL; } else if (attrNormalizedName === 'xlinkHref') { // Some xlink:href are okay, most aren't - if (tag === 'image') return $sce.MEDIA_URL; - if (tag === 'a') return $sce.URL; + if (nodeName === 'image') return $sce.MEDIA_URL; + if (nodeName === 'a') return $sce.URL; return $sce.RESOURCE_URL; } else if ( // Formaction - (tag === 'form' && attrNormalizedName === 'action') || + (nodeName === 'form' && attrNormalizedName === 'action') || // If relative URLs can go where they are not expected to, then // all sorts of trust issues can arise. - (tag === 'base' && attrNormalizedName === 'href') || + (nodeName === 'base' && attrNormalizedName === 'href') || // links can be stylesheets or imports, which can run script in the current origin - (tag === 'link' && attrNormalizedName === 'href') + (nodeName === 'link' && attrNormalizedName === 'href') ) { return $sce.RESOURCE_URL; - } else if (tag === 'a' && (attrNormalizedName === 'href' || + } else if (nodeName === 'a' && (attrNormalizedName === 'href' || attrNormalizedName === 'ngHref')) { return $sce.URL; } } + function getTrustedPropContext(nodeName, propNormalizedName) { + var prop = propNormalizedName.toLowerCase(); + return PROP_CONTEXTS[nodeName + '|' + prop] || PROP_CONTEXTS['*|' + prop]; + } + + function sanitizeSrcsetPropertyValue(value) { + return sanitizeSrcset($sce.valueOf(value), 'ng-prop-srcset'); + } + function addPropertyDirective(node, directives, attrName, propName) { + if (EVENT_HANDLER_ATTR_REGEXP.test(propName)) { + throw $compileMinErr('nodomevents', 'Property bindings for HTML DOM event properties are disallowed'); + } + + var nodeName = nodeName_(node); + var trustedContext = getTrustedPropContext(nodeName, propName); + + var sanitizer = identity; + // Sanitize img[srcset] + source[srcset] values. + if (propName === 'srcset' && (nodeName === 'img' || nodeName === 'source')) { + sanitizer = sanitizeSrcsetPropertyValue; + } else if (trustedContext) { + sanitizer = $sce.getTrusted.bind($sce, trustedContext); + } + + directives.push({ + priority: 100, + compile: function ngPropCompileFn(_, attr) { + var ngPropGetter = $parse(attr[attrName]); + var ngPropWatch = $parse(attr[attrName], function sceValueOf(val) { + // Unwrap the value to compare the actual inner safe value, not the wrapper object. + return $sce.valueOf(val); + }); + + return { + pre: function ngPropPreLinkFn(scope, $element) { + function applyPropValue() { + var propValue = ngPropGetter(scope); + $element.prop(propName, sanitizer(propValue)); + } + + applyPropValue(); + scope.$watch(ngPropWatch, applyPropValue); + } + }; + } + }); + } + + function addEventDirective(directives, attrName, eventName) { + directives.push( + createEventDirective($parse, $rootScope, $exceptionHandler, attrName, eventName, /*forceAsync=*/false) + ); + } function addAttrInterpolateDirective(node, directives, value, name, isNgAttr) { - var trustedContext = getTrustedContext(node, name); + var nodeName = nodeName_(node); + var trustedContext = getTrustedAttrContext(nodeName, name); var mustHaveExpression = !isNgAttr; var allOrNothing = ALL_OR_NOTHING_ATTRS[name] || isNgAttr; @@ -3376,16 +3537,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // no interpolation found -> ignore if (!interpolateFn) return; - if (name === 'multiple' && nodeName_(node) === 'select') { + if (name === 'multiple' && nodeName === 'select') { throw $compileMinErr('selmulti', 'Binding to the \'multiple\' attribute is not supported. Element: {0}', startingTag(node)); } if (EVENT_HANDLER_ATTR_REGEXP.test(name)) { - throw $compileMinErr('nodomevents', - 'Interpolations for HTML DOM event attributes are disallowed. Please use the ' + - 'ng- versions (such as ng-click instead of onclick) instead.'); + throw $compileMinErr('nodomevents', 'Interpolations for HTML DOM event attributes are disallowed'); } directives.push({ diff --git a/src/ng/directive/ngEventDirs.js b/src/ng/directive/ngEventDirs.js index faee43da465a..dbb7c4b1bda1 100644 --- a/src/ng/directive/ngEventDirs.js +++ b/src/ng/directive/ngEventDirs.js @@ -51,39 +51,43 @@ forEach( function(eventName) { var directiveName = directiveNormalize('ng-' + eventName); ngEventDirectives[directiveName] = ['$parse', '$rootScope', '$exceptionHandler', function($parse, $rootScope, $exceptionHandler) { - return { - restrict: 'A', - compile: function($element, attr) { - // NOTE: - // We expose the powerful `$event` object on the scope that provides access to the Window, - // etc. This is OK, because expressions are not sandboxed any more (and the expression - // sandbox was never meant to be a security feature anyway). - var fn = $parse(attr[directiveName]); - return function ngEventHandler(scope, element) { - element.on(eventName, function(event) { - var callback = function() { - fn(scope, {$event: event}); - }; - - if (!$rootScope.$$phase) { - scope.$apply(callback); - } else if (forceAsyncEvents[eventName]) { - scope.$evalAsync(callback); - } else { - try { - callback(); - } catch (error) { - $exceptionHandler(error); - } - } - }); - }; - } - }; + return createEventDirective($parse, $rootScope, $exceptionHandler, directiveName, eventName, forceAsyncEvents[eventName]); }]; } ); +function createEventDirective($parse, $rootScope, $exceptionHandler, directiveName, eventName, forceAsync) { + return { + restrict: 'A', + compile: function($element, attr) { + // NOTE: + // We expose the powerful `$event` object on the scope that provides access to the Window, + // etc. This is OK, because expressions are not sandboxed any more (and the expression + // sandbox was never meant to be a security feature anyway). + var fn = $parse(attr[directiveName]); + return function ngEventHandler(scope, element) { + element.on(eventName, function(event) { + var callback = function() { + fn(scope, {$event: event}); + }; + + if (!$rootScope.$$phase) { + scope.$apply(callback); + } else if (forceAsync) { + scope.$evalAsync(callback); + } else { + try { + callback(); + } catch (error) { + $exceptionHandler(error); + } + } + }); + }; + } + }; +} + /** * @ngdoc directive * @name ngDblclick diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index a0b8b7204439..48b5e565e088 100644 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -11480,7 +11480,7 @@ describe('$compile', function() { expect(element.attr('srcset')).toEqual('http://example.com'); })); - it('does not work with trusted values', inject(function($rootScope, $compile, $sce) { + it('should NOT work with trusted values', inject(function($rootScope, $compile, $sce) { // A limitation of the approach used for srcset is that you cannot use `trustAsUrl`. // Use trustAsHtml and ng-bind-html to work around this. element = $compile('')($rootScope); @@ -11705,18 +11705,19 @@ describe('$compile', function() { expect(function() { $compile('')($rootScope); + $rootScope.$digest(); + expect(element.prop('disabled')).toBe(false); + $rootScope.isDisabled = true; + $rootScope.$digest(); + expect(element.prop('disabled')).toBe(true); + $rootScope.isDisabled = false; + $rootScope.$digest(); + expect(element.prop('disabled')).toBe(false); + })); + + it('should bind boolean properties (input checked)', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + expect(element.prop('checked')).toBe(false); + $rootScope.isChecked = true; + $rootScope.$digest(); + expect(element.prop('checked')).toBe(true); + $rootScope.isChecked = false; + $rootScope.$digest(); + expect(element.prop('checked')).toBe(false); + })); + + it('should bind string properties (title)', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + $rootScope.title = 123; + $rootScope.$digest(); + expect(element.prop('title')).toBe('123'); + $rootScope.title = 'foobar'; + $rootScope.$digest(); + expect(element.prop('title')).toBe('foobar'); + })); + + it('should bind variable type properties', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + $rootScope.asdf = 123; + $rootScope.$digest(); + expect(element.prop('asdf')).toBe(123); + $rootScope.asdf = 'foobar'; + $rootScope.$digest(); + expect(element.prop('asdf')).toBe('foobar'); + $rootScope.asdf = true; + $rootScope.$digest(); + expect(element.prop('asdf')).toBe(true); + })); + + it('should support mixed case using underscore-separated names', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + $rootScope.value = 123; + $rootScope.$digest(); + expect(element.prop('aBcdE')).toBe(123); + })); + + it('should work with different prefixes', inject(function($rootScope, $compile) { + $rootScope.name = 'Misko'; + var element = $compile('')($rootScope); + expect(element.prop('test')).toBe('Misko'); + expect(element.prop('test2')).toBe('Misko'); + expect(element.prop('test3')).toBe('Misko'); + })); + + it('should work with the "href" property', inject(function($rootScope, $compile) { + $rootScope.value = 'test'; + var element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.prop('href')).toMatch(/\/test\/test$/); + })); + + it('should work if they are prefixed with x- or data- and different prefixes', inject(function($rootScope, $compile) { + $rootScope.name = 'Misko'; + var element = $compile('')($rootScope); + expect(element.prop('test2')).toBe('Misko'); + expect(element.prop('test3')).toBe('Misko'); + expect(element.prop('test4')).toBe('Misko'); + expect(element.prop('test5')).toBe('Misko'); + expect(element.prop('test6')).toBe('Misko'); + })); + + it('should work independently of attributes with the same name', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + $rootScope.asdf = 123; + $rootScope.$digest(); + expect(element.prop('asdf')).toBe(123); + expect(element.attr('asdf')).toBe('foo'); + })); + + it('should work independently of (ng-)attributes with the same name', inject(function($rootScope, $compile) { + var element = $compile('')($rootScope); + $rootScope.asdf = 123; + $rootScope.$digest(); + expect(element.prop('asdf')).toBe(123); + expect(element.attr('asdf')).toBe('foo'); + })); + + it('should use the full ng-prop-* attribute name in $attr mappings', function() { + var attrs; + module(function($compileProvider) { + $compileProvider.directive('attrExposer', valueFn({ + link: function($scope, $element, $attrs) { + attrs = $attrs; + } + })); + }); + inject(function($compile, $rootScope) { + $compile('
')($rootScope); + + expect(attrs.title).toBeUndefined(); + expect(attrs.$attr.title).toBeUndefined(); + expect(attrs.ngPropTitle).toBe('12'); + expect(attrs.$attr.ngPropTitle).toBe('ng-prop-title'); + + expect(attrs.superTitle).toBeUndefined(); + expect(attrs.$attr.superTitle).toBeUndefined(); + expect(attrs.ngPropSuperTitle).toBe('34'); + expect(attrs.$attr.ngPropSuperTitle).toBe('ng-prop-super-title'); + + expect(attrs.myCamelTitle).toBeUndefined(); + expect(attrs.$attr.myCamelTitle).toBeUndefined(); + expect(attrs.ngPropMyCamelTitle).toBe('56'); + expect(attrs.$attr.ngPropMyCamelTitle).toBe('ng-prop-my-camel_title'); + }); + }); + + it('should not conflict with (ng-attr-)attribute mappings of the same name', function() { + var attrs; + module(function($compileProvider) { + $compileProvider.directive('attrExposer', valueFn({ + link: function($scope, $element, $attrs) { + attrs = $attrs; + } + })); + }); + inject(function($compile, $rootScope) { + $compile('
')($rootScope); + expect(attrs.title).toBe('foo'); + expect(attrs.$attr.title).toBe('title'); + expect(attrs.$attr.ngPropTitle).toBe('ng-prop-title'); + }); + }); + + it('should disallow property binding to onclick', inject(function($compile, $rootScope) { + // All event prop bindings are disallowed. + expect(function() { + $compile('