CVE-2022-0869: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: Multiple Open Redirect in GitHub repository nitely/spirit prior to 0.12.3.
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-03-06T10:15Z
Repository: https://github.com/nitely/spirit
Language: Python
Files changed: spirit/admin/views.py, spirit/comment/flag/views.py, spirit/comment/like/views.py, spirit/comment/poll/views.py, spirit/comment/views.py, spirit/core/utils/decorators.py, spirit/core/utils/http.py, spirit/topic/favorite/views.py, spirit/topic/moderate/views.py, spirit/topic/notification/views.py, spirit/topic/private/views.py, spirit/topic/views.py, spirit/user/admin/views.py, spirit/user/auth/tests/tests.py, spirit/user/auth/views.py
Commit date: 2022-02-23 05:16:32 -0300
Commit message: fix unsafe redirect (#308)


Diff: diff --git a/spirit/admin/views.py b/spirit/admin/views.py
index 31e35a9d..00e134c5 100644
--- a/spirit/admin/views.py
+++ b/spirit/admin/views.py
@@ -1,14 +1,15 @@
 # -*- coding: utf-8 -*-
 
-from django.shortcuts import render, redirect
+from django.shortcuts import render
 from django.contrib import messages
 from django.utils.translation import gettext as _
 from django.contrib.auth import get_user_model
 
 import spirit
 import django
+from spirit.core.utils.http import safe_redirect
 from spirit.category.models import Category
 from spirit.comment.flag.models import CommentFlag
 from spirit.comment.like.models import CommentLike
 from spirit.comment.models import Comment
 from spirit.topic.models import Topic
@@ -23,11 +24,11 @@ User = get_user_model()
 def config_basic(request):
     form = BasicConfigForm(data=post_data(request))
     if is_post(request) and form.is_valid():
         form.save()
         messages.info(request, _("Settings updated!"))
-        return redirect(request.GET.get("next", request.get_full_path()))
+        return safe_redirect(request, "next", request.get_full_path())
     return render(
         request=request,
         template_name='spirit/admin/config_basic.html',
         context={'form': form})
 
diff --git a/spirit/comment/flag/views.py b/spirit/comment/flag/views.py
index a2f29520..9fa4cf16 100644
--- a/spirit/comment/flag/views.py
+++ b/spirit/comment/flag/views.py
@@ -1,11 +1,12 @@
 # -*- coding: utf-8 -*-
 
 from django.contrib.auth.decorators import login_required
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 
-from ...core.utils.views import is_post, post_data
+from spirit.core.utils.http import safe_redirect
+from spirit.core.utils.views import is_post, post_data
 from ..models import Comment
 from .forms import FlagForm
 
 
 @login_required
@@ -16,11 +17,11 @@ def create(request, comment_id):
         comment=comment,
         data=post_data(request))
 
     if is_post(request) and form.is_valid():
         form.save()
