Repository: pypi/warehouse

Issue:
Handle next redirect support for login/logout
This needs to ensure that we securely handle this, take a look at what Django does to validate a redirect.

Redirect after login or logout (#fix 415).
Again, probably not perfect, but good enough.

I copied some code from Django! Namely, for `warehouse.utils.http.is_safe_url`.

Someone else (probably @dstufft) needs to review legality and security, though.



Discussion:
I'm looking at this



Resolving patch:
diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py
--- a/warehouse/accounts/__init__.py
+++ b/warehouse/accounts/__init__.py
@@ -19,6 +19,8 @@
     BasicAuthAuthenticationPolicy, SessionAuthenticationPolicy,
 )
 
+REDIRECT_FIELD_NAME = 'next'
+
 
 def _login(username, password, request):
     login_service = request.find_service(ILoginService, context=None)
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -14,12 +14,14 @@
 from pyramid.security import remember, forget
 from pyramid.view import view_config
 
+from warehouse.accounts import REDIRECT_FIELD_NAME
 from warehouse.accounts.forms import LoginForm
 from warehouse.accounts.interfaces import ILoginService
 from warehouse.cache.origin import origin_cache
 from warehouse.cache.http import cache_control
 from warehouse.csrf import csrf_protect
 from warehouse.sessions import uses_session
+from warehouse.utils.http import is_safe_url
 
 
 @view_config(
@@ -48,19 +50,23 @@ def profile(user, request):
     renderer="accounts/login.html",
     decorator=[csrf_protect("accounts.login"), uses_session],
 )
-def login(request, _form_class=LoginForm):
-    # TODO: If already logged in just redirect to ?next=
+def login(request, redirect_field_name=REDIRECT_FIELD_NAME,
+          _form_class=LoginForm):
     # TODO: Logging in should reset request.user
     # TODO: Configure the login view as the default view for not having
     #       permission to view something.
 
     login_service = request.find_service(ILoginService, context=None)
 
+    redirect_to = request.POST.get(redirect_field_name,
+                                   request.GET.get(redirect_field_name))
+
     form = _form_class(request.POST, login_service=login_service)
 
     if request.method == "POST" and form.validate():
         # Get the user id for the given username.
-        userid = login_service.find_userid(form.username.data)
+        username = form.username.data
+        userid = login_service.find_userid(username)
 
         # We have a session factory associated with this request, so in order
         # to protect against session fixation attacks we're going to make sure
@@ -96,13 +102,23 @@ def login(request, _form_class=LoginForm):
         # and we don't want to continue using the old one.
         request.session.new_csrf_token()
 
+        # If the user-originating redirection url is not safe, then redirect to
+        # the index instead.
+        if (not redirect_to or
+                not is_safe_url(url=redirect_to, host=request.host)):
+            redirect_to = "/"
+
         # Now that we're logged in we'll want to redirect the user to either
         # where they were trying to go originally, or to the default view.
-        # TODO: Implement ?next= support.
-        # TODO: Figure out a better way to handle the "default view".
-        return HTTPSeeOther("/", headers=dict(headers))
+        return HTTPSeeOther(redirect_to, headers=dict(headers))
 
-    return {"form": form}
+    return {
+        "form": form,
+        "redirect": {
+            "field": REDIRECT_FIELD_NAME,
+            "data": redirect_to,
+        },
+    }
 
 
 @view_config(
@@ -110,10 +126,13 @@ def login(request, _form_class=LoginForm):
     renderer="accounts/logout.html",
     decorator=[csrf_protect("accounts.logout"), uses_session],
 )
-def logout(request):
+def logout(request, redirect_field_name=REDIRECT_FIELD_NAME):
     # TODO: If already logged out just redirect to ?next=
     # TODO: Logging out should reset request.user
 
+    redirect_to = request.POST.get(redirect_field_name,
+                                   request.GET.get(redirect_field_name))
+
     if request.method == "POST":
         # A POST to the logout view tells us to logout. There's no form to
         # validate here becuse there's no data. We should be protected against
@@ -133,10 +152,14 @@ def logout(request):
         #       to handle this for us.
         request.session.invalidate()
 
+        # If the user-originating redirection url is not safe, then redirect to
+        # the index instead.
+        if (not redirect_to or
+                not is_safe_url(url=redirect_to, host=request.host)):
+            redirect_to = "/"
+
         # Now that we're logged out we'll want to redirect the user to either
         # where they were originally, or to the default view.
-        # TODO: Implement ?next= support.
-        # TODO: Figure out a better way to handle the "default view".
-        return HTTPSeeOther("/", headers=dict(headers))
+        return HTTPSeeOther(redirect_to, headers=dict(headers))
 
