Skip to content

Commit 3d6a89e

Browse files
committed
feat($parse): secure expressions by hiding "private" properties
BREAKING CHANGE: This commit introduces the notion of "private" properties (properties whose names begin and/or end with an underscore) on the scope chain. These properties will not be available to Angular expressions (i.e. {{ }} interpolation in templates and strings passed to `$parse`) They are freely available to JavaScript code (as before). Motivation ---------- Angular expressions execute in a limited context.  They do not have direct access to the global scope, Window, Document or the Function constructor.  However, they have direct access to names/properties on the scope chain.  It has been a long standing best practice to keep sensitive APIs outside of the scope chain (in a closure or your controller.)  That's easier said that done for two reasons: (1) JavaScript does not have a notion of private properties so if you need someone on the scope chain for JavaScript use, you also expose it to Angular expressions, and (2) the new "controller as" syntax that's now in increased usage exposes the entire controller on the scope chain greatly increaing the exposed surface.  Though Angular expressions are written and controlled by the developer, they (1) typically deal with user input and (2) don't get the kind of test coverage that JavaScript code would.  This commit provides a way, via a naming convention, to allow publishing/restricting properties from controllers/scopes to Angular expressions enabling one to only expose those properties that are actually needed by the expressions.
1 parent 5b62065 commit 3d6a89e

File tree

3 files changed

+140
-11
lines changed

3 files changed

+140
-11
lines changed
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@ngdoc error
2+
@name $parse:isecprv
3+
@fullName Referencing private Field in Expression
4+
5+
@description
6+
7+
Occurs when an Angular expression attempts to access a private field.
8+
9+
Fields with names that begin or end with an underscore are considered
10+
private fields.  Angular expressions are not allowed to reference such
11+
fields on the scope chain.  This only applies to Angular expressions
12+
(e.g. {{ }} interpolation and calls to `$parse` with a string expression
13+
argument) – Javascript itself has no such notion.
14+
15+
To resolve this error, use an alternate non-private field if available
16+
or make the field public (by removing any leading and trailing
17+
underscore characters from its name.)
18+
19+
Example expression that would result in this error:
20+
21+
```html
22+
<div>{{user._private_field}}</div>
23+
```
24+
25+
Background:
26+
Though Angular expressions are written and controlled by the developer
27+
and are trusted, they do represent an attack surface due to the
28+
following two factors:
29+
30+
- they typically deal with user input which is generally high risk
31+
- they often don't get the kind of attention and test coverage that
32+
JavaScript code would.
33+
34+
If these expression were evaluated in a context with full trust, an
35+
attacker, though unable to change the expression itself, can feed it
36+
unexpected and dangerous input that could result in a security
37+
breach/exploit.
38+
39+
As such, Angular expressions are evaluated in a limited context.  They
40+
do not have direct access to the global scope, Window, Document, the
41+
Function constructor or "private" properties (names beginning or ending
42+
with an underscore character) on the scope chain.  They should get their
43+
work done via public properties and methods exposed on the scope chain
44+
(keep in mind that this includes controllers as well as they are
45+
published on the scope via the "controller as" syntax.)
46+
47+
As a best practise, only "publish" properties on the scopes and
48+
controllers that must be available to Angular expressions.  All other
49+
members should either be in closures or be "private" by giving them
50+
names with a leading or trailing underscore character.

src/ng/parse.js

