CVE-2022-4589: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: A vulnerability has been found in cyface Terms and Conditions Module up to 2.0.9 and classified as problematic. Affected by this vulnerability is the function returnTo of the file termsandconditions/views.py. The manipulation leads to open redirect. The attack can be launched remotely. Upgrading to version 2.0.10 is able to address this issue. The name of the patch is 03396a1c2e0af95e12a45c5faef7e47a4b513e1a. It is recommended to upgrade the affected component. The associated identifier of this vulnerability is VDB-216175.
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 6.1
Published: 2022-12-17T13:15Z
Repository: https://github.com/cyface/django-termsandconditions
Language: Python
Files changed: termsandconditions/tests.py, termsandconditions/views.py
Commit date: 2022-06-23 06:50:22 -0600
Commit message: Fix open redirect vulnerability

Diff: diff --git a/termsandconditions/tests.py b/termsandconditions/tests.py
index 99c65a4..768ce84 100644
--- a/termsandconditions/tests.py
+++ b/termsandconditions/tests.py
@@ -240,34 +240,43 @@ class TermsAndConditionsTests(TestCase):
         accept_version_post_response = self.client.post(
             "/terms/accept/", {"terms": 3, "returnTo": "/secure/"}, follow=True
         )
         self.assertContains(accept_version_post_response, "Secure")
 
-    def test_accept_redirect_safe(self):
+    def _post_accept(self, return_to):
         # Pre-accept terms 2 and 3
         UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms2)
         UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms3)
 
         LOGGER.debug("Test user1 login for test_accept_redirect")
         login_response = self.client.login(username="user1", password="user1password")
         self.assertTrue(login_response)
 
         LOGGER.debug("Test /terms/accept/site-terms/1/ post")
         accept_response = self.client.post(
-            "/terms/accept/", {"terms": 1, "returnTo": "/secure/"}, follow=True
+            "/terms/accept/", {"terms": 1, "returnTo": return_to}, follow=True
         )
+        return accept_response
+
+    def test_accept_redirect_safe(self):
+        accept_response = self._post_accept("/secure/")
         self.assertRedirects(accept_response, "/secure/")
 
     def test_accept_redirect_unsafe(self):
-        # Pre-accept terms 2 and 3
-        UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms2)
-        UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms3)
+        accept_response = self._post_accept("http://attacker/")
+        self.assertRedirects(accept_response, "/")
 
-        LOGGER.debug("Test /terms/accept/contrib-terms/3/ post")
-        accept_response = self.client.post(
-            "/terms/accept/", {"terms": 3, "returnTo": "http://attacker/"}, follow=False
-        )
+    def test_accept_redirect_unsafe_2(self):
+        accept_response = self._post_accept("//attacker.com")
+        self.assertRedirects(accept_response, "/")
+
+    def test_accept_redirect_unsafe_3(self):
+        accept_response = self._post_accept("///attacker.com")
+        self.assertRedirects(accept_response, "/")
+
+    def test_accept_redirect_unsafe_4(self):
+        accept_response = self._post_accept("////attacker.com")
         self.assertRedirects(accept_response, "/")
 
     def test_accept_store_ip_address(self):
         """Test with IP address storage setting true (default)"""
         self.client.login(username="user1", password="user1password")
diff --git a/termsandconditions/views.py b/termsandconditions/views.py
index 1eb9688..55a966f 100644
--- a/termsandconditions/views.py
+++ b/termsandconditions/views.py
@@ -12,10 +12,11 @@ from django.core.mail import send_mail
 from django.contrib import messages
 from django.http import HttpResponseRedirect, Http404
 from django.utils.translation import gettext as _
 from django.views.generic import DetailView, CreateView, FormView
 from django.template.loader import get_template
+from django.utils.encoding import iri_to_uri
 import logging
 from smtplib import SMTPException
 
 LOGGER = logging.getLogger(name="termsandconditions")
 DEFAULT_TERMS_BASE_TEMPLATE = "base.html"
@@ -42,10 +43,32 @@ class GetTermsViewMixin(object):
         else:
             # Return a list of not agreed to terms for the current user for the list view
             terms = TermsAndConditions.get_active_terms_not_agreed_to(self.request.user)
         return terms
 
