Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions src/components/autocomplete/autocomplete.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1214,9 +1214,11 @@ describe('<md-autocomplete>', function() {
});

describe('xss prevention', function() {

it('should not allow html to slip through', inject(function($timeout, $material) {
var html = 'foo <img src="img" onerror="alert(1)" />';
var scope = createScope([{display: html}]);
var scope = createScope([{ display: html }]);

var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
Expand All @@ -1227,6 +1229,7 @@ describe('<md-autocomplete>', function() {
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';

var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
Expand All @@ -1250,6 +1253,7 @@ describe('<md-autocomplete>', function() {

element.remove();
}));

});

describe('Async matching', function() {
Expand Down Expand Up @@ -1774,6 +1778,7 @@ describe('<md-autocomplete>', function() {
});

describe('md-highlight-text', function() {

it('updates when content is modified', inject(function() {
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {message: 'some text', query: 'some'});
Expand All @@ -1794,7 +1799,7 @@ describe('<md-autocomplete>', function() {
element.remove();
}));

it('should properly apply highlight flags', inject(function() {
it('should properly apply highlight flags', function() {
var template = '<div md-highlight-text="query" md-highlight-flags="{{flags}}">{{message}}</div>';
var scope = createScope(null, {message: 'Some text', query: 'some', flags: '^i'});
var element = compile(template, scope);
Expand Down Expand Up @@ -1826,7 +1831,93 @@ describe('<md-autocomplete>', function() {
expect(element.html()).toBe('Some text, some flag<span class="highlight">s</span>');

element.remove();
}));
});

it('should correctly parse special text identifiers', function() {
var template = '<div md-highlight-text="query">{{message}}</div>';

var scope = createScope(null, {
message: 'Angular&Material',
query: 'Angular&'
});

var element = compile(template, scope);

expect(element.html()).toBe('<span class="highlight">Angular&amp;</span>Material');

scope.query = 'Angular&Material';
scope.$apply();

expect(element.html()).toBe('<span class="highlight">Angular&amp;Material</span>');

element.remove();
});

it('should properly parse html entity identifiers', function() {
var template = '<div md-highlight-text="query">{{message}}</div>';

var scope = createScope(null, {
message: 'Angular&amp;Material',
query: ''
});

var element = compile(template, scope);

expect(element.html()).toBe('Angular&amp;amp;Material');

scope.query = 'Angular&amp;Material';
scope.$apply();

expect(element.html()).toBe('<span class="highlight">Angular&amp;amp;Material</span>');


scope.query = 'Angular&';
scope.$apply();

expect(element.html()).toBe('<span class="highlight">Angular&amp;</span>amp;Material');

element.remove();
});

it('should prevent XSS attacks from the highlight text', function() {

spyOn(window, 'alert');

var template = '<div md-highlight-text="query">{{message}}</div>';

var scope = createScope(null, {
message: 'Angular Material',
query: '<img src="img" onerror="alert(1)">'
});

var element = compile(template, scope);

expect(element.html()).toBe('Angular Material');
expect(window.alert).not.toHaveBeenCalled();

element.remove();
});

});

it('should prevent XSS attacks from the content text', function() {

spyOn(window, 'alert');

var template = '<div md-highlight-text="query">{{message}}</div>';

var scope = createScope(null, {
message: '<img src="img" onerror="alert(1)">',
query: ''
});

var element = compile(template, scope);

// Expect the image to be escaped due to XSS protection.
expect(element.html()).toBe('&lt;img src="img" onerror="alert(1)"&gt;');
expect(window.alert).not.toHaveBeenCalled();

element.remove();
});

});
138 changes: 108 additions & 30 deletions src/components/autocomplete/js/highlightController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,116 @@ angular
.controller('MdHighlightCtrl', MdHighlightCtrl);

function MdHighlightCtrl ($scope, $element, $attrs) {
this.init = init;

function init (termExpr, unsafeTextExpr) {
var text = null,
regex = null,
flags = $attrs.mdHighlightFlags || '',
watcher = $scope.$watch(function($scope) {
return {
term: termExpr($scope),
unsafeText: unsafeTextExpr($scope)
};
}, function (state, prevState) {
if (text === null || state.unsafeText !== prevState.unsafeText) {
text = angular.element('<div>').text(state.unsafeText).html();
}
if (regex === null || state.term !== prevState.term) {
regex = getRegExp(state.term, flags);
}

$element.html(text.replace(regex, '<span class="highlight">$&</span>'));
}, true);
$element.on('$destroy', watcher);
this.$scope = $scope;
this.$element = $element;
this.$attrs = $attrs;

// Cache the Regex to avoid rebuilding each time.
this.regex = null;
}

MdHighlightCtrl.prototype.init = function(unsafeTermFn, unsafeContentFn) {

this.flags = this.$attrs.mdHighlightFlags || '';

this.unregisterFn = this.$scope.$watch(function($scope) {
return {
term: unsafeTermFn($scope),
contentText: unsafeContentFn($scope)
};
}.bind(this), this.onRender.bind(this), true);

this.$element.on('$destroy', this.unregisterFn);
};

/**
* Triggered once a new change has been recognized and the highlighted
* text needs to be updated.
*/
MdHighlightCtrl.prototype.onRender = function(state, prevState) {

var contentText = state.contentText;

/* Update the regex if it's outdated, because we don't want to rebuilt it constantly. */
if (this.regex === null || state.term !== prevState.term) {
this.regex = this.createRegex(state.term, this.flags);
}

function sanitize (term) {
return term && term.toString().replace(/[\\\^\$\*\+\?\.\(\)\|\{}\[\]]/g, '\\$&');
/* If a term is available apply the regex to the content */
if (state.term) {
this.applyRegex(contentText);
} else {
this.$element.text(contentText);
}

function getRegExp (text, flags) {
var startFlag = '', endFlag = '';
if (flags.indexOf('^') >= 0) startFlag = '^';
if (flags.indexOf('$') >= 0) endFlag = '$';
return new RegExp(startFlag + sanitize(text) + endFlag, flags.replace(/[\$\^]/g, ''));
};

/**
* Decomposes the specified text into different tokens (whether match or not).
* Breaking down the string guarantees proper XSS protection due to the native browser
* escaping of unsafe text.
*/
MdHighlightCtrl.prototype.applyRegex = function(text) {
var tokens = this.resolveTokens(text);

this.$element.empty();

tokens.forEach(function (token) {

if (token.isMatch) {
var tokenEl = angular.element('<span class="highlight">').text(token.text);

this.$element.append(tokenEl);
} else {
this.$element.append(document.createTextNode(token));
}

}.bind(this));

};

/**
* Decomposes the specified text into different tokens by running the regex against the text.
*/
MdHighlightCtrl.prototype.resolveTokens = function(string) {
var tokens = [];
var lastIndex = 0;

// Use replace here, because it supports global and single regular expressions at same time.
string.replace(this.regex, function(match, index) {
appendToken(lastIndex, index);

tokens.push({
text: match,
isMatch: true
});

lastIndex = index + match.length;
});

// Append the missing text as a token.
appendToken(lastIndex);

return tokens;

function appendToken(from, to) {
var targetText = string.slice(from, to);
targetText && tokens.push(targetText);
}
}
};

/** Creates a regex for the specified text with the given flags. */
MdHighlightCtrl.prototype.createRegex = function(term, flags) {
var startFlag = '', endFlag = '';
var regexTerm = this.sanitizeRegex(term);

if (flags.indexOf('^') >= 0) startFlag = '^';
if (flags.indexOf('$') >= 0) endFlag = '$';

return new RegExp(startFlag + regexTerm + endFlag, flags.replace(/[$\^]/g, ''));
};

/** Sanitizes a regex by removing all common RegExp identifiers */
MdHighlightCtrl.prototype.sanitizeRegex = function(term) {
return term && term.toString().replace(/[\\\^\$\*\+\?\.\(\)\|\{}\[\]]/g, '\\$&');
};
4 changes: 2 additions & 2 deletions src/components/autocomplete/js/highlightDirective.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ function MdHighlight ($interpolate, $parse) {
controller: 'MdHighlightCtrl',
compile: function mdHighlightCompile(tElement, tAttr) {
var termExpr = $parse(tAttr.mdHighlightText);
var unsafeTextExpr = $interpolate(tElement.html());
var unsafeContentExpr = $interpolate(tElement.html());

return function mdHighlightLink(scope, element, attr, ctrl) {
ctrl.init(termExpr, unsafeTextExpr);
ctrl.init(termExpr, unsafeContentExpr);
};
}
};
Expand Down