Repository: ckan/ckan

Issue:
Warn the user when redirecting to an external site
CKAN doesn't currently do any checks when redirecting to a URL through /util/redirect. For example http://demo.ckan.org/util/redirect?url=http://www.google.com will send you directly to the target URL with no warning that what you're going to access is not hosted at http://demo.ckan.org.

It might be desirable to warn the user (and give a clickable link with the actual target URL) to avoid situations where e.g. http://reputable-site.com/util/redirect?url=http://undesirable-site.com/ could send oblivious users for example to malware sites.

If you think this is a good idea, I might be able to contribute.



Discussion:
Ouch, yes open redirect is very bad https://www.owasp.org/index.php/Open_redirect

Not sure why we need the `/util/redirect` endpoint at all. If it is not used I suggest getting rid of it and handle redirects  properly from the controllers code

@mwahlroos do you want to do a quick search on the code to see where it is used? (if it is at all) 

@amercader it seems to be used by the UI language selector, but a quick search didn't reveal it being used anywhere else.

So, do you think the endpoint should be removed and the language selector modified to use something else instead, or the endpoint fixed?

We'll need to fix this for an extension we're developing anyway, so we might as well do it in a way acceptable for CKAN core rather than diverge with a solution that differs from what you guys are going to do.

I might tend towards removing the endpoint unless there's some other need for it beside the current use in the language selector. At the moment it's pretty much just a thin wrapper around other functionality anyway.

@mwahlroos Sorry for the late reply. I'm happy to remove the endpoint, but we still need to do a redirect on the language selector. Perhaps we can add a quick check on the controller action to only support internal redirects. There is a small function on the user controller that does it:

https://github.com/ckan/ckan/blob/625b51cdb0f1697add59c7e3faf723a48c8e04fd/ckan/controllers/user.py#L692


Resolving patch:
diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py
--- a/ckan/controllers/user.py
+++ b/ckan/controllers/user.py
@@ -1,6 +1,5 @@
 import logging
 from urllib import quote
-from urlparse import urlparse
 
 from pylons import config
 
@@ -356,7 +355,7 @@ def login(self, error=None):
     def logged_in(self):
         # redirect if needed
         came_from = request.params.get('came_from', '')
-        if self._sane_came_from(came_from):
+        if h.url_is_local(came_from):
             return h.redirect_to(str(came_from))
 
         if c.user:
@@ -390,7 +389,7 @@ def logout(self):
     def logged_out(self):
         # redirect if needed
         came_from = request.params.get('came_from', '')
-        if self._sane_came_from(came_from):
+        if h.url_is_local(came_from):
             return h.redirect_to(str(came_from))
         h.redirect_to(controller='user', action='logged_out_page')
 
@@ -686,14 +685,3 @@ def unfollow(self, id):
                              or e.error_dict)
             h.flash_error(error_message)
         h.redirect_to(controller='user', action='read', id=id)
-
-    def _sane_came_from(self, url):
-        '''Returns True if came_from is local'''
-        if not url or (len(url) >= 2 and url.startswith('//')):
-            return False
-        parsed = urlparse(url)
-        if parsed.scheme:
-            domain = urlparse(h.url_for('/', qualified=True)).netloc
-            if domain != parsed.netloc:
-                return False
-        return True
diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py
--- a/ckan/controllers/util.py
+++ b/ckan/controllers/util.py
@@ -2,6 +2,8 @@
 
 import ckan.lib.base as base
 import ckan.lib.i18n as i18n
+import ckan.lib.helpers as h
+from ckan.common import _
 
 
 class UtilController(base.BaseController):
@@ -10,7 +12,11 @@ class UtilController(base.BaseController):
     def redirect(self):
         ''' redirect to the url parameter. '''
         url = base.request.params.get('url')
-        return base.redirect(url)
+
+        if h.url_is_local(url):
+            return base.redirect(url)
+        else:
+            base.abort(403, _('Redirecting to external site at %s not allowed.') % url)
 
     def primer(self):
         ''' Render all html components out onto a single page.
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -12,6 +12,7 @@
 import urllib
 import pprint
 import copy
+import urlparse
 from urllib import urlencode
 
 from paste.deploy.converters import asbool
@@ -227,6 +228,18 @@ def _add_i18n_to_url(url_to_amend, **kw):
     return url
 
 
+def url_is_local(url):
+    '''Returns True if url is local'''
+    if not url or (len(url) >= 2 and url.startswith('//')):
+        return False
+    parsed = urlparse.urlparse(url)
+    if parsed.scheme:
+        domain = urlparse.urlparse(url_for('/', qualified=True)).netloc
+        if domain != parsed.netloc:
+            return False
+    return True
+
+
 def full_current_url():
     ''' Returns the fully qualified current url (eg http://...) useful
     for sharing etc '''


Test patch: