-
Notifications
You must be signed in to change notification settings - Fork 27.4k
feat($sce): handle URLs through the $sce service, plus allow concatenation in that $sce contexts. #14250
feat($sce): handle URLs through the $sce service, plus allow concatenation in that $sce contexts. #14250
Changes from all commits
3cf8837
9e31d3c
23a5484
2b5de99
4e92679
1f867be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -121,6 +121,10 @@ function $InterpolateProvider() { | |
return unwatch; | ||
} | ||
|
||
function isConcatenationAllowed(context) { | ||
return context === $sce.URL; | ||
} | ||
|
||
/** | ||
* @ngdoc service | ||
* @name $interpolate | ||
|
@@ -262,7 +266,9 @@ function $InterpolateProvider() { | |
textLength = text.length, | ||
exp, | ||
concat = [], | ||
expressionPositions = []; | ||
expressionPositions = [], | ||
singleExpression = false, | ||
contextAllowsConcatenation = isConcatenationAllowed(trustedContext); | ||
|
||
while (index < textLength) { | ||
if (((startIndex = text.indexOf(startSymbol, index)) !== -1) && | ||
|
@@ -275,7 +281,7 @@ function $InterpolateProvider() { | |
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) { | ||
|
@@ -285,27 +291,54 @@ function $InterpolateProvider() { | |
} | ||
} | ||
|
||
if (concat.length === 1 && expressionPositions.length === 1) { | ||
singleExpression = true; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why remove the checks at this point and defer them inside There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Readability, mostly: see the other comment below. |
||
|
||
// 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-context is dispensed of this, since its getTrusted method can sanitize. | ||
// In that context, .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(''); | ||
|
||
if (contextAllowsConcatenation) { | ||
if (singleExpression) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There doesn't seem to be any benefit in having two distinct code paths (that essentially do the same thing). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's mostly for readability: I was struggling when writing this part to understand the data flows and make them clearer, that's why I preferred to split them so I could comment the paths inline. I'm also reusing That being said, I'm not that happy with it: if you have suggestions of how to write this better, I'm all ears :/ |
||
// The raw value was left as-is by parseStringifyInterceptor | ||
return $sce.getTrusted(trustedContext, concat[0]); | ||
} else { | ||
return $sce.getTrusted(trustedContext, concat.join('')); | ||
} | ||
} else if (trustedContext) { | ||
if (concat.length > 1) { | ||
// there's at least two parts, so expr + string or exp + exp, and this context | ||
// doesn't allow that. | ||
$interpolateMinErr.throwNoconcat(text); | ||
} else { | ||
return concat.join(''); | ||
} | ||
} else { // In an unprivileged context, just concatenate and return. | ||
return concat.join(''); | ||
} | ||
}; | ||
|
||
var getValue = function(value) { | ||
return trustedContext ? | ||
// 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, that's handled in | ||
// parseStringifyInterceptor below. | ||
return (trustedContext && !contextAllowsConcatenation) ? | ||
$sce.getTrusted(trustedContext, value) : | ||
$sce.valueOf(value); | ||
}; | ||
|
@@ -344,8 +377,13 @@ function $InterpolateProvider() { | |
|
||
function parseStringifyInterceptor(value) { | ||
try { | ||
value = getValue(value); | ||
return allOrNothing && !isDefined(value) ? value : stringify(value); | ||
if (contextAllowsConcatenation && singleExpression) { | ||
// No stringification in this case, to keep the trusted value until unwrapping. | ||
return value; | ||
} else { | ||
value = getValue(value); | ||
return allOrNothing && !isDefined(value) ? value : stringify(value); | ||
} | ||
} catch (err) { | ||
$exceptionHandler($interpolateMinErr.interr(text, err)); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,67 +3,41 @@ | |
/** | ||
* @this | ||
* @description | ||
* Private service to sanitize uris for links and images. Used by $compile and $sanitize. | ||
* Private service to sanitize uris for $sce.URL context. Used by $compile, $sce and $sanitize. | ||
*/ | ||
function $$SanitizeUriProvider() { | ||
var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/, | ||
imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; | ||
|
||
/** | ||
* @description | ||
* 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. | ||
* | ||
* 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. | ||
* | ||
* @param {RegExp=} regexp New regexp to whitelist urls with. | ||
* @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for | ||
* chaining otherwise. | ||
*/ | ||
this.aHrefSanitizationWhitelist = function(regexp) { | ||
if (isDefined(regexp)) { | ||
aHrefSanitizationWhitelist = regexp; | ||
return this; | ||
} | ||
return aHrefSanitizationWhitelist; | ||
}; | ||
var uriSanitizationWhitelist = /^\s*((https?|ftp|file|blob|tel|mailto):|data:image\/)/i; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm wrong there, blob shouldn't be safe for navigation. A link to a blob with user-controlled contents and a dangerous MIME type will run in the origin that created it. |
||
|
||
|
||
/** | ||
* @description | ||
* Retrieves or overrides the default regular expression that is used for whitelisting of safe | ||
* urls during img[src] sanitization. | ||
* urls. | ||
* | ||
* 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. | ||
* | ||
* 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` | ||
* Any url about to be assigned to URL context via data-binding is first normalized and turned into | ||
* an absolute url. Afterwards, the url is matched against the `urlSanitizationWhitelist` | ||
* 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. | ||
* the absolute url is prefixed with `'unsafe:'` string and only then is it written into the DOM, | ||
* making it inactive. | ||
* | ||
* @param {RegExp=} regexp New regexp to whitelist urls with. | ||
* @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for | ||
* chaining otherwise. | ||
*/ | ||
this.imgSrcSanitizationWhitelist = function(regexp) { | ||
this.uriSanitizationWhitelist = function(regexp) { | ||
if (isDefined(regexp)) { | ||
imgSrcSanitizationWhitelist = regexp; | ||
uriSanitizationWhitelist = regexp; | ||
return this; | ||
} | ||
return imgSrcSanitizationWhitelist; | ||
return uriSanitizationWhitelist; | ||
}; | ||
|
||
this.$get = function() { | ||
return function sanitizeUri(uri, isImage) { | ||
var regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist; | ||
var normalizedVal; | ||
normalizedVal = urlResolve(uri).href; | ||
if (normalizedVal !== '' && !normalizedVal.match(regex)) { | ||
return function sanitizeUri(uri) { | ||
var normalizedVal = urlResolve(uri).href; | ||
if (normalizedVal !== '' && !normalizedVal.match(uriSanitizationWhitelist)) { | ||
return 'unsafe:' + normalizedVal; | ||
} | ||
return uri; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Afaict, this change means that people using
attrs.$set(key, value)
will not benefit from sanitization (for<a href/xlink:href
or<img src/ng-src
) any more. Is that correct?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. I assumed users shouldn't use these and instead rely on attributes at the template level.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bumping this: there's actually a lot of uses of attrs.$set that would be relevant, and probably a bunch of vulnerabilities created by this PR. But then, as far as I can tell, URLs are the only things sanitized at this level, all the rest isn't :/