-        return redirect(request.POST.get('next', comment.get_absolute_url()))
+        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/comment/flag/create.html',
         context={
diff --git a/spirit/comment/like/views.py b/spirit/comment/like/views.py
index 6b046b99..541d0e57 100644
--- a/spirit/comment/like/views.py
+++ b/spirit/comment/like/views.py
@@ -1,11 +1,12 @@
 # -*- coding: utf-8 -*-
 
 from django.contrib.auth.decorators import login_required
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 from django.urls import reverse
 
+from spirit.core.utils.http import safe_redirect
 from spirit.core.utils.views import is_post, post_data, is_ajax
 from spirit.core.utils import json_response
 from spirit.comment.models import Comment
 from .models import CommentLike
 from .forms import LikeForm
@@ -26,11 +27,11 @@ def create(request, comment_id):
         like.comment.increase_likes_count()
 
         if is_ajax(request):
             return json_response({'url_delete': like.get_delete_url()})
 
-        return redirect(request.POST.get('next', comment.get_absolute_url()))
+        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/comment/like/create.html',
         context={
@@ -50,11 +51,12 @@ def delete(request, pk):
             url = reverse(
                 'spirit:comment:like:create',
                 kwargs={'comment_id': like.comment.pk})
             return json_response({'url_create': url, })
 
-        return redirect(request.POST.get('next', like.comment.get_absolute_url()))
+        return safe_redirect(
+            request, 'next', like.comment.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/comment/like/delete.html',
         context={'like': like})
diff --git a/spirit/comment/poll/views.py b/spirit/comment/poll/views.py
index 3a904df3..18838844 100644
--- a/spirit/comment/poll/views.py
+++ b/spirit/comment/poll/views.py
@@ -1,19 +1,20 @@
 # -*- coding: utf-8 -*-
 
 from django.contrib.auth.decorators import login_required
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 from django.views.decorators.http import require_POST
 from django.contrib import messages
 from django.contrib.auth.views import redirect_to_login
 from django.utils import timezone
 from django.core.exceptions import PermissionDenied
 
 from djconfig import config
 
-from ...core import utils
-from ...core.utils.paginator import yt_paginate
+from spirit.core.utils.http import safe_redirect
+from spirit.core import utils
+from spirit.core.utils.paginator import yt_paginate
 from .models import CommentPoll, CommentPollChoice, CommentPollVote
 from .forms import PollVoteManyForm
 
 
 @login_required
@@ -33,11 +34,11 @@ def close_or_open(request, pk, close=True):
 
     (CommentPoll.objects
      .filter(pk=poll.pk)
      .update(close_at=close_at))
 
-    return redirect(request.GET.get('next', poll.get_absolute_url()))
+    return safe_redirect(request, 'next', poll.get_absolute_url())
 
 
 @require_POST
 def vote(request, pk):
     # TODO: check if user has access to this topic/poll
@@ -53,14 +54,14 @@ def vote(request, pk):
 
     if form.is_valid():
         CommentPollChoice.decrease_vote_count(poll=poll, voter=request.user)
         form.save_m2m()
         CommentPollChoice.increase_vote_count(poll=poll, voter=request.user)
-        return redirect(request.POST.get('next', poll.get_absolute_url()))
+        return safe_redirect(request, 'next', poll.get_absolute_url(), method='POST')
 
     messages.error(request, utils.render_form_errors(form))
-    return redirect(request.POST.get('next', poll.get_absolute_url()))
+    return safe_redirect(request, 'next', poll.get_absolute_url(), method='POST')
 
 
 @login_required
 def voters(request, pk):
     # TODO: check if user has access to this topic/poll
diff --git a/spirit/comment/views.py b/spirit/comment/views.py
index e7dff724..232b0e8c 100644
--- a/spirit/comment/views.py
+++ b/spirit/comment/views.py
@@ -6,10 +6,11 @@ from django.views.decorators.http import require_POST
 from django.contrib import messages
 from django.http import Http404
 
 from djconfig import config
 
+from spirit.core.utils.http import safe_redirect
 from spirit.core.utils.views import is_post, post_data, is_ajax
 from spirit.core.utils.ratelimit.decorators import ratelimit
 from spirit.core.utils.decorators import moderator_required
 from spirit.core.utils import markdown, paginator, render_form_errors, json_response
 from spirit.topic.models import Topic
@@ -39,19 +40,18 @@ def publish(request, topic_id, pk=None):
         initial=initial)
 
     if is_post(request) and not request.is_limited() and form.is_valid():
         if not user.st.update_post_hash(form.get_comment_hash()):
             # Hashed comment may have not been saved yet
-            return redirect(
-                request.POST.get('next', None) or
-                Comment
+            default_url = lambda: (Comment
                 .get_last_for_topic(topic_id)
                 .get_absolute_url())
+            return safe_redirect(request, 'next', default_url, method='POST')
 
         comment = form.save()
         comment_posted(comment=comment, mentions=form.mentions)
-        return redirect(request.POST.get('next', comment.get_absolute_url()))
+        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/comment/publish.html',
         context={
@@ -65,11 +65,11 @@ def update(request, pk):
     form = CommentForm(data=post_data(request), instance=comment)
     if is_post(request) and form.is_valid():
         pre_comment_update(comment=Comment.objects.get(pk=comment.pk))
         comment = form.save()
         post_comment_update(comment=comment)
-        return redirect(request.POST.get('next', comment.get_absolute_url()))
+        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
     return render(
         request=request,
         template_name='spirit/comment/update.html',
         context={'form': form})
 
@@ -79,11 +79,11 @@ def delete(request, pk, remove=True):
     comment = get_object_or_404(Comment, pk=pk)
     if is_post(request):
         (Comment.objects
          .filter(pk=pk)
          .update(is_removed=remove))
-        return redirect(request.GET.get('next', comment.get_absolute_url()))
+        return safe_redirect(request, 'next', comment.get_absolute_url())
     return render(
         request=request,
         template_name='spirit/comment/moderate.html',
         context={'comment': comment})
 
@@ -102,11 +102,11 @@ def move(request, topic_id):
             topic.decrease_comment_count()
             post_comment_move(comment=comment, topic=topic)
     else:
         messages.error(request, render_form_errors(form))
 
-    return redirect(request.POST.get('next', topic.get_absolute_url()))
+    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
 
 
 def find(request, pk):
     comment = get_object_or_404(Comment.objects.select_related('topic'), pk=pk)
     comment_number = (
diff --git a/spirit/core/utils/decorators.py b/spirit/core/utils/decorators.py
index 5c68224e..425bc9d0 100644
--- a/spirit/core/utils/decorators.py
+++ b/spirit/core/utils/decorators.py
@@ -2,13 +2,13 @@
 
 from functools import wraps
 
 from django.core.exceptions import PermissionDenied
 from django.contrib.auth.views import redirect_to_login
-from django.shortcuts import redirect
 
 from spirit.core.conf import settings
+from spirit.core.utils.http import safe_redirect
 
 
 def moderator_required(view_func):
     @wraps(view_func)
     def wrapper(request, *args, **kwargs):
@@ -46,10 +46,10 @@ def administrator_required(view_func):
 def guest_only(view_func):
     # TODO: test!
     @wraps(view_func)
     def wrapper(request, *args, **kwargs):
         if request.user.is_authenticated:
-            return redirect(request.GET.get('next', request.user.st.get_absolute_url()))
+            return safe_redirect(request, 'next', request.user.st.get_absolute_url())
 
         return view_func(request, *args, **kwargs)
 
     return wrapper
diff --git a/spirit/core/utils/http.py b/spirit/core/utils/http.py
new file mode 100644
index 00000000..fb3626fa
--- /dev/null
+++ b/spirit/core/utils/http.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+from django.shortcuts import redirect
+from django.utils.encoding import iri_to_uri
+
+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
+
+
+def _resolve_lazy_url(url):
+    if callable(url):
+        return url()
+    return url
+
+
+def safe_redirect(request, key, default_url='', method='GET'):
+    next = (
+        getattr(request, method).get(key, None) or
+        _resolve_lazy_url(default_url)
+    )
+    url_is_safe = url_has_allowed_host_and_scheme(
+        url=next, allowed_hosts=None)
+        #allowed_hosts=settings.ALLOWED_HOSTS,
+        #require_https=request.is_secure())
+    if url_is_safe:
+        return redirect(iri_to_uri(next))
+    return redirect('/')
diff --git a/spirit/topic/favorite/views.py b/spirit/topic/favorite/views.py
index f95e7eca..04a6241b 100644
--- a/spirit/topic/favorite/views.py
+++ b/spirit/topic/favorite/views.py
@@ -1,17 +1,17 @@
 # -*- coding: utf-8 -*-
 
 from django.contrib.auth.decorators import login_required
 from django.shortcuts import get_object_or_404
-from django.shortcuts import redirect
 from django.views.decorators.http import require_POST
 from django.contrib import messages
 
 from .models import TopicFavorite
 from .forms import FavoriteForm
 from ..models import Topic
-from ...core import utils
+from spirit.core import utils
+from spirit.core.utils.http import safe_redirect
 
 
 @require_POST
 @login_required
 def create(request, topic_id):
@@ -21,14 +21,14 @@ def create(request, topic_id):
     if form.is_valid():
         form.save()
     else:
         messages.error(request, utils.render_form_errors(form))
 
-    return redirect(request.POST.get('next', topic.get_absolute_url()))
+    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
 
 
 @require_POST
 @login_required
 def delete(request, pk):
     favorite = get_object_or_404(TopicFavorite, pk=pk, user=request.user)
     favorite.delete()
-    return redirect(request.POST.get('next', favorite.topic.get_absolute_url()))
+    return safe_redirect(request, 'next', favorite.topic.get_absolute_url(), method='POST')
diff --git a/spirit/topic/moderate/views.py b/spirit/topic/moderate/views.py
index 1502f2d3..752ec99f 100644
--- a/spirit/topic/moderate/views.py
+++ b/spirit/topic/moderate/views.py
@@ -1,12 +1,13 @@
 # -*- coding: utf-8 -*-
 
 from django.utils import timezone
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 from django.contrib import messages
 from django.utils.translation import gettext as _
 
+from spirit.core.utils.http import safe_redirect
 from spirit.core.utils.views import is_post
 from spirit.core.utils.decorators import moderator_required
 from spirit.comment.models import Comment
 from spirit.topic.models import Topic
 
@@ -31,12 +32,11 @@ def _moderate(request, pk, field_name, to_value, action=None, message=None):
                 action=action)
 
         if message is not None:
             messages.info(request, message)
 
-        return redirect(request.POST.get(
-            'next', topic.get_absolute_url()))
+        return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/topic/moderate.html',
         context={'topic': topic})
diff --git a/spirit/topic/notification/views.py b/spirit/topic/notification/views.py
index 7ed189d6..da8673ea 100644
--- a/spirit/topic/notification/views.py
+++ b/spirit/topic/notification/views.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 
 import json
 
 from django.contrib.auth.decorators import login_required
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 from django.views.decorators.http import require_POST
 from django.http import Http404, HttpResponse
 from django.contrib import messages
 from django.utils.html import escape
 from django.urls import reverse
@@ -16,10 +16,11 @@ from infinite_scroll_pagination.serializers import to_page_key
 from spirit.core.conf import settings
 from spirit.core import utils
 from spirit.core.utils.paginator import yt_paginate
 from spirit.core.utils.paginator.infinite_paginator import paginate
 from spirit.core.utils.views import is_ajax
+from spirit.core.utils.http import safe_redirect
 from spirit.topic.models import Topic
 from .models import TopicNotification
 from .forms import NotificationForm, NotificationCreationForm
 
 
@@ -37,11 +38,11 @@ def create(request, topic_id):
     if form.is_valid():
         form.save()
     else:
         messages.error(request, utils.render_form_errors(form))
 
-    return redirect(request.POST.get('next', topic.get_absolute_url()))
+    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
 
 
 @require_POST
 @login_required
 def update(request, pk):
@@ -51,12 +52,12 @@ def update(request, pk):
     if form.is_valid():
         form.save()
     else:
         messages.error(request, utils.render_form_errors(form))
 
-    return redirect(request.POST.get(
-        'next', notification.topic.get_absolute_url()))
+    return safe_redirect(
+        request, 'next', notification.topic.get_absolute_url(), method='POST')
 
 
 @login_required
 def index_ajax(request):
     if not is_ajax(request):
@@ -122,7 +123,7 @@ def index(request):
 def mark_all_as_read(request):
     (TopicNotification.objects
         .for_access(request.user)
         .filter(is_read=False)
         .update(is_read=True))
-    return redirect(request.POST.get(
-        'next', reverse('spirit:topic:notification:index')))
+    return safe_redirect(
+        request, 'next', reverse('spirit:topic:notification:index'), method='POST')
diff --git a/spirit/topic/private/views.py b/spirit/topic/private/views.py
index bd0f2a15..4ddd4852 100644
--- a/spirit/topic/private/views.py
+++ b/spirit/topic/private/views.py
@@ -8,18 +8,19 @@ from django.views.decorators.http import require_POST
 from django.contrib import messages
 from django.http import HttpResponsePermanentRedirect
 
 from djconfig import config
 
-from ...core.conf import settings
-from ...core import utils
-from ...core.utils.views import is_post, post_data
-from ...core.utils.paginator import paginate, yt_paginate
-from ...core.utils.ratelimit.decorators import ratelimit
-from ...comment.forms import CommentForm
-from ...comment.utils import comment_posted
-from ...comment.models import Comment
+from spirit.core.conf import settings
+from spirit.core import utils
+from spirit.core.utils.http import safe_redirect
+from spirit.core.utils.views import is_post, post_data
+from spirit.core.utils.paginator import paginate, yt_paginate
+from spirit.core.utils.ratelimit.decorators import ratelimit
+from spirit.comment.forms import CommentForm
+from spirit.comment.utils import comment_posted
+from spirit.comment.models import Comment
 from ..models import Topic
 from ..utils import topic_viewed
 from .utils import notify_access
 from .models import TopicPrivate
 from .forms import (
@@ -48,13 +49,12 @@ def publish(request, user_id=None):
 
     if (is_post(request) and
             all([tform.is_valid(), cform.is_valid(), tpform.is_valid()]) and
             not request.is_limited()):
         if not user.st.update_post_hash(tform.get_topic_hash()):
-            return redirect(
-                request.POST.get('next', None) or
-                tform.category.get_absolute_url())
+            return safe_redirect(
+                request, 'next', lambda: tform.category.get_absolute_url(), method='POST')
 
         # wrap in transaction.atomic?
         topic = tform.save()
         cform.topic = topic
         comment = cform.save()
@@ -121,11 +121,12 @@ def create_access(request, topic_id):
         form.save()
         notify_access(user=form.get_user(), topic_private=topic_private)
     else:
         messages.error(request, utils.render_form_errors(form))
 
-    return redirect(request.POST.get('next', topic_private.get_absolute_url()))
+    return safe_redirect(
+        request, 'next', topic_private.get_absolute_url(), method='POST')
 
 
 @login_required
 def delete_access(request, pk):
     topic_private = TopicPrivate.objects.for_delete_or_404(pk, request.user)
@@ -134,11 +135,12 @@ def delete_access(request, pk):
         topic_private.delete()
 
         if request.user.pk == topic_private.user_id:
             return redirect(reverse("spirit:topic:private:index"))
 
-        return redirect(request.POST.get('next', topic_private.get_absolute_url()))
+        return safe_redirect(
+            request, 'next', topic_private.get_absolute_url(), method='POST')
 
     return render(
         request=request,
         template_name='spirit/topic/private/delete.html',
         context={'topic_private': topic_private})
@@ -158,11 +160,12 @@ def join_in(request, topic_id):
         user=request.user,
         data=post_data(request))
     if is_post(request) and form.is_valid():
         topic_private = form.save()
         notify_access(user=form.get_user(), topic_private=topic_private)
-        return redirect(request.POST.get('next', topic.get_absolute_url()))
+        return safe_redirect(
+            request, 'next', topic.get_absolute_url(), method='POST')
     return render(
         request=request,
         template_name='spirit/topic/private/join.html',
         context={
             'topic': topic,
diff --git a/spirit/topic/views.py b/spirit/topic/views.py
index cfba1d83..a2a66fcc 100644
--- a/spirit/topic/views.py
+++ b/spirit/topic/views.py
@@ -4,10 +4,11 @@ from django.contrib.auth.decorators import login_required
 from django.shortcuts import render, redirect, get_object_or_404
 from django.http import HttpResponsePermanentRedirect
 
 from djconfig import config
 
+from spirit.core.utils.http import safe_redirect
 from spirit.core.utils.views import is_post, post_data
 from spirit.core.utils.paginator import paginate, yt_paginate
 from spirit.core.utils.ratelimit.decorators import ratelimit
 from spirit.category.models import Category
 from spirit.comment.forms import CommentForm
@@ -36,13 +37,13 @@ def publish(request, category_id=None):
         data=post_data(request))
     if (is_post(request) and
             all([form.is_valid(), cform.is_valid()]) and
             not request.is_limited()):
         if not user.st.update_post_hash(form.get_topic_hash()):
-            return redirect(
-                request.POST.get('next', None) or
-                form.get_category().get_absolute_url())
+            default_url = lambda: form.get_category().get_absolute_url()
+            return safe_redirect(
+                request, 'next', default_url, method='POST')
         # wrap in transaction.atomic?
         topic = form.save()
         cform.topic = topic
         comment = cform.save()
         comment_posted(comment=comment, mentions=cform.mentions)
@@ -64,11 +65,11 @@ def update(request, pk):
     if is_post(request) and form.is_valid():
         topic = form.save()
         if topic.category_id != category_id:
             Comment.create_moderation_action(
                 user=request.user, topic=topic, action=Comment.MOVED)
-        return redirect(request.POST.get('next', topic.get_absolute_url()))
+        return safe_redirect(request,'next', topic.get_absolute_url(), method='POST')
     return render(
         request=request,
         template_name='spirit/topic/update.html',
         context={'form': form})
 
diff --git a/spirit/user/admin/views.py b/spirit/user/admin/views.py
index 9e5e4230..a6cdb0c2 100644
--- a/spirit/user/admin/views.py
+++ b/spirit/user/admin/views.py
@@ -1,17 +1,18 @@
 # -*- coding: utf-8 -*-
 
-from django.shortcuts import render, redirect, get_object_or_404
+from django.shortcuts import render, get_object_or_404
 from django.contrib.auth import get_user_model
 from django.contrib import messages
 from django.utils.translation import gettext as _
 
 from djconfig import config
 
-from ...core.utils.views import is_post, post_data
-from ...core.utils.paginator import yt_paginate
-from ...core.utils.decorators import administrator_required
+from spirit.core.utils.http import safe_redirect
+from spirit.core.utils.views import is_post, post_data
+from spirit.core.utils.paginator import yt_paginate
+from spirit.core.utils.decorators import administrator_required
 from .forms import UserForm, UserProfileForm
 
 User = get_user_model()
 
 
@@ -22,11 +23,11 @@ def edit(request, user_id):
     form = UserProfileForm(data=post_data(request), instance=user.st)
     if is_post(request) and all([uform.is_valid(), form.is_valid()]):
         uform.save()
         form.save()
         messages.info(request, _("This profile has been updated!"))
-        return redirect(request.GET.get("next", request.get_full_path()))
+        return safe_redirect(request, "next", request.get_full_path())
     return render(
         request=request,
         template_name='spirit/user/admin/edit.html',
         context={'form': form, 'uform': uform})
 
diff --git a/spirit/user/auth/tests/tests.py b/spirit/user/auth/tests/tests.py
index ba092b46..2c1103ad 100644
--- a/spirit/user/auth/tests/tests.py
+++ b/spirit/user/auth/tests/tests.py
@@ -53,10 +53,15 @@ class UserViewTest(TestCase):
         self.assertRedirects(response, expected_url, status_code=302)
         # next
         response = self.client.get(reverse('spirit:user:auth:login') + '?next=/fakepath/')
         self.assertRedirects(response, '/fakepath/', status_code=302, target_status_code=404)
 
+    def test_login_open_redirect(self):
+        utils.login(self)
+        response = self.client.get(reverse('spirit:user:auth:login') + '?next=https%3A%2F%2Fevil.com')
+        self.assertRedirects(response, '/', status_code=302)
+
     @override_settings(ST_CASE_INSENSITIVE_EMAILS=True)
     def test_login_email_case_insensitive(self):
         """
         try to login by email
         """
diff --git a/spirit/user/auth/views.py b/spirit/user/auth/views.py
index 8a90554f..514f219a 100644
--- a/spirit/user/auth/views.py
+++ b/spirit/user/auth/views.py
@@ -7,10 +7,11 @@ from django.shortcuts import redirect, render, get_object_or_404
 from django.utils.translation import gettext as _
 from django.contrib.auth import get_user_model
 from django.urls import reverse_lazy
 
 from spirit.core.conf import settings
+from spirit.core.utils.http import safe_redirect
 from spirit.core.utils.views import is_post, post_data
 from spirit.core.utils.ratelimit.decorators import ratelimit
 from spirit.user.utils.email import send_activation_email
 from spirit.user.utils.tokens import UserActivationTokenGenerator
 from .forms import (
@@ -60,22 +61,23 @@ custom_password_reset_done = _CustomPasswordResetDoneView.as_view()
 @ratelimit(field='username', rate='5/5m')
 # TODO: @guest_only
 def custom_login(request, **kwargs):
     # Currently, Django 1.5 login view does not redirect somewhere if the user is logged in
     if request.user.is_authenticated:
-        return redirect(request.GET.get('next', request.user.st.get_absolute_url()))
+        return safe_redirect(
+            request, 'next', request.user.st.get_absolute_url())
 
     if request.method == "POST" and request.is_limited():
         return redirect(request.get_full_path())
 
     return _login_view(request, authentication_form=LoginForm, **kwargs)
 
 
 # TODO: @login_required ?
 def custom_logout(request, **kwargs):
     if not request.user.is_authenticated:
-        return redirect(request.GET.get('next', reverse(settings.LOGIN_URL)))
+        return safe_redirect(request, 'next', reverse(settings.LOGIN_URL))
 
     if request.method == 'POST':
         return _logout_view(request, **kwargs)
 
     return render(request, 'spirit/user/auth/logout.html')
@@ -91,11 +93,11 @@ def custom_password_reset(request, **kwargs):
 
 @ratelimit(rate='2/10s')
 # TODO: @guest_only
 def register(request, registration_form=RegistrationForm):
     if request.user.is_authenticated:
-        return redirect(request.GET.get('next', reverse('spirit:user:update')))
+        return safe_redirect(request, 'next', reverse('spirit:user:update'))
 
     form = registration_form(data=post_data(request))
     if (is_post(request) and
             not request.is_limited() and
             form.is_valid()):
@@ -107,11 +109,11 @@ def register(request, registration_form=RegistrationForm):
                 "so you can activate your account!") % {'email': form.get_email()})
 
         # TODO: email-less activation
         # if not settings.REGISTER_EMAIL_ACTIVATION_REQUIRED:
         # login(request, user)
-        # return redirect(request.GET.get('next', reverse('spirit:user:update')))
+        # return safe_redirect(request, 'next', reverse('spirit:user:update'))
 
         return redirect(reverse(settings.LOGIN_URL))
     return render(
         request=request,
         template_name='spirit/user/auth/register.html',
@@ -133,11 +135,11 @@ def registration_activation(request, pk, token):
 
 @ratelimit(field='email', rate='5/5m')
 # TODO: @guest_only
 def resend_activation_email(request):
     if request.user.is_authenticated:
-        return redirect(request.GET.get('next', reverse('spirit:user:update')))
+        return safe_redirect(request, 'next', reverse('spirit:user:update'))
 
     form = ResendActivationForm(data=post_data(request))
     if is_post(request):
         if not request.is_limited() and form.is_valid():
             user = form.get_user()

Vulnerable code: from django.shortcuts import render, redirect
        return redirect(request.GET.get("next", request.get_full_path()))
from django.shortcuts import render, redirect, get_object_or_404
from ...core.utils.views import is_post, post_data
        return redirect(request.POST.get('next', comment.get_absolute_url()))
from django.shortcuts import render, redirect, get_object_or_404
        return redirect(request.POST.get('next', comment.get_absolute_url()))
        return redirect(request.POST.get('next', like.comment.get_absolute_url()))
from django.shortcuts import render, redirect, get_object_or_404
from ...core import utils
from ...core.utils.paginator import yt_paginate
    return redirect(request.GET.get('next', poll.get_absolute_url()))
        return redirect(request.POST.get('next', poll.get_absolute_url()))
    return redirect(request.POST.get('next', poll.get_absolute_url()))
            return redirect(
                request.POST.get('next', None) or
                Comment
        return redirect(request.POST.get('next', comment.get_absolute_url()))
        return redirect(request.POST.get('next', comment.get_absolute_url()))
        return redirect(request.GET.get('next', comment.get_absolute_url()))
    return redirect(request.POST.get('next', topic.get_absolute_url()))
from django.shortcuts import redirect
            return redirect(request.GET.get('next', request.user.st.get_absolute_url()))
from django.shortcuts import redirect
from ...core import utils
    return redirect(request.POST.get('next', topic.get_absolute_url()))
    return redirect(request.POST.get('next', favorite.topic.get_absolute_url()))
from django.shortcuts import render, redirect, get_object_or_404
        return redirect(request.POST.get(
            'next', topic.get_absolute_url()))
from django.shortcuts import render, redirect, get_object_or_404
    return redirect(request.POST.get('next', topic.get_absolute_url()))
    return redirect(request.POST.get(
        'next', notification.topic.get_absolute_url()))
    return redirect(request.POST.get(
        'next', reverse('spirit:topic:notification:index')))
from ...core.conf import settings
from ...core import utils
from ...core.utils.views import is_post, post_data
from ...core.utils.paginator import paginate, yt_paginate
from ...core.utils.ratelimit.decorators import ratelimit
from ...comment.forms import CommentForm
from ...comment.utils import comment_posted
from ...comment.models import Comment
            return redirect(
                request.POST.get('next', None) or
                tform.category.get_absolute_url())
    return redirect(request.POST.get('next', topic_private.get_absolute_url()))
        return redirect(request.POST.get('next', topic_private.get_absolute_url()))
        return redirect(request.POST.get('next', topic.get_absolute_url()))
            return redirect(
                request.POST.get('next', None) or
                form.get_category().get_absolute_url())
        return redirect(request.POST.get('next', topic.get_absolute_url()))
from django.shortcuts import render, redirect, get_object_or_404
from ...core.utils.views import is_post, post_data
from ...core.utils.paginator import yt_paginate
from ...core.utils.decorators import administrator_required
        return redirect(request.GET.get("next", request.get_full_path()))
        return redirect(request.GET.get('next', request.user.st.get_absolute_url()))
        return redirect(request.GET.get('next', reverse(settings.LOGIN_URL)))
        return redirect(request.GET.get('next', reverse('spirit:user:update')))
        # return redirect(request.GET.get('next', reverse('spirit:user:update')))
        return redirect(request.GET.get('next', reverse('spirit:user:update')))
Fixed code: from django.shortcuts import render
from spirit.core.utils.http import safe_redirect
        return safe_redirect(request, "next", request.get_full_path())
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
from spirit.core.utils.views import is_post, post_data
        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
        return safe_redirect(
            request, 'next', like.comment.get_absolute_url(), method='POST')
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
from spirit.core import utils
from spirit.core.utils.paginator import yt_paginate
    return safe_redirect(request, 'next', poll.get_absolute_url())
        return safe_redirect(request, 'next', poll.get_absolute_url(), method='POST')
    return safe_redirect(request, 'next', poll.get_absolute_url(), method='POST')
from spirit.core.utils.http import safe_redirect
            default_url = lambda: (Comment
            return safe_redirect(request, 'next', default_url, method='POST')
        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
        return safe_redirect(request, 'next', comment.get_absolute_url(), method='POST')
        return safe_redirect(request, 'next', comment.get_absolute_url())
    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
from spirit.core.utils.http import safe_redirect
            return safe_redirect(request, 'next', request.user.st.get_absolute_url())
# -*- coding: utf-8 -*-

from django.shortcuts import redirect
from django.utils.encoding import iri_to_uri

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


def _resolve_lazy_url(url):
    if callable(url):
        return url()
    return url


def safe_redirect(request, key, default_url='', method='GET'):
    next = (
        getattr(request, method).get(key, None) or
        _resolve_lazy_url(default_url)
    )
    url_is_safe = url_has_allowed_host_and_scheme(
        url=next, allowed_hosts=None)
        #allowed_hosts=settings.ALLOWED_HOSTS,
        #require_https=request.is_secure())
    if url_is_safe:
        return redirect(iri_to_uri(next))
    return redirect('/')
from spirit.core import utils
from spirit.core.utils.http import safe_redirect
    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
    return safe_redirect(request, 'next', favorite.topic.get_absolute_url(), method='POST')
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
        return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
    return safe_redirect(request, 'next', topic.get_absolute_url(), method='POST')
    return safe_redirect(
        request, 'next', notification.topic.get_absolute_url(), method='POST')
    return safe_redirect(
        request, 'next', reverse('spirit:topic:notification:index'), method='POST')
from spirit.core.conf import settings
from spirit.core import utils
from spirit.core.utils.http import safe_redirect
from spirit.core.utils.views import is_post, post_data
from spirit.core.utils.paginator import paginate, yt_paginate
from spirit.core.utils.ratelimit.decorators import ratelimit
from spirit.comment.forms import CommentForm
from spirit.comment.utils import comment_posted
from spirit.comment.models import Comment
            return safe_redirect(
                request, 'next', lambda: tform.category.get_absolute_url(), method='POST')
    return safe_redirect(
        request, 'next', topic_private.get_absolute_url(), method='POST')
        return safe_redirect(
            request, 'next', topic_private.get_absolute_url(), method='POST')
        return safe_redirect(
            request, 'next', topic.get_absolute_url(), method='POST')
from spirit.core.utils.http import safe_redirect
            default_url = lambda: form.get_category().get_absolute_url()
            return safe_redirect(
                request, 'next', default_url, method='POST')
        return safe_redirect(request,'next', topic.get_absolute_url(), method='POST')
from django.shortcuts import render, get_object_or_404
from spirit.core.utils.http import safe_redirect
from spirit.core.utils.views import is_post, post_data
from spirit.core.utils.paginator import yt_paginate
from spirit.core.utils.decorators import administrator_required
        return safe_redirect(request, "next", request.get_full_path())
    def test_login_open_redirect(self):
        utils.login(self)
        response = self.client.get(reverse('spirit:user:auth:login') + '?next=https%3A%2F%2Fevil.com')
        self.assertRedirects(response, '/', status_code=302)

from spirit.core.utils.http import safe_redirect
        return safe_redirect(
            request, 'next', request.user.st.get_absolute_url())
        return safe_redirect(request, 'next', reverse(settings.LOGIN_URL))
        return safe_redirect(request, 'next', reverse('spirit:user:update'))
        # return safe_redirect(request, 'next', reverse('spirit:user:update'))
        return safe_redirect(request, 'next', reverse('spirit:user:update'))