From e3b358d3ab33383f4ff5ed4e1a0a4949f6fb4860 Mon Sep 17 00:00:00 2001 From: Caitlin Potter Date: Tue, 20 May 2014 04:11:34 -0400 Subject: [PATCH] feat($interpolate): escaped interpolation expressions This CL enables interpolation expressions to be escaped, by prefixing each character of their start/end markers with a REVERSE SOLIDUS U+005C, and to render the escaped expression as a regular interpolation expression. Example: `{{foo}}, \\{\\{World!\\}\\}` would be rendered as: `Hello, {{World!}}` This will also work with custom interpolation markers, for example: module. config(function($interpolateProvider) { $interpolateProvider.startSymbol('\\\\'); $interpolateProvider.endSymbol('//'); }). run(function($interpolate) { // Will alert with "hello\\bar//": alert($interpolate('\\\\foo//\\\\\\\\bar\\/\\/')({foo: "hello", bar: "world"})); }); This change effectively only changes the rendering of these escaped markers, because they are not context-aware, and are incapable of preventing nested expressions within those escaped markers from being evaluated. Therefore, backends are encouraged to ensure that when escaping expressions for security reasons, every single instance of a start or end marker have each of its characters prefixed with a backslash (REVERSE SOLIDUS, U+005C) Closes #5601 --- src/ng/interpolate.js | 50 ++++++++++++++++++++++++++++++- test/ng/interpolateSpec.js | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/ng/interpolate.js b/src/ng/interpolate.js index f3209a9800a0..a96ca10d29da 100644 --- a/src/ng/interpolate.js +++ b/src/ng/interpolate.js @@ -81,7 +81,13 @@ function $InterpolateProvider() { this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) { var startSymbolLength = startSymbol.length, - endSymbolLength = endSymbol.length; + endSymbolLength = endSymbol.length, + escapedStartRegexp = new RegExp(startSymbol.replace(/./g, escape), 'g'), + escapedEndRegexp = new RegExp(endSymbol.replace(/./g, escape), 'g'); + + function escape(ch) { + return '\\\\\\' + ch; + } /** * @ngdoc service @@ -126,6 +132,42 @@ function $InterpolateProvider() { * * `allOrNothing` is useful for interpolating URLs. `ngSrc` and `ngSrcset` use this behavior. * + * ####Escaped Interpolation + * $interpolate provides a mechanism for escaping interpolation markers. Start and end markers + * can be escaped by preceding each of their characters with a REVERSE SOLIDUS U+005C (backslash). + * It will be rendered as a regular start/end marker, and will not be interpreted as an expression + * or binding. + * + * This enables web-servers to prevent script injection attacks and defacing attacks, to some + * degree, while also enabling code examples to work without relying on the + * {@link ng.directive:ngNonBindable ngNonBindable} directive. + * + * **For security purposes, it is strongly encouraged that web servers escape user-supplied data, + * replacing angle brackets (<, >) with &lt; and &gt; respectively, and replacing all + * interpolation start/end markers with their escaped counterparts.** + * + * Escaped interpolation markers are only replaced with the actual interpolation markers in rendered + * output when the $interpolate service processes the text. So, for HTML elements interpolated + * by {@link ng.$compile $compile}, or otherwise interpolated with the `mustHaveExpression` parameter + * set to `true`, the interpolated text must contain an unescaped interpolation expression. As such, + * this is typically useful only when user-data is used in rendering a template from the server, or + * when otherwise untrusted data is used by a directive. + * + * + * + *
+ *

{{apptitle}}: \{\{ username = "some jerk"; \}\} + *

+ *

{{username}} attempts to inject code which will deface the + * application, but fails to accomplish their task, because the server has correctly + * escaped the interpolation start/end markers with REVERSE SOLIDUS U+005C (backslash) + * characters.

+ *

Instead, the result of the attempted script injection is visible, and can be removed + * from the database by an administrator.

+ *
+ *
+ *
+ * * @param {string} text The text with markup to interpolate. * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have * embedded expression in order to return an interpolation function. Strings with no @@ -176,6 +218,12 @@ function $InterpolateProvider() { } } + forEach(separators, function(key, i) { + separators[i] = separators[i]. + replace(escapedStartRegexp, startSymbol). + replace(escapedEndRegexp, endSymbol); + }); + if (separators.length === expressions.length) { separators.push(''); } diff --git a/test/ng/interpolateSpec.js b/test/ng/interpolateSpec.js index 6dd49d6bdaae..0bc767339062 100644 --- a/test/ng/interpolateSpec.js +++ b/test/ng/interpolateSpec.js @@ -61,6 +61,66 @@ describe('$interpolate', function() { })); + describe('interpolation escaping', function() { + var obj; + beforeEach(function() { + obj = {foo: 'Hello', bar: 'World'}; + }); + + + it('should support escaping interpolation signs', inject(function($interpolate) { + expect($interpolate('{{foo}} \\{\\{bar\\}\\}')(obj)).toBe('Hello {{bar}}'); + expect($interpolate('\\{\\{foo\\}\\} {{bar}}')(obj)).toBe('{{foo}} World'); + })); + + + it('should unescape multiple expressions', inject(function($interpolate) { + expect($interpolate('\\{\\{foo\\}\\}\\{\\{bar\\}\\} {{foo}}')(obj)).toBe('{{foo}}{{bar}} Hello'); + expect($interpolate('{{foo}}\\{\\{foo\\}\\}\\{\\{bar\\}\\}')(obj)).toBe('Hello{{foo}}{{bar}}'); + expect($interpolate('\\{\\{foo\\}\\}{{foo}}\\{\\{bar\\}\\}')(obj)).toBe('{{foo}}Hello{{bar}}'); + expect($interpolate('{{foo}}\\{\\{foo\\}\\}{{bar}}\\{\\{bar\\}\\}{{foo}}')(obj)).toBe('Hello{{foo}}World{{bar}}Hello'); + })); + + + it('should support escaping custom interpolation start/end symbols', function() { + module(function($interpolateProvider) { + $interpolateProvider.startSymbol('[['); + $interpolateProvider.endSymbol(']]'); + }); + inject(function($interpolate) { + expect($interpolate('[[foo]] \\[\\[bar\\]\\]')(obj)).toBe('Hello [[bar]]'); + }); + }); + + + it('should unescape incomplete escaped expressions', inject(function($interpolate) { + expect($interpolate('\\{\\{foo{{foo}}')(obj)).toBe('{{fooHello'); + expect($interpolate('\\}\\}foo{{foo}}')(obj)).toBe('}}fooHello'); + expect($interpolate('foo{{foo}}\\{\\{')(obj)).toBe('fooHello{{'); + expect($interpolate('foo{{foo}}\\}\\}')(obj)).toBe('fooHello}}'); + })); + + + it('should not unescape markers within expressions', inject(function($interpolate) { + expect($interpolate('{{"\\\\{\\\\{Hello, world!\\\\}\\\\}"}}')(obj)).toBe('\\{\\{Hello, world!\\}\\}'); + expect($interpolate('{{"\\{\\{Hello, world!\\}\\}"}}')(obj)).toBe('{{Hello, world!}}'); + expect(function() { + $interpolate('{{\\{\\{foo\\}\\}}}')(obj); + }).toThrowMinErr('$parse', 'lexerr', + 'Lexer Error: Unexpected next character at columns 0-0 [\\] in expression [\\{\\{foo\\}\\]'); + })); + + + // This test demonstrates that the web-server is responsible for escaping every single instance + // of interpolation start/end markers in an expression which they do not wish to evaluate, + // because AngularJS will not protect them from being evaluated (due to the added complexity + // and maintenance burden of context-sensitive escaping) + it('should evaluate expressions between escaped start/end symbols', inject(function($interpolate) { + expect($interpolate('\\{\\{Hello, {{bar}}!\\}\\}')(obj)).toBe('{{Hello, World!}}'); + })); + }); + + describe('interpolating in a trusted context', function() { var sce; beforeEach(function() {