+25-9
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@ var promiseWarning;
88
// ------------------------------
99
// Angular expressions are generally considered safe because these expressions only have direct
1010
// access to $scope and locals. However, one can obtain the ability to execute arbitrary JS code by
11-
// obtaining a reference to native JS functions such as the Function constructor.
11+
// obtaining a reference to native JS functions such as the Function constructor, thw global Window
12+
// or Document object. In addition, many powerful functions for use by JavaScript code are
13+
// published on scope that shouldn't be available from within an Angular expression.
1214
//
1315
// As an example, consider the following Angular expression:
1416
//
1517
// {}.toString.constructor(alert("evil JS code"))
1618
//
1719
// We want to prevent this type of access. For the sake of performance, during the lexing phase we
18-
// disallow any "dotted" access to any member named "constructor".
20+
// disallow any "dotted" access to any member named "constructor" or to any member whose name begins
21+
// or ends with an underscore. The latter allows one to exclude the private / JavaScript only API
22+
// available on the scope and controllers from the context of an Angular expression.
1923
//
20-
// For reflective calls (a[b]) we check that the value of the lookup is not the Function constructor
21-
// while evaluating the expression, which is a stronger but more expensive test. Since reflective
22-
// calls are expensive anyway, this is not such a big deal compared to static dereferencing.
24+
// For reflective calls (a[b]), we check that the value of the lookup is not the Function
25+
// constructor, Window or DOM node while evaluating the expression, which is a stronger but more
26+
// expensive test. Since reflective calls are expensive anyway, this is not such a big deal compared
27+
// to static dereferencing.
2328
//
2429
// This sandboxing technique is not perfect and doesn't aim to be. The goal is to prevent exploits
2530
// against the expression language, but not to prevent exploits that were enabled by exposing
@@ -33,12 +38,20 @@ var promiseWarning;
3338
// In general, it is not possible to access a Window object from an angular expression unless a
3439
// window or some DOM object that has a reference to window is published onto a Scope.
3540

