Opened 4 weeks ago

Closed 4 weeks ago

Last modified 4 weeks ago

#37086 closed Bug (duplicate)

`set_language` silently fails when `next` URL prefix differs from active language cookie

Reported by: Bugy Future Owned by: Jason Judkins
Component: Internationalization Version: 6.0
Severity: Normal Keywords: LocalePrefixPattern, set_language, get_language_from_path(), i18n
Cc: Bugy Future, Jason Judkins Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: yes Patch needs improvement: no
Easy pickings: no UI/UX: yes

Description

Bug Report: set_language silently fails when next URL prefix differs from active language cookie

Ticket tracker: https://code.djangoproject.com/newticket
Component: Internationalization (django.views.i18n, django.urls)
Severity: Medium — silent data loss (language switch is a no-op for the user)


Summary

django.views.i18n.set_language silently fails to translate the redirect URL
whenever the language prefix already present in the next parameter differs
from the language currently active in get_language() (i.e. the cookie /
session value). The user is redirected back to the same URL in the old
language, and the language switch appears broken — even though the cookie is
set correctly.


Environment

Django 6.0.1 (reproduced on 4.2 LTS and 5.x as well — see Notes)
Python 3.13.12
Middleware django.middleware.locale.LocaleMiddleware (standard stack)
Setting prefix_default_language = False in i18n_patterns()

Steps to Reproduce

Minimal urls.py

from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include

urlpatterns = [
    path('i18n/', include('django.conf.urls.i18n')),
]

urlpatterns += i18n_patterns(
    path('destination/<slug:country>/<slug:city>/', some_view, name='city_detail'),
    prefix_default_language=False,
)

Settings

LANGUAGE_CODE = 'en'
LANGUAGES = [
    ('en', 'English'),
    ('fr', 'Français'),
    ('ru', 'Русский'),
    ('zh-hans', '简体中文'),
]
MIDDLEWARE = [
    ...
    'django.middleware.locale.LocaleMiddleware',
    ...
]

Reproduce

  1. Set the language cookie to zh-hans (visit /zh-hans/ or POST to setlang/).
  2. Manually navigate to /fr/destination/thailand/pattaya/ (the URL now carries the fr prefix, but the cookie still says zh-hans).
  3. Use the language-switcher form on the page to switch to Russian:
{% csrf_token %}
  1. Submit.

Expected result

Redirect → /ru/destination/thailand/pattaya/ with django_language=ru cookie.

Actual result

Redirect → /fr/destination/thailand/pattaya/ (unchanged URL) with
django_language=ru cookie.

The cookie is updated correctly, but the page URL is not translated.
On the next full page load the user lands on /fr/destination/... with a ru
cookie, which LocaleMiddleware then 302-redirects to /ru/destination/....
The net effect is one extra round-trip and a confusing flicker, but the root
cause is a silent no-op in translate_url.


Root Cause Analysis

The failure chain involves three components.

1. LocalePrefixPattern.language_prefix reads get_language() at call time

# django/urls/resolvers.py : 398
@property
def language_prefix(self):
    language_code = get_language() or settings.LANGUAGE_CODE
    if language_code == settings.LANGUAGE_CODE and not self.prefix_default_language:
        return ""
    else:
        return "%s/" % language_code

The prefix is not derived from the URL being resolved — it is derived from
whatever get_language() returns at the moment resolve() is called.

2. LocalePrefixPattern.match uses that prefix to strip the URL

# django/urls/resolvers.py : 406
def match(self, path):
    language_prefix = self.language_prefix   # e.g. 'zh-hans/'
    if path.startswith(language_prefix):     # '/fr/...' does NOT start with 'zh-hans/'
        return path.removeprefix(language_prefix), (), {}
    return None                              # → resolve() raises Resolver404

3. translate_url calls resolve() without aligning get_language() to the URL's actual prefix

# django/urls/base.py : 181
def translate_url(url, lang_code):
    parsed = urlsplit(url)
    try:
        match = resolve(unquote(parsed.path))   # ← get_language() still = 'zh-hans'
    except Resolver404:
        pass                                    # ← silently swallowed; url returned unchanged
    else:
        ...
        with override(lang_code):               # ← override only happens for reverse(),
            url = reverse(...)                  #   never reached if resolve() failed
    return url                                  # ← returns original url unmodified

Call graph of the failing case

set_language(POST next='/fr/destination/thailand/pattaya/', language='ru')
│
└─ translate_url('/fr/destination/thailand/pattaya/', 'ru')
     │
     └─ resolve('/fr/destination/thailand/pattaya/')
          │
          └─ LocalePrefixPattern.match('/fr/destination/thailand/pattaya/')
               │
               ├─ language_prefix = get_language() → 'zh-hans'   (stale cookie)
               ├─ '/fr/...'.startswith('zh-hans/') → False
               └─ return None  →  Resolver404
          ↑
          silently caught by translate_url; original URL returned unchanged

Why manual address-bar navigation works

When the user types /fr/destination/... directly, the browser makes a GET
request. LocaleMiddleware.process_request calls get_language_from_request,
which checks the URL prefix first (before the cookie), activates fr, and
updates the cookie. On the subsequent page load the cookie and URL prefix are
consistent. The problem only surfaces when a POST to setlang/ is processed
while the cookie and URL prefix are already out of sync.


Proposed Fix

The fix requires a single additional translation.override() call to align
get_language() with the language actually encoded in next_url before
translate_url calls resolve().

get_language_from_path() already exists in Django's public API for exactly
this purpose — extracting the language from a URL path — and is the same
function used by LocaleMiddleware itself.

translate_url already wraps its internal reverse() call in
override(lang_code), so no changes are needed for the target-language phase.

# django/views/i18n.py  —  proposed patch

 from django.utils.http import url_has_allowed_host_and_scheme
+from django.utils.translation import check_for_language, get_language_from_path
+from django.utils import translation
+from urllib.parse import urlsplit

 def set_language(request):
     next_url = request.POST.get("next", request.GET.get("next"))
     if (
         next_url or request.accepts("text/html")
     ) and not url_has_allowed_host_and_scheme(...):
         ...

     response = HttpResponseRedirect(next_url) if next_url else HttpResponse(status=204)

     if request.method == "POST":
         lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
         if lang_code and check_for_language(lang_code):
             if next_url:
-                next_trans = translate_url(next_url, lang_code)
+                # Fix: detect the language prefix already present in next_url
+                # so that LocalePrefixPattern.match() can resolve() it correctly,
+                # regardless of what get_language() currently returns from the cookie.
+                path = urlsplit(next_url).path
+                source_lang = get_language_from_path(path) or settings.LANGUAGE_CODE
+                with translation.override(source_lang):
+                    next_trans = translate_url(next_url, lang_code)
+
                 if next_trans != next_url:
                     response = HttpResponseRedirect(next_trans)
             response.set_cookie(...)
     return response

Why override(source_lang), not override(target_lang)

Using override(target_lang) (the language being switched to) would fix the
case where next_url is a bare, prefix-less path — but would still fail for
any URL that carries a different existing prefix:

next_url = '/fr/destination/paris/'   target = 'ru'

override('ru'):
  language_prefix = 'ru/'
  '/fr/...'.startswith('ru/') → False → Resolver404  ✗

override('fr'):   ← source_lang from get_language_from_path()
  language_prefix = 'fr/'
  '/fr/...'.startswith('fr/') → True → resolve() OK
  then translate_url internally does override('ru') for reverse()  ✓

Correctness across all cases

next_url cookie source_lang result
/fr/destination/paris/ zh-hans fr /ru/destination/paris/
/ru/destination/paris/ en ru /fr/destination/paris/
/zh-hans/destination/paris/ fr zh-hans /destination/paris/
/destination/paris/ zh-hans en /ru/destination/paris/
/ any en /ru/

Workaround (for projects that cannot wait for a patch)

Override the set_language URL before Django's own i18n/ include and point
it at a custom view that applies the fix:

# urls.py
from myapp.views import set_language_fixed

