Fix localized setlang redirects for prefixed next paths

This commit is contained in:
2026-04-11 21:03:29 +02:00
parent 497addffb2
commit d9ecab62e3
2 changed files with 54 additions and 37 deletions

View File

@@ -1,28 +1,55 @@
from __future__ import annotations from __future__ import annotations
import inspect from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.http import HttpRequest, HttpResponse from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import check_for_language
from django.utils.translation import get_language as _get_language
from django.utils.translation import translate_url
from django.views.i18n import LANGUAGE_QUERY_PARAMETER
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.i18n import set_language as django_set_language
from .i18n_utils import normalize_set_language_next from .i18n_utils import normalize_set_language_next
@csrf_exempt @csrf_exempt
def set_language_normalized(request: HttpRequest) -> HttpResponse: def set_language_normalized(request: HttpRequest) -> HttpResponse:
"""Normalize `next` before delegating to Django's set_language view.""" """
if request.method == "POST": Set language while normalizing `next` to avoid duplicated locale prefixes.
next_value = request.POST.get("next")
if next_value is not None:
post_data = request.POST.copy()
post_data["next"] = normalize_set_language_next(next_value)
request.POST = post_data
else:
next_value = request.GET.get("next")
if next_value is not None:
get_data = request.GET.copy()
get_data["next"] = normalize_set_language_next(next_value)
request.GET = get_data
return inspect.unwrap(django_set_language)(request) Mirrors Django's set_language behavior closely, but enforces `next`
normalization before translating redirects.
"""
next_url = request.POST.get("next", request.GET.get("next"))
if next_url:
next_url = normalize_set_language_next(next_url)
if next_url and not url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
next_url = request.META.get("HTTP_REFERER")
if not next_url:
next_url = "/"
response: HttpResponse = HttpResponseRedirect(next_url)
if request.method == "POST":
lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
if lang_code and check_for_language(lang_code):
translated = translate_url(next_url, lang_code)
if translated != next_url:
response = HttpResponseRedirect(translated)
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME,
lang_code,
max_age=settings.LANGUAGE_COOKIE_AGE,
path=settings.LANGUAGE_COOKIE_PATH,
domain=settings.LANGUAGE_COOKIE_DOMAIN,
secure=settings.LANGUAGE_COOKIE_SECURE,
httponly=settings.LANGUAGE_COOKIE_HTTPONLY,
samesite=settings.LANGUAGE_COOKIE_SAMESITE,
)
response.headers.setdefault("Content-Language", _get_language())
return response

View File

@@ -1,6 +1,3 @@
from unittest.mock import patch
from django.http import HttpResponseRedirect
from django.test import RequestFactory, SimpleTestCase, override_settings from django.test import RequestFactory, SimpleTestCase, override_settings
from mandelstudio.i18n_utils import normalize_set_language_next from mandelstudio.i18n_utils import normalize_set_language_next
@@ -35,47 +32,40 @@ class SetLanguageNormalizationTests(SimpleTestCase):
"/manage/checkout/paymentmethod/", "/manage/checkout/paymentmethod/",
) )
@patch("mandelstudio.i18n_views.django_set_language") def test_post_next_is_normalized_before_delegate(self):
def test_post_next_is_normalized_before_delegate(self, django_set_language):
django_set_language.side_effect = lambda request: HttpResponseRedirect(
request.POST["next"]
)
request = self.factory.post( request = self.factory.post(
"/i18n/setlang/", "/i18n/setlang/",
data={"language": "de", "next": "/en/manage/checkout/paymentmethod/"}, data={"language": "de", "next": "/en/manage/checkout/paymentmethod/"},
) )
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request) response = set_language_normalized(request)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(
django_set_language.call_args.args[0].POST["next"], response["Location"],
"/manage/checkout/paymentmethod/", "/de/manage/checkout/paymentmethod/",
) )
@patch("mandelstudio.i18n_views.django_set_language") def test_get_next_is_normalized_before_delegate(self):
def test_get_next_is_normalized_before_delegate(self, django_set_language):
django_set_language.side_effect = lambda request: HttpResponseRedirect(
request.GET["next"]
)
request = self.factory.get( request = self.factory.get(
"/i18n/setlang/", "/i18n/setlang/",
data={"language": "fr", "next": "/de/manage/"}, data={"language": "fr", "next": "/de/manage/"},
) )
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request) response = set_language_normalized(request)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(django_set_language.call_args.args[0].GET["next"], "/manage/") self.assertEqual(response["Location"], "/manage/")
@patch("mandelstudio.i18n_views.django_set_language") def test_set_language_view_is_csrf_exempt(self):
def test_set_language_view_is_csrf_exempt(self, django_set_language):
django_set_language.return_value = HttpResponseRedirect("/manage/")
request = self.factory.post( request = self.factory.post(
"/i18n/setlang/", "/i18n/setlang/",
data={"language": "nl", "next": "/en/manage/"}, data={"language": "nl", "next": "/en/manage/"},
) )
request.csrf_processing_done = False request.csrf_processing_done = False
request.META["HTTP_HOST"] = "testserver"
response = set_language_normalized(request) response = set_language_normalized(request)