#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
     Reporter:  Bugy Future          |                     Type:  Bug
       Status:  new                  |                Component:
                                     |  Internationalization
      Version:  6.0                  |                 Severity:  Normal
     Keywords:                       |             Triage Stage:
  LocalePrefixPattern,               |  Unreviewed
  set_language,                      |
  get_language_from_path(),  i18n    |
    Has patch:  1                    |      Needs documentation:  0
  Needs tests:  0                    |  Patch needs improvement:  0
Easy pickings:  0                    |                    UI/UX:  1
-------------------------------------+-------------------------------------
 = 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` ===

 {{{
 #!python
 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 ===

 {{{
 #!python
 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:

 {{{
 #!html
 <form action="/i18n/setlang/" method="post">
   {% csrf_token %}
   <input type="hidden" name="next"
 value="/fr/destination/thailand/pattaya/">
   <select name="language">
     <option value="ru">Русский</option>
   </select>
 </form>
 }}}

 4. 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 ===

 {{{
 #!python
 # 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 ===

 {{{
 #!python
 # 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 ===

 {{{
 #!python
 # 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.

 {{{
 #!diff
 # 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:

 {{{
 #!python
 # 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')),
     ...
 ]
 }}}

 {{{
 #!python
 # 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`).
-- 
Ticket URL: <https://code.djangoproject.com/ticket/37086>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion visit 
https://groups.google.com/d/msgid/django-updates/0107019dfc7aaa39-1d76b104-102f-4e47-87f1-4362424b0351-000000%40eu-central-1.amazonses.com.

Reply via email to