-    return {}
+    return {"redirect": {"field": REDIRECT_FIELD_NAME, "data": redirect_to}}
diff --git a/warehouse/utils/http.py b/warehouse/utils/http.py
new file mode 100644
--- /dev/null
+++ b/warehouse/utils/http.py
@@ -0,0 +1,49 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unicodedata
+from urllib.parse import urlparse
+
+
+# FROM https://github.com/django/django/blob/
+# 011a54315e46acdf288003566b8570440f5ac985/django/utils/http.py
+def is_safe_url(url, host=None):
+    """
+    Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
+    a different host and uses a safe scheme).
+    Always returns ``False`` on an empty url.
+    """
+    if url is not None:
+        url = url.strip()
+    if not url:
+        return False
+    # Chrome treats \ completely as /
+    url = url.replace('\\', '/')
+    # Chrome considers any URL with more than two slashes to be absolute, but
+    # urlparse is not so flexible. Treat any url with three slashes as unsafe.
+    if url.startswith('///'):
+        return False
+    url_info = urlparse(url)
+    # Forbid URLs like http:///example.com - with a scheme, but without a
+    # hostname.
+    # In that URL, example.com is not the hostname but, a path component.
+    # However, Chrome will still consider example.com to be the hostname,
+    # so we must not allow this syntax.
+    if not url_info.netloc and url_info.scheme:
+        return False
+    # Forbid URLs that start with control characters. Some browsers (like
+    # Chrome) ignore quite a few control characters at the start of a
+    # URL and might consider the URL as scheme relative.
+    if unicodedata.category(url[0])[0] == 'C':
+        return False
+    return ((not url_info.netloc or url_info.netloc == host) and
+            (not url_info.scheme or url_info.scheme in {'http', 'https'}))


Test patch:
diff --git a/tests/accounts/test_views.py b/tests/accounts/test_views.py
--- a/tests/accounts/test_views.py
+++ b/tests/accounts/test_views.py
@@ -51,7 +51,8 @@ def test_returns_user(self, db_request):
 
 class TestLogin:
 
-    def test_get_returns_form(self, pyramid_request):
+    @pytest.mark.parametrize("next_url", [None, "/foo/bar/", "/wat/"])
+    def test_get_returns_form(self, pyramid_request, next_url):
         login_service = pretend.stub()
         pyramid_request.find_service = pretend.call_recorder(
             lambda iface, context: login_service
@@ -59,9 +60,15 @@ def test_get_returns_form(self, pyramid_request):
         form_obj = pretend.stub()
         form_class = pretend.call_recorder(lambda d, login_service: form_obj)
 
+        if next_url is not None:
+            pyramid_request.GET["next"] = next_url
+
         result = views.login(pyramid_request, _form_class=form_class)
 
-        assert result == {"form": form_obj}
+        assert result == {
+            "form": form_obj,
+            "redirect": {"field": "next", "data": next_url},
+        }
         assert pyramid_request.find_service.calls == [
             pretend.call(ILoginService, context=None),
         ]
@@ -69,18 +76,24 @@ def test_get_returns_form(self, pyramid_request):
             pretend.call(pyramid_request.POST, login_service=login_service),
         ]
 
-    def test_post_invalid_returns_form(self, pyramid_request):
+    @pytest.mark.parametrize("next_url", [None, "/foo/bar/", "/wat/"])
+    def test_post_invalid_returns_form(self, pyramid_request, next_url):
         login_service = pretend.stub()
         pyramid_request.find_service = pretend.call_recorder(
             lambda iface, context: login_service
         )
         pyramid_request.method = "POST"
+        if next_url is not None:
+            pyramid_request.POST["next"] = next_url
         form_obj = pretend.stub(validate=pretend.call_recorder(lambda: False))
         form_class = pretend.call_recorder(lambda d, login_service: form_obj)
 
         result = views.login(pyramid_request, _form_class=form_class)
 
-        assert result == {"form": form_obj}
+        assert result == {
+            "form": form_obj,
+            "redirect": {"field": "next", "data": next_url},
+        }
         assert pyramid_request.find_service.calls == [
             pretend.call(ILoginService, context=None),
         ]
