Skip to content

Commit 3c39df2

Browse files
committed
feat($interpolate): enable escaping interpolated expressions
Previously, Angular would offer no proper mechanism to reveal attempted script injection attacks when users would add expressions which may be compiled by angular. This CL enables web servers to escape escaped expressions by replacing interpolation start and end markers with escpaed values (which by default are `{{{{` and `}}}}`, respectively). This also allows the application to render the content of the expression without rendering just the result of the expression. Closes angular#5601
1 parent e927193 commit 3c39df2

File tree

2 files changed

+133
-30
lines changed

2 files changed

+133
-30
lines changed

src/ng/interpolate.js

+93-30
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ var $interpolateMinErr = minErr('$interpolate');
4141
function $InterpolateProvider() {
4242
var startSymbol = '{{';
4343
var endSymbol = '}}';
44+
var escapedStartSymbol = '{{{{';
45+
var escapedEndSymbol = '}}}}';
4446

4547
/**
4648
* @ngdoc method
@@ -49,11 +51,15 @@ function $InterpolateProvider() {
4951
* Symbol to denote start of expression in the interpolated string. Defaults to `{{`.
5052
*
5153
* @param {string=} value new value to set the starting symbol to.
54+
* @param {string=} escaped new value to set the escaped starting symbol to.
5255
* @returns {string|self} Returns the symbol when used as getter and self if used as setter.
5356
*/
54-
this.startSymbol = function(value){
57+
this.startSymbol = function(value, escaped){
5558
if (value) {
5659
startSymbol = value;
60+
if (escaped) {
61+
escapedStartSymbol = escaped;
62+
}
5763
return this;
5864
} else {
5965
return startSymbol;
@@ -69,9 +75,12 @@ function $InterpolateProvider() {
6975
* @param {string=} value new value to set the ending symbol to.
7076
* @returns {string|self} Returns the symbol when used as getter and self if used as setter.
7177
*/
72-
this.endSymbol = function(value){
78+
this.endSymbol = function(value, escaped){
7379
if (value) {
7480
endSymbol = value;
81+
if (escaped) {
82+
escapedEndSymbol = escaped;
83+
}
7584
return this;
7685
} else {
7786
return endSymbol;
@@ -81,7 +90,32 @@ function $InterpolateProvider() {
8190

8291
this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) {
8392
var startSymbolLength = startSymbol.length,
84-
endSymbolLength = endSymbol.length;
93+
endSymbolLength = endSymbol.length,
94+
escapedStartLength = escapedStartSymbol.length,
95+
escapedEndLength = escapedEndSymbol.length,
96+
escapedLength = escapedStartLength + escapedEndLength,
97+
ESCAPED_EXPR_REGEXP = new RegExp(lit(escapedStartSymbol) + '((?:.|\n)*?)' + lit(escapedEndSymbol), 'm'),
98+
EXPR_REGEXP = new RegExp(lit(startSymbol) + '((?:.|\n)*?)' + lit(endSymbol), 'm');
99+
100+
function lit(str) {
101+
return str.replace(/([\(\)\[\]\{\}\+\\\^\$\.\!\?\*\=\:\|\-])/g, function(op) {
102+
return '\\' + op;
103+
});
104+
}
105+
106+
function Piece(text, isExpr) {
107+
this.text = text;
108+
this.isExpr = isExpr;
109+
}
110+
111+
function addPiece(text, isExpr, pieces) {
112+
var lastPiece = pieces.length ? pieces[pieces.length - 1] : null;
113+
if (!isExpr && lastPiece && !lastPiece.isExpr) {
114+
lastPiece.text += text;
115+
} else {
116+
pieces.push(new Piece(text, isExpr, pieces));
117+
}
118+
}
85119

86120
/**
87121
* @ngdoc service
@@ -152,34 +186,67 @@ function $InterpolateProvider() {
152186
textLength = text.length,
153187
hasInterpolation = false,
154188
hasText = false,
155-
exp,
156-
concat = [],
157-
lastValuesCache = { values: {}, results: {}};
158-
159-
while(index < textLength) {
160-
if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) &&
161-
((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) {
162-
if (index !== startIndex) hasText = true;
163-
separators.push(text.substring(index, startIndex));
164-
exp = text.substring(startIndex + startSymbolLength, endIndex);
165-
expressions.push(exp);
166-
parseFns.push($parse(exp));
167-
index = endIndex + endSymbolLength;
189+
exp = text,
190+
concat,
191+
lastValuesCache = { values: {}, results: {}},
192+
pieces = [],
193+
piece,
194+
indexes = [];
195+
196+
while (text.length) {
197+
var expr = EXPR_REGEXP.exec(text);
198+
var escaped = ESCAPED_EXPR_REGEXP.exec(text);
199+
var until = text.length;
200+
var chunk;
201+
var from = 0;
202+
203+
if (expr) {
204+
until = expr.index;
205+
}
206+
207+
if (escaped && escaped.index <= until) {
208+
from = escaped.index;
209+
until = escaped.index + escaped[0].length;
210+
escaped = startSymbol + escaped[1] + endSymbol;
211+
expr = null;
212+
}
213+
214+
if (until > 0) {
215+
chunk = isString(escaped) ? text.substring(0, from) + escaped : text.substring(0, until);
216+
text = text.slice(until);
217+
addPiece(chunk, false, pieces);
218+
separators.push(chunk);
219+
hasText = true;
220+
} else {
221+
separators.push('');
222+
}
223+
224+
if (expr) {
225+
text = text.slice(expr[0].length);
226+
addPiece(expr[1], true, pieces);
227+
expr = null;
168228
hasInterpolation = true;
229+
}
230+
}
231+
232+
concat = new Array(pieces.length);
233+
for (index = 0; index < pieces.length; ++index) {
234+
piece = pieces[index];
235+
if (piece.isExpr) {
236+
parseFns.push($parse(piece.text));
237+
expressions.push(piece.text);
238+
indexes.push(index);
169239
} else {
170-
// we did not find an interpolation, so we have to add the remainder to the separators array
171-
if (index !== textLength) {
172-
hasText = true;
173-
separators.push(text.substring(index));
174-
}
175-
break;
240+
concat[index] = piece.text;
176241
}
177242
}
178243

179244
if (separators.length === expressions.length) {
180245
separators.push('');
181246
}
182247

248+
pieces = null;
249+
183250
// Concatenating expressions makes it hard to reason about whether some combination of
184251
// concatenated values are unsafe to use and could easily lead to XSS. By requiring that a
185252
// single expression be used for iframe[src], object[src], etc., we ensure that the value
@@ -190,18 +257,14 @@ function $InterpolateProvider() {
190257
throw $interpolateMinErr('noconcat',
191258
"Error while interpolating: {0}\nStrict Contextual Escaping disallows " +
192259
"interpolations that concatenate multiple expressions when a trusted value is " +
193-
"required. See http://docs.angularjs.org/api/ng.$sce", text);
260+
"required. See http://docs.angularjs.org/api/ng.$sce", exp);
194261
}
195262

196263
if (!mustHaveExpression || hasInterpolation) {
197-
concat.length = separators.length + expressions.length;
198-
199264
var compute = function(values) {
200265
for(var i = 0, ii = expressions.length; i < ii; i++) {
201-
concat[2*i] = separators[i];
202-
concat[(2*i)+1] = values[i];
266+
concat[indexes[i]] = values[i];
203267
}
204-
concat[2*ii] = separators[ii];
205268
return concat.join('');
206269
};
207270

@@ -278,15 +341,15 @@ function $InterpolateProvider() {
278341
lastValuesCache.results[scopeId] = lastResult = compute(values);
279342
}
280343
} catch(err) {
281-
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text,
344+
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", exp,
282345
err.toString());
283346
$exceptionHandler(newErr);
284347
}
285348

286349
return lastResult;
287350
}, {
288351
// all of these properties are undocumented for now
289-
exp: text, //just for compatibility with regular watchers created via $watch
352+
exp: exp, //just for compatibility with regular watchers created via $watch
290353
separators: separators,
291354
expressions: expressions
292355
});

test/ng/interpolateSpec.js

+40
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ describe('$interpolate', function() {
6161
}));
6262

6363

64+
describe('interpolation escaping', function() {
65+
var obj;
66+
67+
beforeEach(function() {
68+
obj = {foo: 'Hello', bar: 'World'};
69+
});
70+
71+
it('should support escaping interpolation signs', inject(function($interpolate) {
72+
expect($interpolate('{{foo}} {{{{bar}}}}')(obj)).toBe('Hello {{bar}}');
73+
expect($interpolate('{{{{foo}}}} {{bar}}')(obj)).toBe('{{foo}} World');
74+
}));
75+
76+
77+
it('should unescape multiple expressions', inject(function($interpolate) {
78+
expect($interpolate('{{{{foo}}}}{{{{bar}}}} {{foo}}')(obj)).toBe('{{foo}}{{bar}} Hello');
79+
}));
80+
81+
82+
it('should support customizing escape signs', function() {
83+
module(function($interpolateProvider) {
84+
$interpolateProvider.startSymbol('{{', '[[');
85+
$interpolateProvider.endSymbol('}}', ']]');
86+
});
87+
inject(function($interpolate) {
88+
expect($interpolate('{{foo}} [[bar]]')(obj)).toBe('Hello {{bar}}');
89+
});
90+
});
91+
92+
it('should support customizing escape signs which contain interpolation signs', function() {
93+
module(function($interpolateProvider) {
94+
$interpolateProvider.startSymbol('{{', '-{{-');
95+
$interpolateProvider.endSymbol('}}', '-}}-');
96+
});
97+
inject(function($interpolate) {
98+
expect($interpolate('{{foo}} -{{-bar-}}-')(obj)).toBe('Hello {{bar}}');
99+
});
100+
});
101+
});
102+
103+
64104
describe('interpolating in a trusted context', function() {
65105
var sce;
66106
beforeEach(function() {

0 commit comments

Comments
 (0)