Permalink
Browse files

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:

`<span ng-init="foo='Hello'">{{foo}}, \\{\\{World!\\}\\}</span>` would be rendered as:
`<span ng-init="foo='Hello'">Hello, {{World!}}</span>`

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
Closes #7517
  • Loading branch information...
caitp committed May 20, 2014
1 parent e994259 commit e3f78c17d3b5d3a714402d7314094aabe7f6512a
Showing with 109 additions and 1 deletion.
  1. +49 −1 src/ng/interpolate.js
  2. +60 −0 test/ng/interpolateSpec.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 (&lt;, &gt;) with &amp;lt; and &amp;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.
*
* <example>
* <file name="index.html">
* <div ng-init="username='A user'">
* <p ng-init="apptitle='Escaping demo'">{{apptitle}}: \{\{ username = "some jerk"; \}\}
* </p>
* <p><strong>{{username}}</strong> 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.</p>
* <p>Instead, the result of the attempted script injection is visible, and can be removed
* from the database by an administrator.</p>
* </div>
* </file>
* </example>
*
* @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('');
}
@@ -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() {

1 comment on commit e3f78c1

@tleruitte

This comment has been minimized.

Copy link
Contributor

tleruitte commented on e3f78c1 Dec 15, 2014

Hello,
Would it be possible to backport this to angular 1.2?
Thanks

Please sign in to comment.