@@ -127,29 +140,71 @@ def test_post_validate_redirects(self, monkeypatch, pyramid_request,
         result = views.login(pyramid_request, _form_class=form_class)
 
         assert isinstance(result, HTTPSeeOther)
+
         assert result.headers["Location"] == "/"
         assert result.headers["foo"] == "bar"
-        assert pyramid_request.find_service.calls == [
-            pretend.call(ILoginService, context=None),
-        ]
+
         assert form_class.calls == [
             pretend.call(pyramid_request.POST, login_service=login_service),
         ]
         assert form_obj.validate.calls == [pretend.call()]
+
         assert login_service.find_userid.calls == [pretend.call("theuser")]
+
         if with_user:
             assert new_session == {}
         else:
             assert new_session == {"a": "b", "foo": "bar"}
-        assert pyramid_request.session.invalidate.calls == [pretend.call()]
+
         assert remember.calls == [pretend.call(pyramid_request, 1)]
+        assert pyramid_request.session.invalidate.calls == [pretend.call()]
+        assert pyramid_request.find_service.calls == [
+            pretend.call(ILoginService, context=None),
+        ]
         assert pyramid_request.session.new_csrf_token.calls == [pretend.call()]
 
+    @pytest.mark.parametrize(
+        # The set of all possible next URLs. Since this set is infinite, we
+        # test only a finite set of reasonable URLs.
+        ("expected_next_url, observed_next_url"),
+        [
+            ("/security/", "/security/"),
+            ("http://example.com", "/"),
+        ],
+    )
+    def test_post_validate_no_redirects(self, pyramid_request,
+                                        expected_next_url, observed_next_url):
+        login_service = pretend.stub(
+            find_userid=pretend.call_recorder(lambda username: 1),
+        )
+        pyramid_request.find_service = pretend.call_recorder(
+            lambda iface, context: login_service
+        )
+        pyramid_request.method = "POST"
+        pyramid_request.POST["next"] = expected_next_url
+
+        form_obj = pretend.stub(
+            validate=pretend.call_recorder(lambda: True),
+            username=pretend.stub(data="theuser"),
+        )
+        form_class = pretend.call_recorder(lambda d, login_service: form_obj)
+
+        result = views.login(pyramid_request, _form_class=form_class)
+
+        assert isinstance(result, HTTPSeeOther)
+
+        assert result.headers["Location"] == observed_next_url
+
 
 class TestLogout:
 
-    def test_get_returns_empty(self, pyramid_request):
-        assert views.logout(pyramid_request) == {}
+    @pytest.mark.parametrize("next_url", [None, "/foo/bar/", "/wat/"])
+    def test_get_returns_empty(self, pyramid_request, next_url):
+        if next_url is not None:
+            pyramid_request.GET["next"] = next_url
+
+        assert views.logout(pyramid_request) == \
+            {"redirect": {"field": "next", "data": next_url}}
 
     def test_post_forgets_user(self, monkeypatch, pyramid_request):
         forget = pretend.call_recorder(lambda request: [("foo", "bar")])
@@ -167,3 +222,23 @@ def test_post_forgets_user(self, monkeypatch, pyramid_request):
         assert result.headers["foo"] == "bar"
         assert forget.calls == [pretend.call(pyramid_request)]
         assert pyramid_request.session.invalidate.calls == [pretend.call()]
+
+    @pytest.mark.parametrize(
+        # The set of all possible next URLs. Since this set is infinite, we
+        # test only a finite set of reasonable URLs.
+        ("expected_next_url, observed_next_url"),
+        [
+            ("/security/", "/security/"),
+            ("http://example.com", "/"),
+        ],
+    )
+    def test_post_redirects_user(self, pyramid_request, expected_next_url,
+                                 observed_next_url):
+        pyramid_request.method = "POST"
+
+        pyramid_request.POST["next"] = expected_next_url
+
+        result = views.logout(pyramid_request)
+
+        assert isinstance(result, HTTPSeeOther)
+        assert result.headers["Location"] == observed_next_url
diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py
new file mode 100644
--- /dev/null
+++ b/tests/utils/test_http.py
@@ -0,0 +1,65 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+from warehouse.utils.http import is_safe_url
+
+
+# (MOSTLY) FROM https://github.com/django/django/blob/
+# 011a54315e46acdf288003566b8570440f5ac985/tests/utils_tests/test_http.py
+class TestIsSafeUrl:
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            None,
+            'http://example.com',
+            'http:///example.com',
+            'https://example.com',
+            'ftp://exampel.com',
+            r'\\example.com',
+            r'\\\example.com',
+            r'/\\/example.com',
+            r'\\\example.com',
+            r'\\example.com',
+            r'\\//example.com',
+            r'/\/example.com',
+            r'\/example.com',
+            r'/\example.com',
+            'http:///example.com',
+            'http:/\//example.com',
+            'http:\/example.com',
+            'http:/\example.com',
+            'javascript:alert("XSS")',
+            '\njavascript:alert(x)',
+            '\x08//example.com',
+            '\n',
+        ],
+    )
+    def test_rejects_bad_url(self, url):
+        assert not is_safe_url(url, host="testserver")
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            '/view/?param=http://example.com',
+            '/view/?param=https://example.com',
+            '/view?param=ftp://exampel.com',
+            'view/?param=//example.com',
+            'https://testserver/',
+            'HTTPS://testserver/',
+            '//testserver/',
+            '/url%20with%20spaces/',
+        ],
+    )
+    def test_accepts_good_url(self, url):
+        assert is_safe_url(url, host="testserver")