Multiple Open Redirect in nitely/spirit
Description
Spirit prior to 0.12.3 contains multiple open redirect vulnerabilities in comment views, allowing an attacker to redirect users to arbitrary external sites.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Spirit prior to 0.12.3 contains multiple open redirect vulnerabilities in comment views, allowing an attacker to redirect users to arbitrary external sites.
Vulnerability
Multiple open redirect vulnerabilities exist in Spirit versions prior to 0.12.3, a Python/Django forum application [3]. The flaws reside in the publish, update, delete, and move comment views, where user-supplied next parameters from POST or GET requests were used directly in redirect() calls without proper validation [2]. This allowed an attacker to supply a malicious URL as the next parameter, causing the application to redirect the user to an arbitrary external site.
Exploitation
An attacker can exploit this by crafting a link that directs a logged-in user to a legitimate Spirit page (e.g., a comment form) while embedding a malicious URL in the next parameter. Upon submitting a form or performing an action, the application would redirect the user to the attacker-controlled site. No authentication beyond a valid user session is required, and the attack can be triggered via cross-site request forgery (CSRF) if the user is tricked into clicking a crafted link or submitting a form [2]. The fix introduced the safe_redirect helper to validate redirect targets [2].
Impact
A successful attack can redirect users to malicious websites, enabling phishing attacks, credential theft, or distribution of malware. The open redirect can also be used to bypass URL validation schemes and harm the application's reputation. While no direct data exposure or code execution occurs, the open redirect facilitates social engineering and trust exploitation.
Mitigation
The vulnerability is fixed in Spirit version 0.12.3, released on or around February 26, 2022 [1]. Users should upgrade to this version or later. The commit [2] replaced insecure redirect() calls with safe_redirect() from the Spirit core, ensuring only safe redirects are allowed. If upgrading is not immediately possible, site administrators should monitor for suspicious next parameter usage and consider implementing network-level controls to restrict outgoing redirects, though the vendor patch is the recommended solution.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
django-spiritPyPI | < 0.12.3 | 0.12.3 |
Affected products
2- Range: unspecified
Patches
18f32f89654d6fix unsafe redirect (#308)
15 files changed · +111 −64
spirit/admin/views.py+3 −2 modified@@ -1,12 +1,13 @@ # -*- 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 @@ -25,7 +26,7 @@ def config_basic(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',
spirit/comment/flag/views.py+4 −3 modified@@ -1,9 +1,10 @@ # -*- 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 @@ -18,7 +19,7 @@ def create(request, comment_id): 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,
spirit/comment/like/views.py+5 −3 modified@@ -1,9 +1,10 @@ # -*- 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 @@ -28,7 +29,7 @@ def create(request, comment_id): 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, @@ -52,7 +53,8 @@ def delete(request, pk): 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,
spirit/comment/poll/views.py+7 −6 modified@@ -1,7 +1,7 @@ # -*- 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 @@ -10,8 +10,9 @@ 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 @@ -35,7 +36,7 @@ def close_or_open(request, pk, close=True): .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 @@ -55,10 +56,10 @@ def vote(request, pk): 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
spirit/comment/views.py+7 −7 modified@@ -8,6 +8,7 @@ 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 @@ -41,15 +42,14 @@ def publish(request, topic_id, pk=None): 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, @@ -67,7 +67,7 @@ def update(request, pk): 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', @@ -81,7 +81,7 @@ def delete(request, pk, remove=True): (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', @@ -104,7 +104,7 @@ def move(request, topic_id): 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):
spirit/core/utils/decorators.py+2 −2 modified@@ -4,9 +4,9 @@ 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): @@ -48,7 +48,7 @@ def guest_only(view_func): @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)
spirit/core/utils/http.py+29 −0 added@@ -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('/')
spirit/topic/favorite/views.py+4 −4 modified@@ -2,14 +2,14 @@ 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 @@ -23,12 +23,12 @@ def create(request, topic_id): 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')
spirit/topic/moderate/views.py+3 −3 modified@@ -1,10 +1,11 @@ # -*- 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 @@ -33,8 +34,7 @@ def _moderate(request, pk, field_name, to_value, action=None, message=None): 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,
spirit/topic/notification/views.py+7 −6 modified@@ -3,7 +3,7 @@ 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 @@ -18,6 +18,7 @@ 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 @@ -39,7 +40,7 @@ def create(request, topic_id): 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 @@ -53,8 +54,8 @@ def update(request, pk): 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 @@ -124,5 +125,5 @@ def mark_all_as_read(request): .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')
spirit/topic/private/views.py+17 −14 modified@@ -10,14 +10,15 @@ 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 @@ -50,9 +51,8 @@ def publish(request, user_id=None): 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() @@ -123,7 +123,8 @@ def create_access(request, topic_id): 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 @@ -136,7 +137,8 @@ def delete_access(request, pk): 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, @@ -160,7 +162,8 @@ def join_in(request, topic_id): 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',
spirit/topic/views.py+5 −4 modified@@ -6,6 +6,7 @@ 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 @@ -38,9 +39,9 @@ def publish(request, category_id=None): 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 @@ -66,7 +67,7 @@ def update(request, pk): 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',
spirit/user/admin/views.py+6 −5 modified@@ -1,15 +1,16 @@ # -*- 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() @@ -24,7 +25,7 @@ def edit(request, user_id): 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',
spirit/user/auth/tests/tests.py+5 −0 modified@@ -55,6 +55,11 @@ def test_login_redirect(self): 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): """
spirit/user/auth/views.py+7 −5 modified@@ -9,6 +9,7 @@ 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 @@ -62,7 +63,8 @@ class _CustomLoginView(django_views.LoginView): 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()) @@ -73,7 +75,7 @@ def custom_login(request, **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) @@ -93,7 +95,7 @@ def custom_password_reset(request, **kwargs): # 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 @@ -109,7 +111,7 @@ def register(request, registration_form=RegistrationForm): # 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( @@ -135,7 +137,7 @@ def registration_activation(request, pk, token): # 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):
Vulnerability mechanics
Root cause
"The application failed to validate user-supplied redirect URLs, allowing for open redirection."
Attack vector
An attacker can trigger an open redirect by providing a malicious URL in the `next` parameter of a POST or GET request to various endpoints within the `spirit` application. Because the application previously trusted this input without validation, it would redirect the user to an arbitrary external domain [patch_id=17806]. This vulnerability allows attackers to conduct phishing attacks by redirecting users to malicious sites under the guise of the legitimate application.
Affected code
Multiple view functions across the `spirit` application were affected, including those in `spirit/topic/private/views.py`, `spirit/comment/views.py`, `spirit/comment/poll/views.py`, `spirit/topic/notification/views.py`, `spirit/user/auth/views.py`, `spirit/user/admin/views.py`, `spirit/topic/views.py`, `spirit/comment/like/views.py`, and `spirit/topic/favorite/views.py`. These views improperly handled user-supplied redirect URLs by directly passing them to the `redirect()` function [patch_id=17806].
What the fix does
The patch introduces a new utility function `safe_redirect` in `spirit/core/utils/http.py` which utilizes `url_has_allowed_host_and_scheme` to validate the target URL before performing a redirect [patch_id=17806]. All identified vulnerable views were updated to use this `safe_redirect` function instead of the standard Django `redirect` function. This ensures that only safe, local URLs are processed, effectively preventing open redirect attacks [patch_id=17806].
Preconditions
- inputThe attacker must be able to send a request to the application with a 'next' parameter containing an arbitrary URL.
Generated on May 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-5p9j-w2wx-qx4cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-0869ghsaADVISORY
- github.com/nitely/spirit/commit/8f32f89654d6c30d56e0dd167059d32146fb32efghsax_refsource_MISCWEB
- huntr.dev/bounties/ed335a88-f68c-4e4d-ac85-f29a51b03342ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.