36-
function ensureSafeMemberName(name, fullExpression) {
37-
if (name === "constructor") {
41+
function ensureSafeMemberName(name, fullExpression, allowConstructor) {
42+
if (typeof name !== 'string' && toString.apply(name) !== "[object String]") {
43+
return name;
44+
}
45+
if (name === "constructor" && !allowConstructor) {
3846
throw $parseMinErr('isecfld',
3947
'Referencing "constructor" field in Angular expressions is disallowed! Expression: {0}',
4048
fullExpression);
4149
}
50+
if (name.charAt(0) === '_' || name.charAt(name.length-1) === '_') {
51+
throw $parseMinErr('isecprv',
52+
'Referencing private fields in Angular expressions is disallowed! Expression: {0}',
53+
fullExpression);
54+
}
4255
return name;
4356
}
4457

@@ -722,7 +735,10 @@ Parser.prototype = {
722735

723736
return extend(function(self, locals) {
724737
var o = obj(self, locals),
725-
i = indexFn(self, locals),
738+
// In the getter, we will not block looking up "constructor" by name in order to support user defined
739+
// constructors. However, if value looked up is the Function constructor, we will still block it in the
740+
// ensureSafeObject call right after we look up o[i] (a few lines below.)
741+
i = ensureSafeMemberName(indexFn(self, locals), parser.text, true /* allowConstructor */),
726742
v, p;
727743

728744
if (!o) return undefined;
@@ -738,7 +754,7 @@ Parser.prototype = {
738754
return v;
739755
}, {
740756
assign: function(self, value, locals) {
741-
var key = indexFn(self, locals);
757+
var key = ensureSafeMemberName(indexFn(self, locals), parser.text);
742758
// prevent overwriting of Function.constructor which would break ensureSafeObject check
743759
var safe = ensureSafeObject(obj(self, locals), parser.text);
744760
return safe[key] = value;

test/ng/parseSpec.js

+65-2
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,57 @@ describe('parser', function() {
591591
});
592592

593593
describe('sandboxing', function() {
594+
describe('private members', function() {
595+
it('should NOT allow access to private members', function() {
596+
forEach(['_name', 'name_', '_', '_name_'], function(name) {
597+
function _testExpression(expression) {
598+
scope.a = {b: name};
599+
scope[name] = {a: scope.a};
600+
scope.piece_1 = "XX" + name.charAt(0) + "XX";
601+
scope.piece_2 = "XX" + name.substr(1) + "XX";
602+
expect(function() {
603+
scope.$eval(expression);
604+
}).toThrowMinErr(
605+
'$parse', 'isecprv', 'Referencing private fields in Angular expressions is disallowed! ' +
606+
'Expression: ' + expression);
607+
}
608+
609+
function testExpression(expression) {
610+
if (expression.indexOf('"NAME"') != -1) {
611+
var concatExpr = 'piece_1.substr(2, 1) + piece_2.substr(2, LEN)'.replace('LEN', name.length-1);
612+
_testExpression(expression.replace(/"NAME"/g, concatExpr));
613+
_testExpression(expression.replace(/"NAME"/g, '(' + concatExpr + ')'));
614+
}
615+
_testExpression(expression.replace(/NAME/g, name));
616+
}
617+
618+
// Not all of these are exploitable. The tests ensure that the contract is honored
619+
// without caring about the implementation or exploitability.
620+
testExpression('NAME'); testExpression('NAME = 1');
621+
testExpression('(NAME)'); testExpression('(NAME) = 1');
622+
testExpression('a.NAME'); testExpression('a.NAME = 1');
623+
testExpression('NAME.b'); testExpression('NAME.b = 1');
624+
testExpression('a.NAME.b'); testExpression('a.NAME.b = 1');
625+
testExpression('NAME()'); testExpression('NAME() = 1');
626+
testExpression('(NAME)()'); testExpression('(NAME = 1)()');
627+
testExpression('(NAME).foo()'); testExpression('(NAME = 1).foo()');
628+
testExpression('a.NAME()'); testExpression('a.NAME() = 1');
629+
testExpression('a.NAME.foo()'); testExpression('a.NAME.foo()');
630+
testExpression('foo(NAME)'); testExpression('foo(NAME = 1)');
631+
testExpression('foo(a.NAME)'); testExpression('foo(a.NAME = 1)');
632+
testExpression('foo(1, a.NAME)'); testExpression('foo(1, a.NAME = 1)');
633+
testExpression('foo(a["NAME"])'); testExpression('foo(a["NAME"] = 1)');
634+
testExpression('foo(1, a["NAME"])'); testExpression('foo(1, a["NAME"] = 1)');
635+
testExpression('foo(b = a["NAME"])'); testExpression('foo(b = (a["NAME"] = 1))');
636+
testExpression('a["NAME"]'); testExpression('a["NAME"] = 1');
637+
testExpression('a["NAME"]()');
638+
testExpression('a["NAME"].foo()');
639+
testExpression('a.b["NAME"]'); testExpression('a.b["NAME"] = 1');
640+
testExpression('a["b"]["NAME"]'); testExpression('a["b"]["NAME"] = 1');
641+
});
642+
});
643+
});
644+
594645
describe('Function constructor', function() {
595646
it('should NOT allow access to Function constructor in getter', function() {
596647
expect(function() {
@@ -651,17 +702,29 @@ describe('parser', function() {
651702
expect(function() {
652703
scope.$eval('{}.toString["constructor"]["constructor"] = 1');
653704
}).toThrowMinErr(
654-
'$parse', 'isecfn', 'Referencing Function in Angular expressions is disallowed! ' +
705+
'$parse', 'isecfld', 'Referencing "constructor" field in Angular expressions is disallowed! ' +
655706
'Expression: {}.toString["constructor"]["constructor"] = 1');
656707

657708

658709
scope.key1 = "const";
659710
scope.key2 = "ructor";
711+
expect(function() {
712+
scope.$eval('{}.toString[key1 + key2].foo');
713+
}).toThrowMinErr(
714+
'$parse', 'isecfn', 'Referencing Function in Angular expressions is disallowed! ' +
715+
'Expression: {}.toString[key1 + key2].foo');
716+
717+
expect(function() {
718+
scope.$eval('{}.toString[key1 + key2] = 1');
719+
}).toThrowMinErr(
720+
'$parse', 'isecfld', 'Referencing "constructor" field in Angular expressions is disallowed! ' +
721+
'Expression: {}.toString[key1 + key2] = 1');
722+
660723
expect(function() {
661724
scope.$eval('{}.toString[key1 + key2].foo = 1');
662725
}).toThrowMinErr(
663726
'$parse', 'isecfn', 'Referencing Function in Angular expressions is disallowed! ' +
664-
'Expression: {}.toString[key1 + key2].foo = 1');
727+
'Expression: {}.toString[key1 + key2].foo = 1');
665728

666729
expect(function() {
667730
scope.$eval('{}.toString["constructor"]["a"] = 1');

0 commit comments

Comments
 (0)