#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.