urlpatterns = [
    path('i18n/setlang/', set_language_fixed, name='set_language'),
    path('i18n/', include('django.conf.urls.i18n')),
    ...
]
# myapp/views.py
from urllib.parse import urlsplit
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import translate_url
from django.utils import translation
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import check_for_language, get_language_from_path
from django.views.i18n import LANGUAGE_QUERY_PARAMETER


def set_language_fixed(request):
    next_url = request.POST.get('next', request.GET.get('next'))
    if (
        next_url or request.accepts('text/html')
    ) 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 url_has_allowed_host_and_scheme(
            url=next_url,
            allowed_hosts={request.get_host()},
            require_https=request.is_secure(),
        ):
            next_url = '/'

    response = HttpResponseRedirect(next_url) if next_url else HttpResponse(status=204)

    if request.method == 'POST':
        lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
        if lang_code and check_for_language(lang_code):
            if next_url:
                path = urlsplit(next_url).path
                source_lang = get_language_from_path(path) or settings.LANGUAGE_CODE
                with translation.override(source_lang):
                    next_trans = translate_url(next_url, lang_code)
                if next_trans != next_url:
                    response = HttpResponseRedirect(next_trans)
            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,
            )

    return response

Notes

  • The bug is present in all Django versions that use LocalePrefixPattern (introduced in Django 2.0). Verified on 4.2 LTS, 5.2, and 6.0.1.
  • The condition that triggers the bug — cookie language ≠ URL prefix language — is common in any multilingual site where users mix manual URL editing with the language switcher UI, or follow external links to a page in a different language than their last visited language.
  • The Resolver404 exception raised inside translate_url is intentionally caught and swallowed (the function contract is "return original URL on failure"). This makes the failure mode silent: no exception reaches the caller, no log entry is produced, the cookie is set, the redirect is issued — but to the wrong URL. This compounding of silent failure makes the bug particularly hard to diagnose in production.
  • The fix is backwards-compatible and adds no new public API surface. get_language_from_path() is already part of Django's public i18n API.
  • A test covering the "cookie language ≠ URL prefix language" scenario does not currently exist in Django's test suite (tests/i18n/test_extraction.py, tests/view_tests/tests/test_i18n.py).

Attachments (2)

Screenshot from 2026-05-06 15-41-37.png (111.7 KB ) - added by Jason Judkins 4 weeks ago.
Screenshot from 2026-05-06 15-40-56.png (115.7 KB ) - added by Jason Judkins 4 weeks ago.

Download all attachments as: .zip

Change History (5)

comment:1 by Jason Judkins, 4 weeks ago

Cc: Jason Judkins added
Needs tests: set
Owner: set to Jason Judkins
Status: newassigned
Triage Stage: UnreviewedAccepted

Reproduced on Django 6.05 / Python 3.12 with the reporter's minimal setup.

Confirmed translate_url returns the original URL unchanged because LocalePrefixPattern.match rejects /fr/... while get_language() returns the cookie value zh-hans.

Worth discussing whether the fix belongs in translate_url itself rather than set_language — the function's contract suggests it should derive the source language from the URL it's translating rather than relying on caller-set context."

Version 0, edited 4 weeks ago by Jason Judkins (next)

by Jason Judkins, 4 weeks ago

by Jason Judkins, 4 weeks ago

comment:2 by Jason Judkins, 4 weeks ago

Last edited 4 weeks ago by Jason Judkins (previous) (diff)

comment:3 by JaeHyuckSa, 4 weeks ago

Resolution: duplicate
Status: assignedclosed

Looking at this again, #35034 looks very close to this issue.

When the language cookie and URL prefix are out of sync, translate_url() tries to resolve the URL using the request's current language. That fails, and set_language() ends up redirecting back to the original URL.

Since #35034 was already closed as a duplicate of #28567, I think this should probably be closed the same way. #37086 has a clearer repro, and 21240 is already open, but it looks like the same root cause. If this is closed as a duplicate, 21240 should probably reference #28567 instead.

Last edited 4 weeks ago by JaeHyuckSa (previous) (diff)
Note: See TracTickets for help on using tickets.
Back to Top