Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
feat($sce): handle URL sanitization through the $sce service
Browse files Browse the repository at this point in the history
Thanks to @rjamet for the original work on this feature.

This is a large patch to handle URLs with the $sce service, similarly to HTML context.

Where we previously sanitized URL attributes when setting attribute value inside the
`$compile` service, we now only apply an `$sce` context requirement and leave the
`$interpolate` service to deal with sanitization.

This commit introduces a new `$sce` context called `MEDIA_URL`, which represents
a URL used as a source for a media element that is not expected to execute code, such as
image, video, audio, etc.
The context hierarchy is setup so that a value trusted as `URL` is also trusted in the
`MEDIA_URL` context, in the same way that the a value trusted as `RESOURCE_URL` is also
trusted in the `URL` context (and transitively also the `MEDIA_URL` context).

The `$sce` service will now automatically attempt to sanitize non-trusted values that
require the `URL` or `MEDIA_URL` context:

* When calling `getTrustedMediaUrl()` a value that is not already a trusted `MEDIA_URL`
will be sanitized using the `imgSrcSanitizationWhitelist`.
* When calling `getTrustedUrl()` a value that is not already a trusted `URL` will be
sanitized using the `aHrefSanitizationWhitelist`.

This results in behaviour that closely matches the previous sanitization behaviour.

To keep rough compatibility with existing apps, we need to allow concatenation of values
that may contain trusted contexts. The following approach is taken for situations that
require a `URL` or `MEDIA_URL` secure context:

* A single trusted value is trusted, e.g. `"{{trustedUrl}}"` and will not be sanitized.
* A single non-trusted value, e.g. `"{{ 'javascript:foo' }}"`, will be handled by
  `getTrustedMediaUrl` or `getTrustedUrl)` and sanitized.
* Any concatenation of values (which may or may not be trusted) results in a
  non-trusted type that will be handled by `getTrustedMediaUrl` or `getTrustedUrl` once the
  concatenation is complete.
  E.g. `"javascript:{{safeType}}"` is a concatenation of a non-trusted and a trusted value,
  which will be sanitized as a whole after unwrapping the `safeType` value.
* An interpolation containing no expressions will still be handled by `getTrustedMediaUrl` or
  `getTrustedUrl`, whereas before this would have been short-circuited in the `$interpolate`
  service. E.g. `"some/hard/coded/url"`. This ensures that `ngHref` and similar directives
  still securely, even if the URL is hard-coded into a template or index.html (perhaps by
  server-side rendering).

BREAKING CHANGES:

If you use `attrs.$set` for URL attributes (a[href] and img[src]) there will no
longer be any automated sanitization of the value. This is in line with other
programmatic operations, such as writing to the innerHTML of an element.

If you are programmatically writing URL values to attributes from untrusted
input then you must sanitize it yourself. You could write your own sanitizer or copy
the private `$$sanitizeUri` service.

Note that values that have been passed through the `$interpolate` service within the
`URL` or `MEDIA_URL` will have already been sanitized, so you would not need to sanitize
these values again.
  • Loading branch information
petebacondarwin committed Jan 31, 2018
1 parent a8830d2 commit 1e9eadc
Show file tree
Hide file tree
Showing 15 changed files with 824 additions and 470 deletions.
12 changes: 12 additions & 0 deletions docs/content/error/$compile/srcset.ngdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@ngdoc error
@name $compile:srcset
@fullName Invalid value passed to `attr.$set('srcset', value)`
@description

This error occurs if you try to programmatically set the `srcset` attribute with a non-string value.

This can be the case if you tried to avoid the automatic sanitization of the `srcset` value by
passing a "trusted" value provided by calls to `$sce.trustAsMediaUrl(value)`.

