Problem/Motivation
The Field UI module uses a "destinations" query string parameter in URLs to redirect users to new destinations after completing an action on a few administration pages. Under certain circumstances, malicious users can use this parameter to construct a URL that will trick users into being redirected to a 3rd party website, thereby exposing the users to potential social engineering attacks.
Proposed resolution
- Let the RedirectResponseSubscriber check for external URLs by default
- Use a special class to opt out of the external URL redirect checkingAda
Remaining tasks
User interface changes
API changes
Data model changes
Comment | File | Size | Author |
---|---|---|---|
#97 | interdiff.txt | 3.57 KB | dawehner |
#97 | 2507831-97.patch | 25.01 KB | dawehner |
#83 | interdiff.txt | 2.84 KB | dawehner |
#83 | 2507831-83.patch | 25.05 KB | dawehner |
#81 | interdiff.txt | 1.03 KB | dawehner |
Comments
Comment #1
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedFor reference, the Drupal 7 patch and tests can be found here:
http://cgit.drupalcode.org/drupal/commit/?id=5cb79b4b217e9aa315d61284398...
(the relevant changes are to the field_ui module)
Credit for that Drupal 7 fix (from https://www.drupal.org/SA-CORE-2015-002) is:
yched, DamienMcKenna, Pere Orga, David_Rothstein, klausi
Comment #2
tim.plunkettThis is a forward port (plus a unit test), but this won't work anymore, as we allow things like ?destinations[][route_name]=entity.field_config.node_field_edit_form and such.
Comment #3
tim.plunkettActually, that's easy enough. If it is a nested array, then it is a route name, and must be internal.
Comment #4
tim.plunkettI think this should be it.
Comment #5
dawehnerDoes that mean that
\Drupal\Core\EventSubscriber\RedirectResponseSubscriber::checkRedirectUrl
does not effectively protected against redirects to external URLs?I think this should be the proper place to fix it.
Comment #6
tim.plunkettcheckRedirectUrl only looks at
$request->query->get('destination')
Field UI uses
$request->query->get('destinations')
Comment #7
catch@Tim, yes but Field UI sets $form_state->setRedirectUrl(), which eventually then becomes a RedirectResponse.
Even if we don't do the check in RedirectResponseSubscriber, I'd expect us to do it in FormState no?
Comment #8
dawehnerRight so we do set it as part of
core/modules/field_ui/src/Form/FieldStorageAddForm.php:422
for example.Somewhere, like in FinishResponseSubscriber or somewhere else we could explicitly check and then maybe allow to opt IN for redirects across domains, but by default we should really not allow it. Maybe opt in could be done using a custom subclass of the RedirectResponse class, which we could set a new property on.
Comment #9
tim.plunkettIt could look like this. But I think we should combine this with the patch in #6, as that's more friendly IMO.
Comment #10
tim.plunkettComment #11
dawehnerWhat about this more deep in protected outside of FAPI? This even protects against custom code setting RedirectResponse objects ...
Comment #12
dawehnerThis time with the entire patch ..
Comment #15
dawehnerExpanded and fixed the test coverage as well as merged in https://www.drupal.org/files/issues/2507831-field_ui-9.patch
Comment #17
dawehnerLet's fix things ...
Comment #18
catchI like the opt-in at the response level a lot.
Comment #19
tim.plunkettLet's add in the updated Field UI coverage (it does not redirect at all now)
Comment #20
dawehnerAdapted the IS
Comment #21
catchReading the new test coverage, that brings up another issue.
It's a bit obscure, but I've seen the following before on a high traffic Drupal 7 site.
- Had several web servers behind a load balancer
- The load balancer was configured to take a web server out of rotation if it kept returning 500s
- image derivatives for various reasons including #927138: Fail image generation with 404 instead of 500, when source file is missing would return 500s to crawlers and/or real users
- all of the webservers would be taken out then put back into rotation, bringing the site down.
This would require a form submit, but for example a site with anonymous commenting you could script something to trigger enough 500s to do that.
So given all of that, I think we should do something other than throw the exception, like a trigger_error() and maybe ignoring the redirect altogether for the end user.
Comment #22
dawehnerMh, so you should never be able to trigger 500s if possible at least not in an easy way?
Comment #23
catchWell I think that load balancing configuration should probably not be used for this reason - something like varnish health checking is much better. But that particular problem made me paranoid about allowing any kind of regular user to trigger 500s easily, yes.
Also in this case the site isn't broken at all, just someone's visited a naughtily crafted link, so 500 semantically isn't the correct error code anyway.
Comment #24
tim.plunkettIt's nice to have one tag to find all of the public critical security issues, and this is on 5 of them already.
Comment #25
tim.plunkettOpened #2508679: Fix empty redirects and redirects with options in \Drupal\field_ui\FieldUI::getNextDestination() to address the other parts that aren't being addressed anymore.
Comment #26
dawehnerReading through the list on https://developer.mozilla.org/en-US/docs/Web/HTTP/Response_codes I think 400 is the most appropriate one.
Anything else left here then?
Comment #27
catchNot much from me:
400. Assuming tests will fail but making note just in case.
Missing docblock.
Comment #29
dawehnerAssuming you are right here.
Comment #31
dawehnerI'm not really happy about the solution to try catch the entire thing, but well, this at least catches the problems.
Comment #33
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedInteresting approach. But I think RedirectResponseToExternalUrl is a confusing name, because you would actually use it whenever you have trusted input (that might be external), not just when you know it is external.
It would be easier to name and more backwards-compatible (although a little less comprehensive security-wise) if it were the other way around, e.g. use RedirectResponseInternal when you know that an external URL should not be allowed. This approach would be worth considering for Drupal 7 backport.
This would be better if the "instanceof" check were first in the list.
Comment #34
catchI think we explicitly want to break redirects to external URLs, and require existing and new code to opt-in here, at least for 8.x.
What about RedirectReponseAllowExternalUrl for the name?
Comment #35
dawehnerMh, so the problem here is that we are dealing with code which is both triggered as part of the normal flow as well as exception handling.
During exception handling, in that case even at the end, it is kinda a bad idea to throw another exception.
Let's go with the approach of catch earlier, throw an error ...
This is the patch uploaded so far.
Comment to #33
I see, so this basically means, in every spot we create a response object coming from the destination(s) query string, we use that object. That seems to be good enough to cover at least core issues. I guess its still okay to be potentially less secure for some custom usecases, but well, its not less secure as HEAD.
You are concerned about the backward-compatible aspect. Do you feel strong about it? I agree for Drupal 7 be BC compliant is the way to go, but for Drupal 8 being safe first, seems to be the way to move forward. I think we could find a better name?
On the other hand, opt IN to protection would have a potential better support for 3rd party code dealing with redirect responses, not sure whether it matters a lot though.
I guess the backport would mean that we take the same approach. For query strings, we opt into a protection mechansim
Comment #36
dawehnerThat seems indeed better!
Comment #39
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commented"RedirectResponseAllowExternalUrl" is a mouthful, but works :)
I just thought Drupal 8 was aiming for backwards-compatibility at this point, otherwise don't really care about the opt-in vs opt-out myself.
That's a good question, though, given that RedirectResponse is a Symfony thing, not a Drupal thing...
Comment #40
dawehnerFair point, I honestly prefer security first and a BC break for it, but I just call out to the framework maintainer hat of catch.
This fixes the failing test. We need to ensure that the specific error is thrown and then remove it from the list of active assertions.
Comment #41
catchRight I feel like here the current API is the root cause of the security issue, so changing the API to make it impossible to have open redirect by accident is the right choice.
If third party code is providing a RedirectResponse and that's being handled by our RedirectResponseSubscriber, then I think opt-in is better here too. Some Drupal code must be integrating with that code to get the RedirectResponse back, and that can then convert it to RedirectResponseAllowExternalUrl if it needs to.
Comment #42
dawehnerWell my point was that you can't write code which is agnostic of Drupal and redirect, because you'd need a dependency to Drupal.
I think for now its okay to live with that kind of limitation.
Comment #43
dawehnerGiven that external code needs some form of integration anyway I think its a small price to pay for better security by default.
Comment #44
effulgentsia CreditAttribution: effulgentsia at Acquia commentedUnused.
Can we make this a separate subscriber? Maybe rename the existing one to DestinationSubscriber and the new logic to either PreventOpenRedirectSubscriber or SafeRedirectSubscriber? That would allow projects concerned with #42 to swap out this new piece more easily.
s/URl/URL/
Maybe rename to TrustedRedirectResponse?
Comment #45
effulgentsia CreditAttribution: effulgentsia at Acquia commentedThinking more about RedirectResponseAllowExternalUrl/TrustedRedirectResponse, something else I'm uneasy with about this class is that it doesn't override setTargetUrl(). Which means one piece of code can create an instance with a truly trusted URL, and then another piece of code can call setTargetUrl() on it with user input, and then the protection added by this patch is bypassed.
For example, it's kind of an implementation detail that Field UI applies
destinations
at the point of instantiating a redirect response. It could just as easily have implemented that logic in a response event subscriber or similar, just like RedirectResponseSubscriber does fordestination
. To tell people "don't worry about validating user entered redirection URLs when you create a RedirectResponse, but do worry about it when you call ->setTargetUrl() on an already created one" seems worse than either saying "always validate yourself" or "never validate yourself".In other words, if we want to fix this issue with the code that's in #44.2 (which I think is pretty compelling), then I think we should also remove the existing validation of
destination
and make sure #44.2 automatically covers that as well. Not sure if that means we should harden RedirectResponseAllowExternalUrl or if we want to ditch that as a special class and instead have a separate service for registering trusted external hosts and/or URLs.Comment #46
dawehnerIn general I think we should not disallow the usage of
setTargetUrl
at all, on the other hand I get your point, we don't want to make it explicit that we require opting out from security. What about the following idea.We could actually get rid of the sanitation but personally I don't have a problem with having multiple level of security.
Comment #48
Fabianx CreditAttribution: Fabianx for Drupal Association commentedI agree with #45. While the proposed patch is great already, it also is encapsulating things in a (for me) difficult to understand manner.
However I don't think a white list of domains is correct either, because an attacker might have control over a specific path of that domain, but not on the originally set path.
I think making RedirectResponseAllowExternalUrl() immutable is a good idea (I am kinda getting fancy about immutable objects since PSR-7 ...).
That would prevent someone else from changing a trusted url into an untrusted one.
Train of thought
Not sure, if that is still possible, but the cleanest API change would be to always return a clone of the object in all set* methods (hopefully not performance critical), where RedirectResponseAllowExternalUrl would always use the default unsafe factory to create the new objects ... Just some thoughts.
Another possibility would be to encapsulate for example PSR-7 request objects and allow creating a redirect with that request object (possibly sub-class and add the SafeInterface to them), because a redirect in reality is kinda a new request ...
Supporting encapsulation of an immutable object also would be a way to solve this.
Comment #49
dawehnerWell, effecitly this is what the patch is doing. It disallows you to set untrusted URLs. But we need to keep in mind that there has to be an explicit method to opt out, in case you really want to.
Comment #50
dawehnerI also don't think that doing that would increase the security, because well, if you want, you can already just use $event->setResponse() and do whatever you want.
Comment #51
dawehnerIt is just sad, how unit test failures look like.
Comment #52
dawehnerIt is just sad, how unit test failures look like.
Comment #53
effulgentsia CreditAttribution: effulgentsia at Acquia commentedI like this, except I'm not sure about throwing an exception. Would it be ok to simply return without setting the URL? Reason being: I think code (such as the subscriber that deals with
destination
) should be able to call->setTargetUrl()
without worrying about whether the object is a RedirectResponse or TrustedRedirectResponse. Or, if we want to throw an exception, is there some global code that can catch it, in order to allow such callers to not need to themselves?Comment #54
effulgentsia CreditAttribution: effulgentsia at Acquia commentedIgnore #53. RedirectResponse::setTargetUrl() already has a defined API of throwing \InvalidArgumentException, so I think it's ok for callers to need to deal with that. I'm going to take a stab at improving this a bit more.
Comment #55
Wim Leers80 cols.
Don't we call this "root-relative URLs"?
80 cols.
I think this is intended to be s/filterResponse/FilterResponseEvent/ ?
I'm assuming this is here for debugging purposes?
Copy/paste error.
Nit: remove the leading backslash.
s/URl/URL/
This feels quite weird. Is this copy/pasted from the base class? Or…?
Nit: two newlines.
Should be moved to before the constructor.
Should this not be singular, i.e.
destination
, instead of plural?Comment #56
Fabianx CreditAttribution: Fabianx for Drupal Association commented#55.9: Yes, this is verbatim from Response::__construct(), little bad that we can't call the parents constructor here due to getting an Exception on the setTargetUrl() it would throw ...
Comment #57
dawehnerWell, we could catch it.
Comment #58
effulgentsia CreditAttribution: effulgentsia at Acquia commentedMy local work in progress addresses #55.9 (and I think some of the other points in #55). Will post what I have later today.
Comment #59
effulgentsia CreditAttribution: effulgentsia at Acquia commentedHere's what I have so far. Has some docs todos. And tests might fail. But curious what you all think about the changes.
This patch does neither of above, but paves the way to do so in a non-critical followup if we want to.
Comment #61
dawehnerI really like the separation of logic into proper objects.
I would have expected that this code just always uses a LocalRedirectResponse
Here would be perfect place to also explain what is unsafe in regard to external redirects
That is a bit odd, so an actual relative URL would be not considered as safe? It seems to work simply because of the conversion logic in the subscriber. I think a local redirect response should be able to specify a relative path
Comment #62
dawehnerSome changes. I also renamed SafeRedirectResponse to SecuredRedirectResponse, because its not necessarily "safe" yet.
Comment #64
dawehnerYeah not sure about the hierarchy of classes, but here is my thought:
figure out whether a local or external is wanted.
Comment #66
dawehnerUps.
Comment #68
dawehnerMh, so we have a test which ensure that you can't call out to Drupal\Core, this is nice, but it also ensures that it cannot be used in documentation :(
Comment #70
dawehnerThis failure is really nice. #2501655: ConfirmFormHelper::buildCancelLink() incorrectly handles ?destination URLs with a leading slash fixed the Url object itself ... so we now have a proper validation error.
Comment #71
larowlanThis is pretty close in my book
Nitpick, missing a doc according to our standard, not sure it needs it though
:)
This is the first time I've seen @package - can you point me to some docs on it's use - or is it just a default docblock added by an IDE?
:)
Any reason to rework this test case?
Comment #72
dawehnerWell, this is afaik the way how phpstorm autofills stuff.
Well, I think it makes sense to split up the case of an external redirect ...
Comment #74
dawehnerThis failure is caused by #2501655: ConfirmFormHelper::buildCancelLink() incorrectly handles ?destination URLs with a leading slash being reverted ...
Comment #75
effulgentsia CreditAttribution: effulgentsia at Acquia commentedWe throw an exception in setTargetUrl(), so I don't get what you mean by "not necessarily safe yet", but regardless, +1 to the rename.
Doing so violates the HTTP spec, but I think all browsers can handle it and Symfony's RedirectResponse allows it, so I'm fine with your relaxing of the isSafe() check.
I wrote this comment, but I think it might be wrong. It's ok to rethrow exceptions, so why exactly are we using trigger_error() here?
What about renaming the trait to LocalRedirectResponseTrait or LocalAwareRedirectResponseTrait and the method to isLocal()? Then classes using this trait can implement isSafe() as calling isLocal() or doing other logic as they see fit.
Comment #76
catch@effulgentsia see #21 for the trigger_error().
Comment #77
dawehnerLet's open up a follow up for that: #2514318: Ensure that all redirect responses comply to http spec
Let's do that.
Comment #79
dawehnerUps
Comment #81
dawehnerLet's fix that as well.
Comment #82
Fabianx CreditAttribution: Fabianx for Drupal Association commentedLeaving at needs-review:
Nit - I think we should write we avoid throwing an exception, to avoid making legitimate pages being cached as 500 errors.
I wasn't aware of that syntax, nice! :)
My feeling is we are loosing test coverage here.
While later we test legitimate external URLs we never check for the wrong case.
At least I was not able to find them ...
Comment #83
dawehnerThank you for your review!
Good point.
Fair point, added the once from providerTestDestinationRedirectToExternalUrl() as well.
Comment #84
Fabianx CreditAttribution: Fabianx for Drupal Association commentedSo don't we need any tests with:
http://attacker.com?
I just wonder ...
Comment #85
dawehnerWhat is the difference between http://attack.com and 'http://example.ca/drupal' given that the complete base URl is 'http://example.com/drupal' ?
Comment #86
Pere OrgaI'm unable to reproduce the vulnerability using 8.0.x HEAD:
When I go to
/admin/structure/types/manage/page/fields/node.page.body?destinations[0]=http://google.com
, change something and click "Save settings" the page breaks with the following error:When I go to
/admin/structure/types/manage/page/fields/node.page.body/storage?destinations[0]=http://google.com
, change something and click "Save field settings" the page does not break but gives the following messages:I get exactly the same behaviour when applying patch in #83.
Is the "destinations" functionality (note the plural) worth keeping it?
Comment #87
dawehnerThe reason for that is #2501655: ConfirmFormHelper::buildCancelLink() incorrectly handles ?destination URLs with a leading slash which ensured that you our url generation machanisms are safe.
On top of that this issue ensures that even our redirect system disallows you to redirect in the first place.
I totally think its worth keeping, given that otherwise adding a field would be a major pain, as there are more than 2 steps involved in that process.
Comment #88
dawehnerJust to be clear, yes the issue linked above #2501655: ConfirmFormHelper::buildCancelLink() incorrectly handles ?destination URLs with a leading slash
causes this exception.
But, well, this issue fixes all kind of possible redirect issues, but you could argue with me to move this to major.
Comment #89
catchJust discussed this at some length with @dawehner in irc.
The link in #88 was a different issue, the issue that added the exception was #2512452: Confirm form cancel button can lead to external domain.
After that issue, attempting to exploit the original Field UI bug, you get the exception instead of the open redirect. So we've already fixed the security issue here as a by-product of that patch with Url:Helper:fromInternalUri() hardening. That's good.
However, showing exceptions to users only due to user input (i.e. nothing is wrong with the site, so it should not be any kind of 500 error) is not good, so I opened #2521836: Ensure all callers of UrlHelper::fromInternalUri() either operate on validated user input, or catch InvalidArgumentException as a follow-up to handle that exception properly.
That leaves the hardening added here, which would additionally prevent open redirects. I think that is definitely worth doing, and per the earlier discussion I think the small API break (external URI opt-in) is worth doing any time up until release candidate (although obviously we can still discuss that - but this will be even less disruptive if we don't introduce the break).
However as hardening, and given UrlHelper:fromInternalUri() hardening already caught this case independently, it's really 'just' defense in depth at this point and not fixing any specific vulnerability, including this one.
So.. re-titling this issue to focus on the hardening rather than the original SA, and downgrading to major.
Comment #90
alexpottComment #91
dawehnerTo be clear though, the patch still exactly looks like how it should look like and is totally fine IMHO.
So reviews are still welcomed!
Comment #92
Fabianx CreditAttribution: Fabianx as a volunteer commentedI still think the Exception case should be added to test-coverage as well (i.e. what had been FALSE before - or maybe I just don't see it), but I am also fine for it to go in without.
For me it is RTBC already - except for that thing.
Comment #93
dawehnerWell, we have dedicated test coverage farther below in the test file .... we just split it up now.
Comment #94
Wim LeersLooking good to me. Only able to find nits.
Nit: s/dont'/don't/
And "cache 500 pages"? Not sure what that means.
Nit: 80 cols.
Nit: 80 cols.
Nit: 80 cols.
Comment #95
Fabianx CreditAttribution: Fabianx as a volunteer commented#93: RTBC then :)
Comment #96
alexpottI think this issue needs a CR. If you want to redirect to external url with this patch you have to do extra work.
Above are all the fixes I would make on commit...
Comment #97
dawehnerFixed the points + added the change record: https://www.drupal.org/node/2532212
Comment #98
Fabianx CreditAttribution: Fabianx as a volunteer commentedTweaked CR a little.
Comment #100
catchI updated this on commit because I don't think it's about caching - just changed to 'because it's a client error rather than a server error'.
Committed/pushed to 8.0.x, thanks!
Comment #101
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedI went ahead and published the change notice.
So we had tagged this for possible Drupal 7 backport - do we want to add something like drupal_goto_trusted() or drupal_goto_internal() in Drupal 7?