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

feat(http): JSONP requests now require trusted resource URLs #15143

Closed
wants to merge 2 commits into from

Conversation

petebacondarwin
Copy link
Contributor

@petebacondarwin petebacondarwin commented Sep 15, 2016

JSONP requests allow full access to the browser and the JavaScript context. So allowing a malicious attacker to make a JSONP request to an evil server could have bad results.

By requiring that JSONP urls are trusted we make it easier for developers to see that their app is only able to make JSONP requests to urls that they have audited.

There are two ways to trust:

  • whitelisting the url
  • explicitly marking a url as trusted by calling $sce.trustAs

The first commit in this PR ensures that all JSONP requests use a URL that is validated against the $sce ResourceUrl whitelist/blacklist checking. This commit creates a Breaking Change for app developers as they must now add all of their JSONP endpoints to the whitelist before their app will work. Although as significant breaking change, it is fairly simple to fix: just search for all calls to $http.jsonp and identify the url. Of course if the url is being generated dynamically then this is harder to fix but also indicates that perhaps there is a security vulnerability.

The second commit allows developers to provide explicitly trusted urls as a result of calling $sce.trustAsResourceUrl. This second approach has a few quirks:

  • the $http service needs to append parameters to the url, which means that the trusted url must be unwrapped, modified and then re-wrapped as trusted before passing it to $httpBackend.
  • it may be possible (??) that there might be an attack vector if an attacker is able to access a different endpoint by changing the parameters. But perhaps since the domain and path cannot be modified by the param serializer this is not an issue?

Is it acceptable (from a security point of view) to allow parameters to be added to a trusted url? If so then we could refactor the checks to happen in the $http service instead of $httpBackend which would remove the need for the re-wrapping of the built url.

Closes #11352
Closes #11328

@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request) and all commit authors, but as best as we can tell these commits were authored by someone else. If that's the case, please add them to this pull request and have them confirm that they're okay with these commits being contributed to Google. If we're mistaken and you did author these commits, just reply here to confirm.

1 similar comment
@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request) and all commit authors, but as best as we can tell these commits were authored by someone else. If that's the case, please add them to this pull request and have them confirm that they're okay with these commits being contributed to Google. If we're mistaken and you did author these commits, just reply here to confirm.

@@ -1293,7 +1297,7 @@ function $HttpProvider() {
reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue;
}

$httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
$httpBackend(config.method, (url === $sce.valueOf(origUrl) ? origUrl : url), reqData, done, reqHeaders, config.timeout,
Copy link
Contributor Author

@petebacondarwin petebacondarwin Sep 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this line is weird since it is saying: "if the newly built url is not the same as the original one then just use the original one." But using the original one defeats the purpose of being able to provide parameters, etc.

But then if we pass the untrusted URL through it will not be allowed by SCE inside the httpBackend, which now expects trusted URLs for JSONP.

And if we "re-trust" the built URL for JSONP requests we are undermining the security unless we can ensure that the buildUrl function cannot modify the URL in a way that allows an attack.

Given all of this, it might be easier to say that $http can only take strings as URLs, which is what is suggested by @klhuillier in #11328 (comment). That way the only way to get JSONP requests to work is if the request URL is in the SCE resource URL whitelist.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the url parameter for $http only takes strings. Is toString() meant when talking about non strings?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this line is weird since it is saying: "if the newly built url is not the same as the original one then just use the original one." But using the original one defeats the purpose of being able to provide parameters, etc.

Not quite 😃 It says: "If the newly built URL is the sameas the original one (possibly unwrapped) - in other words, if there were no parameters to be added - then use the original one (which is just a possibly wrapped version of the newly built one)."

It is true that if you want to pass parameters for JSONP requests, then an $sce-trusted URL won't help you. The resulting URL (inclusing the parameters) must be approved based on the white-/blacklist for resource URLs.
It is not an ideal solution, but I don't think we can ensure that a URL that is safe before buildUrl() will be safe after.

In any case, this behavior should be documented (if we decide to go with this implementation).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we must rely on the whitelist for parameterised requests then there is no benefit in allowing explicitly trusted urls for those requests at all.

The trouble is that many JSONP requests are going to be parameterised, right? So the majority of requests will not benefit from being able to explicitly trust their url.

So perhaps we should only allow string urls (i.e. you cannot explicitly trust them), which is a massive breaking change for JSONP requests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trouble is that many JSONP requests are going to be parameterised, right?

There are two types of "parameterized". If the user trusts the URL (including the parameters - e.g. $http.jsonp($sce.trustAsResourceUrl('url?with=params', ...)), then it could be fine.
What they can't do, is pass the parameters as config.params (e.g. $http.jsonp('url', {params: {...}})).

So perhaps we should only allow string urls (i.e. you cannot explicitly trust them), which is a massive breaking change for JSONP requests.

This particular thing is not a breaking change, since we are already restricting to string URLs, right?

// This is a pretty sensitive operation where we're allowing a script to have full access to
// our DOM and JS space. So we require that the URL satisfies SCE.RESOURCE_URL.
url = $sce.getTrustedResourceUrl(url);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this block below the next line (inside the other jsonp block).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK!

url = buildUrl(config.url, config.paramSerializer(config.params));
// origUrl could be a $sce trusted URL which used a wrapper class
origUrl = config.url,
url = $sce.valueOf(origUrl);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a check near the top of $http that will fail if requestConfig.url is not a string?
That check also needs to be tweaked to accept $sce-wrapped values.

(I haven't looked at the failed tests, but I hope some failed because of this 😃)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is! I just found it. All the tests in this PR are only testing the $httpBackend so none of the changes in this file are tested.

I am working on improving that :-)

origUrl = config.url,
url = $sce.valueOf(origUrl);

url = buildUrl(url, config.paramSerializer(config.params));
Copy link
Member

@gkalpak gkalpak Sep 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is simpler if all changes inside $http are done inside buildUrl. Something like this:

 function buildUrl(url, serializedParams) {
   if (serializedParams.length > 0) {
+    url = $sce.valueOf(url); 
     url += ((url.indexOf('?') === -1) ? '?' : '&') + serializedParams;
   }
   return url;
 }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

@@ -1293,7 +1297,7 @@ function $HttpProvider() {
reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue;
}

$httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
$httpBackend(config.method, (url === $sce.valueOf(origUrl) ? origUrl : url), reqData, done, reqHeaders, config.timeout,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this line is weird since it is saying: "if the newly built url is not the same as the original one then just use the original one." But using the original one defeats the purpose of being able to provide parameters, etc.

Not quite 😃 It says: "If the newly built URL is the sameas the original one (possibly unwrapped) - in other words, if there were no parameters to be added - then use the original one (which is just a possibly wrapped version of the newly built one)."

It is true that if you want to pass parameters for JSONP requests, then an $sce-trusted URL won't help you. The resulting URL (inclusing the parameters) must be approved based on the white-/blacklist for resource URLs.
It is not an ideal solution, but I don't think we can ensure that a URL that is safe before buildUrl() will be safe after.

In any case, this behavior should be documented (if we decide to go with this implementation).

it('should not throw error if the url is an explicitly trusted resource', function() {
expect(function() {
$backend('JSONP', $sce.trustAsResourceUrl('http://example.org/path?cb=JSON_CALLBACK'), null, callback);
}).not.toThrowMinErr('$sce', 'insecurl');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think generic .not.toThrow() expectations are preferrable, because a slight change in the error name (or a typo) render the test useless.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

Copy link
Contributor Author

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gkalpak

url = buildUrl(config.url, config.paramSerializer(config.params));
// origUrl could be a $sce trusted URL which used a wrapper class
origUrl = config.url,
url = $sce.valueOf(origUrl);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is! I just found it. All the tests in this PR are only testing the $httpBackend so none of the changes in this file are tested.

I am working on improving that :-)

origUrl = config.url,
url = $sce.valueOf(origUrl);

url = buildUrl(url, config.paramSerializer(config.params));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

@@ -1293,7 +1297,7 @@ function $HttpProvider() {
reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue;
}

$httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
$httpBackend(config.method, (url === $sce.valueOf(origUrl) ? origUrl : url), reqData, done, reqHeaders, config.timeout,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we must rely on the whitelist for parameterised requests then there is no benefit in allowing explicitly trusted urls for those requests at all.

The trouble is that many JSONP requests are going to be parameterised, right? So the majority of requests will not benefit from being able to explicitly trust their url.

So perhaps we should only allow string urls (i.e. you cannot explicitly trust them), which is a massive breaking change for JSONP requests.

// This is a pretty sensitive operation where we're allowing a script to have full access to
// our DOM and JS space. So we require that the URL satisfies SCE.RESOURCE_URL.
url = $sce.getTrustedResourceUrl(url);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK!

it('should not throw error if the url is an explicitly trusted resource', function() {
expect(function() {
$backend('JSONP', $sce.trustAsResourceUrl('http://example.org/path?cb=JSON_CALLBACK'), null, callback);
}).not.toThrowMinErr('$sce', 'insecurl');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

@petebacondarwin petebacondarwin force-pushed the pr-11623 branch 4 times, most recently from c364004 to de4a462 Compare September 16, 2016 10:51

if (lowercase(method) === 'jsonp') {
// This is a pretty sensitive operation where we're allowing a script to have full access to
// our DOM and JS space. So we require that the URL satisfies SCE.RESOURCE_URL.
url = $sce.getTrustedResourceUrl(url) || $browser.url();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we are basically only allowing JSONP requests to whitelisted URLs (no support for trusting a URL "on-the-fly").
I am fine with this, except that we should document it 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right - documentation ....

// TODO(vojta): fix the signature
return function(method, url, post, callback, headers, timeout, withCredentials, responseType, eventHandlers, uploadEventHandlers) {
url = url || $browser.url();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change? Why are we implicitly allowing JSONP requests to the current URL?
I don't expect this to be a security concern, but (a) it is most probably a programming error and (b) we are duplicating the || browser.url() part unnecessarily. Did I miss something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally moved the trust check inside the second if clause as you suggested but it broke stuff as we need to convert a null url to the current browser url after the trust check. So now I basically moved this line to lines 64 and 74 below.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we leave this url = url || browser.url() here and just add url = $sce.getTrustedResourceUrl(url) in the JSONP case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The url = url || ... has to go after the getTrustedResource so you are back to earlier when you suggested that we move the check inside the if statement.

Anyway, I am hoping that we can accept that the param changes are not a security issue, in which case we can refactor the whole thing out and move the checks out from the $httpBackend, which means that we don't need to do the re-wrapping etc

Reject JSONP requests that are not trusted by `$sce` as "ResourceUrl".

Closes angular#11352

BREAKING CHANGE

All JSONP requests now require the URL to be whitelisted with the
`$sceDelegateProvider.resourceUrlWhitelist()` method.

You configure this list in a module configuration block:

```
appModule.config(['$sceDelegateProvider', function($sceDelegateProvider) {
  $sceDelegateProvider.resourceUrlWhiteList([
    // Allow same origin resource loads.
    'self',
    // Allow JSONP calls that match this pattern
    'https://some.dataserver.com/**.jsonp?**`
  ]);
}]);
```
@mprobst
Copy link
Contributor

mprobst commented Sep 18, 2016

So if I understand this correctly, the question is how to vet the safety of a JSONP URL, and safe construction when appending parameters to it.

As far as I understand, Angular 1 always appends query parameters with ?=..., so as long as there is any original safe URL, it'll be safe to append them for the local page context (AFAIK). If your attack scenario is just whether the <script src=...> URL is safe, I think that'd be sufficient.

However many JSONP endpoints take a function name to call with the result as a parameter (e.g. ?callback=myFunction). If an attacker can inject a parameter that overrides this callback parameter, they can probably inject code into the page (e.g. ?callback=myFunction&arg1=val1&callback=evilCode).

To defend against that, I think indeed you'll have to get a trusted value for both the base URL and all of the query parameter names and their values. That might turn the API into something unusable though, $http.jsonp($sce.trustAsResourceUrl('/myurl'), {params: {a: $sce.trustAsResourceUrl(aval), b: $sce.trustAsResourceUrl(bval)}}); doesn't exactly roll off the tongue. And you'd still risk attackers controlling the parameter names.

I'm not sure if this is worth securing?

/CC @rjamet

@@ -1249,12 +1255,14 @@ function $HttpProvider() {
cache,
cachedResp,
reqHeaders = config.headers,
url = buildUrl(config.url, config.paramSerializer(config.params));
needsTrustedUrl = (lowercase(config.method) === 'jsonp'),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needsTrustedUrl is a little misleading, since you have a $sce.trustAsUrl function for another context :/ needsTrustedResourceUrl ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right thanks!

@petebacondarwin
Copy link
Contributor Author

@mprobst are you questioning whether we should attempt to secure the params or whether it is worth trying to secure JSONP at all in this way?

@rjamet
Copy link
Contributor

rjamet commented Sep 19, 2016

For the ?callback=myFunction&arg1=val1&callback=evilCode case, maybe removing the rightmost duplicate parameters after appending them would be enough?

I'm not fond of having to trust parameters either: this doesn't really make sense with the ResourceUrl semantics, a param value isn't a resource url, and getTrustedResourceUrl with its same-origin whitelist will see everything as relative URLs :/ It just sounds bad. I'm not sure of the right approach, though. Being permissive and building the request on top of a trusted URL from strings isn't great, but it might be the best option.

@mprobst
Copy link
Contributor

mprobst commented Sep 19, 2016

@petebacondarwin I think the reasonable thing to do is requiring the base URL to be a trusted resource URL, and then just accepting the fact that there's a risk factor in the callback keys and values. It's better than nothing, and at least I cannot come up with something more appealing.

I think @rjamet is of the same opinion, if I understand him correctly.

@@ -1293,7 +1301,7 @@ function $HttpProvider() {
reqHeaders[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue;
}

$httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout,
$httpBackend(config.method, needsTrustedUrl ? $sce.trustAsResourceUrl(url) : url, reqData, done, reqHeaders, config.timeout,
Copy link
Contributor

@rjamet rjamet Sep 19, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slight nit, this is the first time AngularJS calls a trustAs function in the core library. That's not wrong, but it's a weird pattern: how about a parameter that will disable the check below instead?

(selfish reason for that: I maintain a custom $sce that uses the Closure's implementation of safe types and disables trustAs calls, and this is going to need ad-hoc patching. That's doable, so if you'd rather keep it this way it's fine)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are going with not securing the params then I can do a tidy refactor on this PR that removes the need for this trustAs call

@rjamet
Copy link
Contributor

rjamet commented Sep 19, 2016

@mprobst @petebacondarwin There's an alternative: since the jsonp callback is a parameter, could these be $sce.getTrustedJs / trustAsJs ? You have the plumbing already in place. This type hasn't been used before, but since parameters there might be run as-is... But this might end up being yet another hurdle that everyone will search then copy-paste from a blog. WDYT ?

(i'm not that familiar with jsonp, so please point out if I'm wrong)

@petebacondarwin
Copy link
Contributor Author

petebacondarwin commented Sep 20, 2016

Closing in favour of #15161 - PTAL @mprobst @rjamet

petebacondarwin added a commit that referenced this pull request Oct 5, 2016
… config

The query parameter that will be used to transmit the JSONP callback to the
server is now specified via the `jsonpCallbackParam` config value, instead of
using the `JSON_CALLBACK` placeholder.

* Any use of `JSON_CALLBACK` in a JSONP request URL will cause an error.
* Any request that provides a parameter with the same name as that given
by the `jsonpCallbackParam` config property will cause an error.

This is to prevent malicious attack via the response from an app inadvertently
allowing untrusted data to be used to generate the callback parameter.

Closes #15161
Closes #15143
Closes #11352
Closes #11328

BREAKING CHANGE

You can no longer use the `JSON_CALLBACK` placeholder in your JSONP requests.
Instead you must provide the name of the query parameter that will pass the
callback via the `jsonpCallbackParam` property of the config object, or app-wide via
the `$http.defaults.jsonpCallbackParam` property, which is `"callback"` by default.

Before this change:

```
$http.json('trusted/url?callback=JSON_CALLBACK');
$http.json('other/trusted/url', {params:cb:'JSON_CALLBACK'});
```

After this change:

```
$http.json('trusted/url');
$http.json('other/trusted/url', {callbackParam:'cb'});
```
ellimist pushed a commit to ellimist/angular.js that referenced this pull request Mar 15, 2017
… config

The query parameter that will be used to transmit the JSONP callback to the
server is now specified via the `jsonpCallbackParam` config value, instead of
using the `JSON_CALLBACK` placeholder.

* Any use of `JSON_CALLBACK` in a JSONP request URL will cause an error.
* Any request that provides a parameter with the same name as that given
by the `jsonpCallbackParam` config property will cause an error.

This is to prevent malicious attack via the response from an app inadvertently
allowing untrusted data to be used to generate the callback parameter.

Closes angular#15161
Closes angular#15143
Closes angular#11352
Closes angular#11328

BREAKING CHANGE

You can no longer use the `JSON_CALLBACK` placeholder in your JSONP requests.
Instead you must provide the name of the query parameter that will pass the
callback via the `jsonpCallbackParam` property of the config object, or app-wide via
the `$http.defaults.jsonpCallbackParam` property, which is `"callback"` by default.

Before this change:

```
$http.json('trusted/url?callback=JSON_CALLBACK');
$http.json('other/trusted/url', {params:cb:'JSON_CALLBACK'});
```

After this change:

```
$http.json('trusted/url');
$http.json('other/trusted/url', {callbackParam:'cb'});
```
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

$http's JSONP should require trusted resource URLs Inconsistent handling of $sce trustedUrl with $http
7 participants