Original report from Martin Barbella:

Description of Vulnerability:
-----------------------------
The drupal_goto API function is meant to "send the user to a different
Drupal page. This issues an on-site HTTP redirect. The function makes
sure the redirected URL is formatted correctly"
(http://api.drupal.org/api/function/drupal_goto).

This function will also check $_REQUEST['destination'] and
$_REQUEST['edit']['destination'], and if either of these variables are
set, will override any specified path with the path element of the
associative array returned when passing either request variable
through parse_url.

When a URL such as
"trickparseurl:http://cwe.mitre.org/data/definitions/601.html"; is
passed to PHP's parse_url function, it will return:

array(2) {
  ["scheme"]=>
  string(13) "trickparseurl"
  ["path"]=>
  string(46) "http://cwe.mitre.org/data/definitions/601.html";
}

This causes the Drupal API function url to treat what is meant to be a
relative path as an external URL, which a user would then be
redirected to. It is important to note that using a destination such
as "http://example.com/"; would not result in an external redirect on
its own.

Systems affected:
-----------------
This issue has been corrected in Drupal 6.16 and 5.22. Earlier
versions are affected.

Impact:
-------
This API function is called by many of Drupal's core modules, as well
as various contributed modules. It affects form handlers, including
the login form handler, so almost all Drupal sites would be affected
by this.

Open redirection vulnerabilities can be exploited by attackers
attempting phishing scams to give their attempts a more trustworthy
appearance.

Mitigating factors:
-------------------
The path is parsed by the Drupal API url function, which will check
that the protocols of URLs it determines to be external are among a
set of approved protocols. This prevents redirection to URLs with
protocols such as data: or javascript:.

Here is the patch which was committed to D6:

diff --git a/includes/common.inc b/includes/common.inc
index 536f94c..69a2cbb 100644
--- a/includes/common.inc
+++ b/includes/common.inc
@@ -311,11 +311,21 @@ function drupal_get_destination() {
  */
 function drupal_goto($path = '', $query = NULL, $fragment = NULL, $http_response_code = 302) {
 
+  $destination = FALSE;
   if (isset($_REQUEST['destination'])) {
-    extract(parse_url(urldecode($_REQUEST['destination'])));
+    $destination = $_REQUEST['destination'];
   }
   else if (isset($_REQUEST['edit']['destination'])) {
-    extract(parse_url(urldecode($_REQUEST['edit']['destination'])));
+    $destination = $_REQUEST['edit']['destination'];
+  }
+
+  if ($destination) {
+    // Do not redirect to an absolute URL originating from user input.
+    $colonpos = strpos($destination, ':');
+    $absolute = ($colonpos !== FALSE && !preg_match('![/?#]!', substr($destination, 0, $colonpos)));
+    if (!$absolute) {
+      extract(parse_url(urldecode($destination)));
+    }
   }
 
   $url = url($path, array('query' => $query, 'fragment' => $fragment, 'absolute' => TRUE));

This issue needs to be fixed in Drupal 7. Here are a couple of suggestions/concerns from the security team:

  • For usability sake, we might want to warn users when they are going to be redirected to another site.
  • Is there a legitimate use case for usage of the destination parameter to redirect to an external website after form submission, or should it be suppressed? If people insist on doing that, they still can override the normal submit redirect.

Comments

heine’s picture

in addition;

1. parse_url should not be used on relative urls (per the note on php.net)

parse_url breaks on the following sample destination, where only '/bar' will endup in $path.

'foo/bar?val=http://foo

2. idem for drupal_parse_url, phpdoc states:

* This function should only be used for URLs that have been generated by the
* system, resp. url(). It should not be used for URLs that come from external
* sources, or URLs that link to external resources.

agentrickard’s picture

    * For usability sake, we might want to warn users when they are going to be redirected to another site.
    * Is there a legitimate use case for usage of the destination parameter to redirect to an external website after form submission, or should it be suppressed? If people insist on doing that, they still can override the normal submit redirect.

There are legitimate cases for redirection to another site -- or at least to another base url -- within Drupal, particularly in the Domain Access world (http://drupal.org/project/domain). I would not want those redirects announced by default.

Trigger module also allow the arbitrary redirection of any form action (essentially) to any URL.

In some cases, you have to set a destination, because, for instance, node module _hardcodes_ a form redirect into its submit handler that cannot be overridden in any other way.

pwolanin’s picture

Do you mean it's hardcoded for the search form for node? Or somewhere else in node module?

There is a patch to deal with the search one, to make it more sane.

agentrickard’s picture

@pwolanin

I mean the node editing form. http://api.drupal.org/api/function/node_form_submit/7, specifically:

  if ($node->nid) {
    unset($form_state['rebuild']);
    $form_state['values']['nid'] = $node->nid;
    $form_state['nid'] = $node->nid;
    $form_state['redirect'] = 'node/' . $node->nid; // <-- this cannot be altered by hook_form_alter().

To force this redirect to go elsewhere, you either have to use Actions/Triggers (which is itself broken in this regard, see #732542: system_goto_action breaks core APIs) or tamper with $_GET['destination'] which overrules the 'redirect' from $form_state.

My basic point is that sometimes I want to redirect a node to something other than $base_url/node/$node->nid.

pwolanin’s picture

@agentrickard - why not add another submit hook that runs after?

agentrickard’s picture

That would still require a redirection to an absolute URL that may or may not match the HTTP_HOST in $base_url, which is what this issue is about.

pwolanin’s picture

@agentrickard - I don't think the patch changes the ability to use redirect to go to an external URL, it only prevents it from coming in $_REQUEST['destination']

agentrickard’s picture

And that's the problem. Because as I understand it, that's really the only way to override what node_form_submit() is doing with any guarantee. A secondary #submit function might work, but also see #732542: system_goto_action breaks core APIs which uses 'destination' to solve a specific problem with drupal_goto() and Triggers. And Triggers allows any arbitrary URL to be used.

grendzy’s picture

One approach might be to require external redirects to be authenticated with drupal_get_token(). Thoughts?
(example: http://drupalsite.org/form?destination=http://elsewhere.com&token=4af3cf...)

grendzy’s picture

I couldn't reproduce this, even on Drupal 6.15. scor thought it might be only be exploitable with certain Apache settings, does anyone know more about how to trigger it? I'm using Apache 2.2.14 on OS X.

meba’s picture

Issue tags: +Security improvements
sun’s picture

Before moving on here, we need a test that exposes the flaw and ensures that it's actually still there.

$_REQUEST['destination'] has been replaced with $_GET['destination'].

Parsing of URLs now happens in http://api.drupal.org/api/function/drupal_parse_url/7, and actually I thought we would have taken this edge-case into account when all of those functions were revamped + cleaned up recently.

cha0s’s picture

Doesn't appear to be a threat any longer (at least using the trivial example given in the original report.

print_r(drupal_parse_url('trickparseurl:http://cwe.mitre.org/data/definitions/601.html'));

gives

Array ( [path] => trickparseurl:http://cwe.mitre.org/data/definitions/601.html [query] => Array ( ) [fragment] => ) 

Will leave status so others can review.

sun’s picture

Priority: Critical » Normal
Issue tags: +Needs tests

Thanks! Can we quickly turn this into a test, so we do not break it in the future?

cha0s’s picture

Priority: Normal » Critical
Status: Active » Needs review
StatusFileSize
new1.8 KB

Yeah, this is actually still an issue. I am thinking drupal_goto should never redirect to an external URL. It currently is, you can see by going to http://example.com/node/add/page?destination=http://google.com

I don't think there's a valid use case for using destination for an external URL, but maybe I'm wrong. The doc for drupal_goto() states "This issues an on-site HTTP redirect.", so I think that if we decide otherwise, we need to change the doc.

I have patched drupal_goto() to eliminate this threat using url_is_external() (which as far as I could tell is the exact code in the security patch above), and added a general-purpose drupal_parse_url() test case, as well as one that tests its vigilance against crafted URLs.

Status: Needs review » Needs work

The last submitted patch, 733028.patch, failed testing.

sun’s picture

+++ modules/simpletest/tests/common.test
@@ -1478,6 +1478,30 @@ class ValidUrlTestCase extends DrupalUnitTestCase {
+    $parts = drupal_parse_url(
+      'http://example.com/?foo=bar#baz'
+    );
+    ¶
+    $this->assertEqual($parts['path'], 'http://example.com', t('drupal_parse_url() correctly parsed path.'));

This is the only assertion that failed, and it failed, because you missed the trailing slash in the comparison.

91 critical left. Go review some!

cha0s’s picture

Status: Needs work » Needs review
StatusFileSize
new1.6 KB

Sorry about that. I missed the original drupal_parse_url() tests, so it wasn't even necessary. I put the forged URL test in the original test. Let's try this.

sun’s picture

Status: Needs review » Needs work
+++ includes/common.inc
@@ -659,7 +659,7 @@ function drupal_encode_path($path) {
+  if (isset($_GET['destination']) && !url_is_external($_GET['destination'])) {
     $destination = drupal_parse_url(urldecode($_GET['destination']));

I wonder whether it is correct to check url_is_external() that early? Will the URL be identified as external if it is encoded somehow, or if it is forged as in the way we are discussing here?

Ideally, this should probably be covered by tests, too :-/

+++ modules/simpletest/tests/common.test
@@ -200,8 +200,13 @@ class CommonURLUnitTest extends DrupalWebTestCase {
+    // Test that drupal_parse_url doesn't expose the hole discussed at
+    // http://drupal.org/node/733028
+    $parts = drupal_parse_url('forged:http://cwe.mitre.org/data/definitions/601.html');
+    $this->assertFalse(valid_url($parts['path'], TRUE), t('drupal_parse_url() correctly parsed a forged URL.'));

The inline comment should not contain a reference to this issue, but instead explain what is being tested and why.

Furthermore, it should state what the expected result is -- and a short summary of that has to be used for the assertion message.

Lastly, I'm not sure what this test/assertion is trying to ensure. valid_url() returns FALSE for any absolute URL that doesn't start with ftp|https?|..., so will always be FALSE on a "forged:" URL scheme...?

Needs plenty of explanation (in code).

+++ modules/simpletest/tests/common.test
@@ -200,8 +200,13 @@ class CommonURLUnitTest extends DrupalWebTestCase {
+  }
+  ¶

Trailing white-space here.

Powered by Dreditor.

agentrickard’s picture

@cha0s

There are valid reasons to use drupal_goto() to send a user to a URL other than $base_url, as described in #2 above, particularly when using hook_url_rewrite_outbound() or system_goto_action().

For example: On user insert, pop the user to an external URL for them to opt-out of newsletters run through an external service.

We should still be able to pass an external $path to drupal_goto() and expect it to work.

For security, we should be trying to prevent INVALID URLs, not assessing the location of the URL.

cha0s’s picture

@sun: Will address these in an upcoming patch. Though,

'Lastly, I'm not sure what this test/assertion is trying to ensure. valid_url() returns FALSE for any absolute URL that doesn't start with ftp|https?|..., so will always be FALSE on a "forged:" URL scheme...?'

Yes, it's supposed to return false, as in the original post, you could see that parsing the crafted URL resulted in a valid URL, now it results in an invalid URL (correct behavior)

@agentrickard: I don't have time to touch this right now, but I think my previous statement was wrong... that is, as far as I rememeber, drupal_goto *can* redirect to an external URL, you just can't do that through the 'destination' query parameter. This means you can do it if you have PHP privilege, but not by using crafted URLs. Let me know if there is still a legit case where you'd need this power in the query parameter. To me it feels dangerous.

agentrickard’s picture

The only piece to check is #732542: system_goto_action breaks core APIs, where we have to decide how to handle system_goto_action(), which may end up using a destination value.

If that's the case, then we need to rethink that patch in light of this security check.

sun’s picture

We shouldn't hold up this (critical) patch on #732542: system_goto_action breaks core APIs

catch’s picture

We could remove the system_goto() action if it's impossible to make it work properly, agreed it shouldn't hold up this security fix.

agentrickard’s picture

#732542: system_goto_action breaks core APIs is also a critical security fix. Just because it's less common doesn't mean it's not.

catch’s picture

I mean if it can't be made secure and also functional, we could remove that action from core altogether.

jbrauer’s picture

pwolanin’s picture

no, please leave the security.drupal.org tag on issues that were transferred from that site after an SA

agentrickard’s picture

Note: url_is_external() is either broken or misnamed. It does not check that a URL is external to the calling website. It checks that the URL is absolute. Huge difference. See #819844: url_is_external() is badly documented.

agentrickard’s picture

Status: Needs work » Needs review
StatusFileSize
new2.64 KB

Revised and updated patch that agrees with cha0s's approach and addresses most of sun's issues.

grendzy’s picture

StatusFileSize
new2.16 KB

Patch looks good. Here's a reroll with trailing space removed.

weboide’s picture

Looks good to me also.

catch’s picture

Status: Needs review » Reviewed & tested by the community

Looks fine to me, has a nice test too.

yesct’s picture

#32: 733028-open-redirect.patch queued for re-testing.

webchick’s picture

Status: Reviewed & tested by the community » Fixed

Looks great, thanks! Committed to HEAD!

We'll have to revisit #732542: system_goto_action breaks core APIs in light of this, though.

Status: Fixed » Closed (fixed)
Issue tags: -Security improvements, -Needs tests, -Security Advisory follow-up

Automatically closed -- issue fixed for 2 weeks with no activity.