Medium severity4.2NVD Advisory· Published May 7, 2026· Updated May 11, 2026
CVE-2026-41519
CVE-2026-41519
Description
Weblate is a web based localization tool. Prior to version 5.17.1, when a user changes their password, browser sessions are correctly invalidated via "cycle_session_keys()", but DRF API tokens ("wlu_*" prefix) stored in "authtoken_token" are not revoked. This issue has been patched in version 5.17.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
weblatePyPI | < 5.17.1 | 5.17.1 |
Affected products
1Patches
1649a2da81700feat(accounts): include API token reset on password change
7 files changed · +117 −19
docs/changes.rst+1 −0 modified@@ -10,6 +10,7 @@ Weblate 5.17.1 .. rubric:: Improvements * Clarified the site-wide scope of the global ``user.edit`` permission. +* Password updates now regenerate your personal API key by default. * The web installation flow for :ref:`addon-weblate.consistency.languages` now shows a preview and requires confirmation before creating missing language files across projects, categories, or site-wide scopes. * Improved :ref:`addon-weblate.discovery.discovery` guidance with guided client-side presets, clearer ``{{ component }}`` validation, and a worked discovery-template example in the docs. * Admins can now revert edits from blocked users in a project or from any user site-wide.
weblate/accounts/forms.py+14 −0 modified@@ -39,6 +39,7 @@ cycle_session_keys, get_all_user_mails, invalidate_reset_codes, + reset_api_token, ) from weblate.auth.models import Group, User from weblate.lang.models import Language @@ -723,6 +724,16 @@ class SetPasswordForm(DjangoSetPasswordForm): label=gettext_lazy("New password confirmation"), new_password=True, ) + regenerate_api_key = forms.BooleanField( + label=gettext_lazy("Regenerate API key"), + help_text=gettext_lazy( + "Leave enabled to revoke the current API key and generate a new one. " + "This is recommended if you suspect your password was compromised. " + "Disable it to keep your current API key active after changing your password." + ), + required=False, + initial=True, + ) @transaction.atomic # pylint: disable-next=arguments-renamed @@ -746,6 +757,9 @@ def save(self, request: AuthenticatedHttpRequest, delete_session=False) -> None: # Invalidate password reset codes invalidate_reset_codes(self.user) + if self.cleaned_data.get("regenerate_api_key"): + reset_api_token(self.user) + if delete_session: request.session.flush()
weblate/accounts/models.py+3 −5 modified@@ -31,7 +31,6 @@ from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp_webauthn.models import WebAuthnCredential -from rest_framework.authtoken.models import Token from social_django.models import UserSocialAuth from unidecode import unidecode @@ -60,7 +59,6 @@ GhostProjectLanguageStats, ProjectLanguageStats, ) -from weblate.utils.token import get_token from weblate.utils.validators import EMAIL_BLACKLIST, WeblateURLValidator from weblate.wladmin.models import get_support_status @@ -1345,10 +1343,10 @@ def post_login_handler( def create_profile_callback(sender, instance, created=False, **kwargs) -> None: """Automatically create token and profile for user.""" if created: + from weblate.accounts.utils import create_api_token # noqa: PLC0415 + # Create API token - instance.auth_token = Token.objects.create( - user=instance, key=get_token("wlp" if instance.is_bot else "wlu") - ) + instance.auth_token = create_api_token(instance) # Create profile instance.profile = Profile.objects.create(user=instance) # Create subscriptions
weblate/accounts/tests/test_registration.py+51 −5 modified@@ -17,6 +17,7 @@ from django.test import Client, TestCase from django.test.utils import modify_settings, override_settings from django.urls import reverse +from rest_framework.authtoken.models import Token from weblate.accounts.captcha import solve_altcha from weblate.accounts.models import VerifiedEmail @@ -105,7 +106,11 @@ def assert_registration(self, match=None, reset=False): # Set password response = self.client.post( reverse("password_reset"), - {"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"}, + { + "new_password1": "2pa$$word!", + "new_password2": "2pa$$word!", + "regenerate_api_key": "on", + }, follow=True, ) self.assertContains(response, "Your password has been changed") @@ -135,7 +140,11 @@ def perform_registration(self) -> None: # Set password response = self.client.post( reverse("password"), - {"new_password1": "1pa$$word!", "new_password2": "1pa$$word!"}, + { + "new_password1": "1pa$$word!", + "new_password2": "1pa$$word!", + "regenerate_api_key": "on", + }, ) self.assertRedirects(response, reverse("profile")) # Password change notification @@ -424,15 +433,23 @@ def test_reset_parallel(self) -> None: # Set first password response = self.client.post( reverse("password_reset"), - {"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"}, + { + "new_password1": "2pa$$word!", + "new_password2": "2pa$$word!", + "regenerate_api_key": "on", + }, follow=True, ) self.assertContains(response, "Your password has been changed") # Set second password response = client2.post( reverse("password_reset"), - {"new_password1": "3pa$$word!", "new_password2": "3pa$$word!"}, + { + "new_password1": "3pa$$word!", + "new_password2": "3pa$$word!", + "regenerate_api_key": "on", + }, follow=True, ) self.assertContains(response, "Password reset has been already completed.") @@ -797,7 +814,8 @@ def test_double_link(self) -> None: @override_settings(REGISTRATION_CAPTCHA=False) def test_reset(self) -> None: """Test for password reset.""" - User.objects.create_user("testuser", "test@example.com", "x") + user = User.objects.create_user("testuser", "test@example.com", "x") + old_token = user.auth_token.key response = self.client.get(reverse("password_reset")) self.assertContains(response, "Reset my password") @@ -807,6 +825,34 @@ def test_reset(self) -> None: self.assertContains(response, "Password reset almost complete") self.assert_registration(reset=True) + self.assertNotEqual(Token.objects.get(user=user).key, old_token) + + @override_settings(REGISTRATION_CAPTCHA=False) + def test_reset_keeps_api_key(self) -> None: + """Test for password reset without API key regeneration.""" + user = User.objects.create_user("testuser", "test@example.com", "x") + old_token = user.auth_token.key + + response = self.client.post( + reverse("password_reset"), {"email": "test@example.com"}, follow=True + ) + self.assertContains(response, "Password reset almost complete") + + response = self.client.get( + self.assert_registration_mailbox("[Weblate] Password reset on Weblate"), + follow=True, + ) + self.assertRedirects(response, reverse("password_reset")) + self.assertContains(response, "You can now set new one") + self.assertContains(response, "Regenerate API key") + + response = self.client.post( + reverse("password_reset"), + {"new_password1": "2pa$$word!", "new_password2": "2pa$$word!"}, + follow=True, + ) + self.assertContains(response, "Your password has been changed") + self.assertEqual(Token.objects.get(user=user).key, old_token) class NoCookieRegistrationTest(CookieRegistrationTest):
weblate/accounts/tests/test_views.py+26 −3 modified@@ -17,6 +17,7 @@ from django.urls import reverse from jsonschema import validate from requests.exceptions import HTTPError +from rest_framework.authtoken.models import Token from social_core.exceptions import ( AuthCanceled, AuthFailed, @@ -538,7 +539,8 @@ def test_login_ratelimit_login(self) -> None: def test_password(self) -> None: # Create user - self.get_user() + user = self.get_user() + old_token = user.auth_token.key # Login self.client.login(username="testuser", password="testpassword") # Change without data @@ -563,14 +565,35 @@ def test_password(self) -> None: "password": "testpassword", "new_password1": "1pa$$word!", "new_password2": "1pa$$word!", + "regenerate_api_key": "on", }, ) self.assertRedirects(response, f"{reverse('profile')}#account") - self.assertTrue( - User.objects.get(username="testuser").check_password("1pa$$word!") + updated_user = User.objects.get(username="testuser") + self.assertTrue(updated_user.check_password("1pa$$word!")) + self.assertNotEqual(updated_user.auth_token.key, old_token) + self.assertFalse(Token.objects.filter(key=old_token).exists()) + + def test_password_keeps_api_key(self) -> None: + user = self.get_user() + old_token = user.auth_token.key + + self.client.login(username="testuser", password="testpassword") + response = self.client.post( + reverse("password"), + { + "password": "testpassword", + "new_password1": "1pa$$word!", + "new_password2": "1pa$$word!", + }, ) + self.assertRedirects(response, f"{reverse('profile')}#account") + updated_user = User.objects.get(username="testuser") + self.assertTrue(updated_user.check_password("1pa$$word!")) + self.assertEqual(updated_user.auth_token.key, old_token) + def test_api_key(self) -> None: # Create user user = self.get_user()
weblate/accounts/utils.py+20 −1 modified@@ -21,6 +21,7 @@ from weblate.accounts.models import AuditLog, VerifiedEmail from weblate.auth.models import User from weblate.trans.signals import user_pre_delete +from weblate.utils.token import get_token if TYPE_CHECKING: from django_otp.models import Device @@ -37,6 +38,24 @@ SECOND_FACTOR_VERIFY_SECONDS = 600 +def create_api_token(user: User) -> Token: + """Create an API token for a user.""" + return Token.objects.create( + user=user, key=get_token("wlp" if user.is_bot else "wlu") + ) + + +def delete_api_tokens(user: User) -> None: + """Delete all API tokens for a user.""" + Token.objects.filter(user=user).delete() + + +def reset_api_token(user: User) -> Token: + """Reset API token for a user.""" + delete_api_tokens(user) + return create_api_token(user) + + def remove_user( user: User, request: AuthenticatedHttpRequest | None, @@ -103,7 +122,7 @@ def remove_user( profile.save() # Delete API tokens - Token.objects.filter(user=user).delete() + delete_api_tokens(user) def lock_user(
weblate/accounts/views.py+2 −5 modified@@ -69,7 +69,6 @@ CompleteCredentialAuthenticationView, ) from requests.exceptions import HTTPError -from rest_framework.authtoken.models import Token from social_core.actions import do_auth from social_core.backends.base import BaseAuth from social_core.exceptions import ( @@ -136,6 +135,7 @@ get_key_name, lock_user, remove_user, + reset_api_token, ) from weblate.auth.forms import UserEditForm from weblate.auth.models import Invitation, User, get_anonymous @@ -151,7 +151,6 @@ from weblate.utils.ratelimit import check_rate_limit, session_ratelimit_post from weblate.utils.request import get_ip_address, get_user_agent from weblate.utils.stats import prefetch_stats -from weblate.utils.token import get_token from weblate.utils.version import USER_AGENT from weblate.utils.views import get_paginator, parse_path from weblate.utils.zammad import ZammadError, submit_zammad_ticket @@ -1265,10 +1264,8 @@ def reset_password(request: AuthenticatedHttpRequest): @session_ratelimit_post("reset_api") def reset_api_key(request: AuthenticatedHttpRequest): """Reset user API key.""" - # Need to delete old token as key is primary key with transaction.atomic(): - Token.objects.filter(user=request.user).delete() - Token.objects.create(user=request.user, key=get_token("wlu")) + reset_api_token(request.user) return redirect_profile("#api")
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/WeblateOrg/weblate/commit/649a2da81700542f95c0807b3c625fc3bb0eaf95nvdPatchWEB
- github.com/WeblateOrg/weblate/pull/19057nvdIssue TrackingPatchWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-6j8j-4qp3-36p2nvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-6j8j-4qp3-36p2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41519ghsaADVISORY
- github.com/WeblateOrg/weblate/releases/tag/weblate-5.17.1nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.