If you want to programmatically set explicitly trusted unsafe URLs, you should use `$sce.trustAsHtml`
on the whole `img` tag and inject it into the DOM using the `ng-bind-html` directive.
48 changes: 31 additions & 17 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -1528,9 +1528,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {

this.$get = [
'$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse',
'$controller', '$rootScope', '$sce', '$animate', '$$sanitizeUri',
'$controller', '$rootScope', '$sce', '$animate',
function($injector, $interpolate, $exceptionHandler, $templateRequest, $parse,
$controller, $rootScope, $sce, $animate, $$sanitizeUri) {
$controller, $rootScope, $sce, $animate) {

var SIMPLE_ATTR_NAME = /^\w/;
var specialAttrHolder = window.document.createElement('div');
Expand Down Expand Up @@ -1679,8 +1679,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
*/
$set: function(key, value, writeAttr, attrName) {
// TODO: decide whether or not to throw an error if "class"
//is set through this function since it may cause $updateClass to
//become unstable.
// is set through this function since it may cause $updateClass to
// become unstable.

var node = this.$$element[0],
booleanKey = getBooleanAttrName(node, key),
Expand Down Expand Up @@ -1710,13 +1710,20 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {

nodeName = nodeName_(this.$$element);

if ((nodeName === 'a' && (key === 'href' || key === 'xlinkHref')) ||
(nodeName === 'img' && key === 'src') ||
(nodeName === 'image' && key === 'xlinkHref')) {
// sanitize a[href] and img[src] values
this[key] = value = $$sanitizeUri(value, nodeName === 'img' || nodeName === 'image');
} else if (nodeName === 'img' && key === 'srcset' && isDefined(value)) {
// sanitize img[srcset] values
// Sanitize img[srcset] values.
if (nodeName === 'img' && key === 'srcset' && value) {
if (!isString(value)) {
throw $compileMinErr('srcset', 'Can\'t pass trusted values to `$set(\'srcset\', value)`: "{0}"', value.toString());
}

// Such values are a bit too complex to handle automatically inside $sce.
// Instead, we sanitize each of the URIs individually, which works, even dynamically.

// It's not possible to work around this using `$sce.trustAsMediaUrl`.
// If you want to programmatically set explicitly trusted unsafe URLs, you should use
// `$sce.trustAsHtml` on the whole `img` tag and inject it into the DOM using the
// `ng-bind-html` directive.

var result = '';

// first check if there are spaces because it's not the same pattern
Expand All @@ -1733,16 +1740,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
for (var i = 0; i < nbrUrisWith2parts; i++) {
var innerIdx = i * 2;
// sanitize the uri
result += $$sanitizeUri(trim(rawUris[innerIdx]), true);
result += $sce.getTrustedMediaUrl(trim(rawUris[innerIdx]));
// add the descriptor
result += (' ' + trim(rawUris[innerIdx + 1]));
result += ' ' + trim(rawUris[innerIdx + 1]);
}

// split the last item into uri and descriptor
var lastTuple = trim(rawUris[i * 2]).split(/\s/);

// sanitize the last uri
result += $$sanitizeUri(trim(lastTuple[0]), true);
result += $sce.getTrustedMediaUrl(trim(lastTuple[0]));

// and add the last descriptor if any
if (lastTuple.length === 2) {
Expand Down Expand Up @@ -3268,14 +3275,18 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
}
var tag = nodeName_(node);
// All tags with src attributes require a RESOURCE_URL value, except for
// img and various html5 media tags.
// img and various html5 media tags, which require the MEDIA_URL context.
if (attrNormalizedName === 'src' || attrNormalizedName === 'ngSrc') {
if (['img', 'video', 'audio', 'source', 'track'].indexOf(tag) === -1) {
return $sce.RESOURCE_URL;
}
return $sce.MEDIA_URL;
} else if (attrNormalizedName === 'xlinkHref') {
// Some xlink:href are okay, most aren't
if (tag === 'image') return $sce.MEDIA_URL;
if (tag === 'a') return $sce.URL;
return $sce.RESOURCE_URL;
} else if (
// Some xlink:href are okay, most aren't
(attrNormalizedName === 'xlinkHref' && (tag !== 'image' && tag !== 'a')) ||
// Formaction
(tag === 'form' && attrNormalizedName === 'action') ||
// If relative URLs can go where they are not expected to, then
Expand All @@ -3285,6 +3296,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
(tag === 'link' && attrNormalizedName === 'href')
) {
return $sce.RESOURCE_URL;
} else if (tag === 'a' && (attrNormalizedName === 'href' ||
attrNormalizedName === 'ngHref')) {
return $sce.URL;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/ng/directive/attrs.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ forEach(['src', 'srcset', 'href'], function(attrName) {
// On IE, if "ng:src" directive declaration is used and "src" attribute doesn't exist
// then calling element.setAttribute('src', 'foo') doesn't do anything, so we need
// to set the property as well to achieve the desired effect.
// We use attr[attrName] value since $set can sanitize the url.
// We use attr[attrName] value since $set might have sanitized the url.
if (msie && propName) element.prop(propName, attr[name]);
});
}
Expand Down
75 changes: 50 additions & 25 deletions src/ng/interpolate.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,16 +238,21 @@ function $InterpolateProvider() {
* - `context`: evaluation context for all expressions embedded in the interpolated text
*/
function $interpolate(text, mustHaveExpression, trustedContext, allOrNothing) {
var contextAllowsConcatenation = trustedContext === $sce.URL || trustedContext === $sce.MEDIA_URL;

// Provide a quick exit and simplified result function for text with no interpolation
if (!text.length || text.indexOf(startSymbol) === -1) {
var constantInterp;
if (!mustHaveExpression) {
var unescapedText = unescapeText(text);
constantInterp = valueFn(unescapedText);
constantInterp.exp = text;
constantInterp.expressions = [];
constantInterp.$$watchDelegate = constantWatchDelegate;
if (mustHaveExpression && !contextAllowsConcatenation) return;

var unescapedText = unescapeText(text);
if (contextAllowsConcatenation) {
unescapedText = $sce.getTrusted(trustedContext, unescapedText);
}
var constantInterp = valueFn(unescapedText);
constantInterp.exp = text;
constantInterp.expressions = [];
constantInterp.$$watchDelegate = constantWatchDelegate;

return constantInterp;
}

Expand All @@ -256,11 +261,13 @@ function $InterpolateProvider() {
endIndex,
index = 0,
expressions = [],
parseFns = [],
parseFns,
textLength = text.length,
exp,
concat = [],
expressionPositions = [];
expressionPositions = [],
singleExpression;


while (index < textLength) {
if (((startIndex = text.indexOf(startSymbol, index)) !== -1) &&
Expand All @@ -270,10 +277,9 @@ function $InterpolateProvider() {
}
exp = text.substring(startIndex + startSymbolLength, endIndex);
expressions.push(exp);
parseFns.push($parse(exp, parseStringifyInterceptor));
index = endIndex + endSymbolLength;
expressionPositions.push(concat.length);
concat.push('');
concat.push(''); // Placeholder that will get replaced with the evaluated expression.
} else {
// we did not find an interpolation, so we have to add the remainder to the separators array
if (index !== textLength) {
Expand All @@ -283,29 +289,42 @@ function $InterpolateProvider() {
}
}

singleExpression = concat.length === 1 && expressionPositions.length === 1;
// Intercept expression if we need to stringify concatenated inputs, which may be SCE trusted
// objects rather than simple strings
// (we don't modify the expression if the input consists of only a single trusted input)
var interceptor = contextAllowsConcatenation && singleExpression ? undefined : parseStringifyInterceptor;
parseFns = expressions.map(function(exp) { return $parse(exp, interceptor); });

// Concatenating expressions makes it hard to reason about whether some combination of
// concatenated values are unsafe to use and could easily lead to XSS. By requiring that a
// single expression be used for iframe[src], object[src], etc., we ensure that the value
// that's used is assigned or constructed by some JS code somewhere that is more testable or
// make it obvious that you bound the value to some user controlled value. This helps reduce
// the load when auditing for XSS issues.
if (trustedContext && concat.length > 1) {
$interpolateMinErr.throwNoconcat(text);
}
// single expression be used for some $sce-managed secure contexts (RESOURCE_URLs mostly),
// we ensure that the value that's used is assigned or constructed by some JS code somewhere
// that is more testable or make it obvious that you bound the value to some user controlled
// value. This helps reduce the load when auditing for XSS issues.

// Note that URL and MEDIA_URL $sce contexts do not need this, since `$sce` can sanitize the values
// passed to it. In that case, `$sce.getTrusted` will be called on either the single expression
// or on the overall concatenated string (losing trusted types used in the mix, by design).
// Both these methods will sanitize plain strings. Also, HTML could be included, but since it's
// only used in srcdoc attributes, this would not be very useful.

if (!mustHaveExpression || expressions.length) {
var compute = function(values) {
for (var i = 0, ii = expressions.length; i < ii; i++) {
if (allOrNothing && isUndefined(values[i])) return;
concat[expressionPositions[i]] = values[i];
}
return concat.join('');
};

var getValue = function(value) {
return trustedContext ?
$sce.getTrusted(trustedContext, value) :
$sce.valueOf(value);
if (contextAllowsConcatenation) {
// If `singleExpression` then `concat[0]` might be a "trusted" value or `null`, rather than a string
return $sce.getTrusted(trustedContext, singleExpression ? concat[0] : concat.join(''));
} else if (trustedContext && concat.length > 1) {
// This context does not allow more than one part, e.g. expr + string or exp + exp.
$interpolateMinErr.throwNoconcat(text);
}
// In an unprivileged context or only one part: just concatenate and return.
return concat.join('');
};

return extend(function interpolationFn(context) {
Expand Down Expand Up @@ -340,7 +359,13 @@ function $InterpolateProvider() {

function parseStringifyInterceptor(value) {
try {
value = getValue(value);
// In concatenable contexts, getTrusted comes at the end, to avoid sanitizing individual
// parts of a full URL. We don't care about losing the trustedness here.
// In non-concatenable contexts, where there is only one expression, this interceptor is
// not applied to the expression.
value = (trustedContext && !contextAllowsConcatenation) ?
$sce.getTrusted(trustedContext, value) :
$sce.valueOf(value);
return allOrNothing && !isDefined(value) ? value : stringify(value);
} catch (err) {
$exceptionHandler($interpolateMinErr.interr(text, err));
Expand Down
38 changes: 24 additions & 14 deletions src/ng/sanitizeUri.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Private service to sanitize uris for links and images. Used by $compile and $sanitize.
*/
function $$SanitizeUriProvider() {

var aHrefSanitizationWhitelist = /^\s*(https?|s?ftp|mailto|tel|file):/,
imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/;

Expand All @@ -14,12 +15,16 @@ function $$SanitizeUriProvider() {
* Retrieves or overrides the default regular expression that is used for whitelisting of safe
* urls during a[href] sanitization.
*
* The sanitization is a security measure aimed at prevent XSS attacks via html links.
* The sanitization is a security measure aimed at prevent XSS attacks via HTML anchor links.
*
* Any url due to be assigned to an `a[href]` attribute via interpolation is marked as requiring
* the $sce.URL security context. When interpolation occurs a call is made to `$sce.trustAsUrl(url)`
* which in turn may call `$$sanitizeUri(url, isMedia)` to sanitize the potentially malicious URL.
*
* If the URL matches the `aHrefSanitizationWhitelist` regular expression, it is returned unchanged.
*
* Any url about to be assigned to a[href] via data-binding is first normalized and turned into
* an absolute url. Afterwards, the url is matched against the `aHrefSanitizationWhitelist`
* regular expression. If a match is found, the original url is written into the dom. Otherwise,
* the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM.
* If there is no match the URL is returned prefixed with `'unsafe:'` to ensure that when it is written
* to the DOM it is inactive and potentially malicious code will not be executed.
*
* @param {RegExp=} regexp New regexp to whitelist urls with.
* @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for
Expand All @@ -39,12 +44,17 @@ function $$SanitizeUriProvider() {
* Retrieves or overrides the default regular expression that is used for whitelisting of safe
* urls during img[src] sanitization.
*
* The sanitization is a security measure aimed at prevent XSS attacks via html links.
* The sanitization is a security measure aimed at prevent XSS attacks via HTML image src links.
*
* Any URL due to be assigned to an `img[src]` attribute via interpolation is marked as requiring
* the $sce.MEDIA_URL security context. When interpolation occurs a call is made to
* `$sce.trustAsMediaUrl(url)` which in turn may call `$$sanitizeUri(url, isMedia)` to sanitize
* the potentially malicious URL.
*
* If the URL matches the `aImgSanitizationWhitelist` regular expression, it is returned unchanged.
*
* Any url about to be assigned to img[src] via data-binding is first normalized and turned into
* an absolute url. Afterwards, the url is matched against the `imgSrcSanitizationWhitelist`
* regular expression. If a match is found, the original url is written into the dom. Otherwise,
* the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM.
* If there is no match the URL is returned prefixed with `'unsafe:'` to ensure that when it is written
* to the DOM it is inactive and potentially malicious code will not be executed.
*
* @param {RegExp=} regexp New regexp to whitelist urls with.
* @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for
Expand All @@ -59,10 +69,10 @@ function $$SanitizeUriProvider() {
};

this.$get = function() {
return function sanitizeUri(uri, isImage) {
var regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist;
var normalizedVal;
normalizedVal = urlResolve(uri && uri.trim()).href;
return function sanitizeUri(uri, isMediaUrl) {
// if (!uri) return uri;
var regex = isMediaUrl ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist;
var normalizedVal = urlResolve(uri && uri.trim()).href;
if (normalizedVal !== '' && !normalizedVal.match(regex)) {
return 'unsafe:' + normalizedVal;
}
Expand Down
Loading

0 comments on commit 1e9eadc

Please sign in to comment.