+    def get_return_to(self, from_dict):
+        return_to = from_dict.get("returnTo", "/")
+
+        if self.is_safe_url(return_to):
+            # Django recommends to use this together with the helper above
+            return iri_to_uri(return_to)
+
+        LOGGER.debug("Unsafe URL found: {}".format(return_to))
+        return "/"
+
+    def is_safe_url(self, url):
+        # In Django 3.0 is_safe_url is renamed, so we import conditionally:
+        # https://docs.djangoproject.com/en/3.2/releases/3.0/#id3
+        try:
+            from django.utils.http import url_has_allowed_host_and_scheme
+        except ImportError:
+            from django.utils.http import (
+                is_safe_url as url_has_allowed_host_and_scheme,
+            )
+
+        return url_has_allowed_host_and_scheme(url, settings.ALLOWED_HOSTS)
+
 
 class AcceptTermsView(CreateView, GetTermsViewMixin):
     """
     Terms and Conditions Acceptance view
 
@@ -67,26 +90,21 @@ class AcceptTermsView(CreateView, GetTermsViewMixin):
     def get_initial(self):
         """Override of CreateView method, queries for which T&C to accept and catches returnTo from URL"""
         LOGGER.debug("termsandconditions.views.AcceptTermsView.get_initial")
 
         terms = self.get_terms(self.kwargs)
-        return_to = self.request.GET.get("returnTo", "/")
+        return_to = self.get_return_to(self.request.GET)
 
         return {"terms": terms, "returnTo": return_to}
 
     def post(self, request, *args, **kwargs):
         """
         Handles POST request.
         """
-        return_url = request.POST.get("returnTo", "/")
+        return_url = self.get_return_to(self.request.POST)
         terms_ids = request.POST.getlist("terms")
 
-        parsed = urlparse(return_url)
-        if parsed.hostname and parsed.hostname not in settings.ALLOWED_HOSTS:
-            # Make sure the return url is a relative path or a trusted hostname
-            return_url = '/'
-
         if not terms_ids:  # pragma: nocover
             return HttpResponseRedirect(return_url)
 
         if request.user.is_authenticated:
             user = request.user
@@ -145,11 +163,11 @@ class EmailTermsView(FormView, GetTermsViewMixin):
         """Override of CreateView method, queries for which T&C send, catches returnTo from URL"""
         LOGGER.debug("termsandconditions.views.EmailTermsView.get_initial")
 
         terms = self.get_terms(self.kwargs)
 
-        return_to = self.request.GET.get("returnTo", "/")
+        return_to = self.get_return_to(self.request.GET)
 
         return {"terms": terms, "returnTo": return_to}
 
     def form_valid(self, form):
         """Override of CreateView method, sends the email."""
@@ -177,11 +195,11 @@ class EmailTermsView(FormView, GetTermsViewMixin):
                 self.request,
                 messages.ERROR,
                 _("An Error Occurred Sending Your Message."),
             )
 
-        self.success_url = form.cleaned_data.get("returnTo", "/") or "/"
+        self.success_url = self.get_return_to(form.cleaned_data)
 
         return super().form_valid(form)
 
     def form_invalid(self, form):
         """Override of CreateView method, logs invalid email form submissions."""

Vulnerable code:     def test_accept_redirect_safe(self):
            "/terms/accept/", {"terms": 1, "returnTo": "/secure/"}, follow=True
        # Pre-accept terms 2 and 3
        UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms2)
        UserTermsAndConditions.objects.create(user=self.user1, terms=self.terms3)
        LOGGER.debug("Test /terms/accept/contrib-terms/3/ post")
        accept_response = self.client.post(
            "/terms/accept/", {"terms": 3, "returnTo": "http://attacker/"}, follow=False
        )
        return_to = self.request.GET.get("returnTo", "/")
        return_url = request.POST.get("returnTo", "/")
        parsed = urlparse(return_url)
        if parsed.hostname and parsed.hostname not in settings.ALLOWED_HOSTS:
            # Make sure the return url is a relative path or a trusted hostname
            return_url = '/'

        return_to = self.request.GET.get("returnTo", "/")
        self.success_url = form.cleaned_data.get("returnTo", "/") or "/"
Fixed code:     def _post_accept(self, return_to):
            "/terms/accept/", {"terms": 1, "returnTo": return_to}, follow=True
        return accept_response

    def test_accept_redirect_safe(self):
        accept_response = self._post_accept("/secure/")
        accept_response = self._post_accept("http://attacker/")
        self.assertRedirects(accept_response, "/")
    def test_accept_redirect_unsafe_2(self):
        accept_response = self._post_accept("//attacker.com")
        self.assertRedirects(accept_response, "/")

    def test_accept_redirect_unsafe_3(self):
        accept_response = self._post_accept("///attacker.com")
        self.assertRedirects(accept_response, "/")

    def test_accept_redirect_unsafe_4(self):
        accept_response = self._post_accept("////attacker.com")
from django.utils.encoding import iri_to_uri
    def get_return_to(self, from_dict):
        return_to = from_dict.get("returnTo", "/")

        if self.is_safe_url(return_to):
            # Django recommends to use this together with the helper above
            return iri_to_uri(return_to)

        LOGGER.debug("Unsafe URL found: {}".format(return_to))
        return "/"

    def is_safe_url(self, url):
        # In Django 3.0 is_safe_url is renamed, so we import conditionally:
        # https://docs.djangoproject.com/en/3.2/releases/3.0/#id3
        try:
            from django.utils.http import url_has_allowed_host_and_scheme
        except ImportError:
            from django.utils.http import (
                is_safe_url as url_has_allowed_host_and_scheme,
            )

        return url_has_allowed_host_and_scheme(url, settings.ALLOWED_HOSTS)

        return_to = self.get_return_to(self.request.GET)
        return_url = self.get_return_to(self.request.POST)
        return_to = self.get_return_to(self.request.GET)
        self.success_url = self.get_return_to(form.cleaned_data)