Repository: carpentries/amy

Issue:
Make URL redirects from `?next` param safer
There are a couple places where we redirect to `?next` value without checking if it's safe. There's a `django.http.is_safe_url` function we could use.


Discussion:

Resolving patch:
diff --git a/amy/autoemails/utils.py b/amy/autoemails/utils.py
--- a/amy/autoemails/utils.py
+++ b/amy/autoemails/utils.py
@@ -1,5 +1,7 @@
-from typing import Union
+from typing import Optional, Union
 
+from django.conf import settings
+from django.utils.http import is_safe_url
 import django_rq
 import pytz
 from rq.exceptions import NoSuchJobError
@@ -66,3 +68,9 @@ def check_status(job: Union[str, Job], scheduler=None):
         return job.get_status() or "scheduled"
     else:
         return job.get_status() or "cancelled"
+
+
+def safe_next_or_default_url(next_url: Optional[str], default: str) -> str:
+    if next_url is not None and is_safe_url(next_url, settings.ALLOWED_HOSTS):
+        return next_url
+    return default
diff --git a/amy/autoemails/views.py b/amy/autoemails/views.py
--- a/amy/autoemails/views.py
+++ b/amy/autoemails/views.py
@@ -13,7 +13,7 @@
 from .actions import GenericAction
 from .forms import GenericEmailScheduleForm
 from .models import EmailTemplate, Trigger
-from .utils import check_status, scheduled_execution_time
+from .utils import check_status, safe_next_or_default_url, scheduled_execution_time
 
 logger = logging.getLogger("amy.signals")
 scheduler = django_rq.get_scheduler("default")
@@ -102,9 +102,9 @@ def generic_schedule_email(request, pk):
             fail_silently=True,
         )
 
-        return redirect(
-            request.POST.get("next", "") or workshop_request.get_absolute_url()
-        )
+        default_url = workshop_request.get_absolute_url()
+        next_url = request.POST.get("next", None)
+        return redirect(safe_next_or_default_url(next_url, default_url))
 
     else:
         messages.error(
@@ -113,6 +113,6 @@ def generic_schedule_email(request, pk):
             fail_silently=True,
         )
 
-        return redirect(
-            request.POST.get("next", "") or workshop_request.get_absolute_url()
-        )
+        default_url = workshop_request.get_absolute_url()
+        next_url = request.POST.get("next", None)
+        return redirect(safe_next_or_default_url(next_url, default_url))


Test patch:
diff --git a/amy/autoemails/tests/test_utils.py b/amy/autoemails/tests/test_utils.py
--- a/amy/autoemails/tests/test_utils.py
+++ b/amy/autoemails/tests/test_utils.py
@@ -7,7 +7,11 @@
 from rq_scheduler.utils import to_unix
 
 from autoemails.tests.base import FakeRedisTestCaseMixin, dummy_fail_job, dummy_job
-from autoemails.utils import check_status, scheduled_execution_time
+from autoemails.utils import (
+    check_status,
+    safe_next_or_default_url,
+    scheduled_execution_time,
+)
 
 
 class TestScheduledExecutionTime(FakeRedisTestCaseMixin, TestCase):
@@ -160,3 +164,41 @@ def test_status_deferred_job(self):
         # test
         rv = check_status(job, self.scheduler)
         self.assertEqual(rv, "deferred")
+
+
+class TestSafeNextOrDefaultURL(TestCase):
+    def test_default_url_if_next_empty(self):
+        # Arrange
+        next_url = None
+        default_url = "/dashboard/"
+        # Act
+        url = safe_next_or_default_url(next_url, default_url)
+        # Assert
+        self.assertEqual(url, default_url)
+
+    def test_default_url_if_next_not_provided(self):
+        # Arrange
+        next_url = ""
+        default_url = "/dashboard/"
+        # Act
+        url = safe_next_or_default_url(next_url, default_url)
+        # Assert
+        self.assertEqual(url, default_url)
+
+    def test_default_url_if_next_not_safe(self):
+        # Arrange
+        next_url = "https://google.com"
+        default_url = "/dashboard/"
+        # Act
+        url = safe_next_or_default_url(next_url, default_url)
+        # Assert
+        self.assertEqual(url, default_url)
+
+    def test_next_url_if_next_safe(self):
+        # Arrange
+        next_url = "/admin/"
+        default_url = "/dashboard/"
+        # Act
+        url = safe_next_or_default_url(next_url, default_url)
+        # Assert
+        self.assertEqual(url, next_url)