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

PackageAffected versionsPatched versions
weblatePyPI
< 5.17.15.17.1

Affected products

1
  • cpe:2.3:a:weblate:weblate:*:*:*:*:*:*:*:*
    Range: <5.17.1

Patches

1
649a2da81700

feat(accounts): include API token reset on password change

https://github.com/WeblateOrg/weblateMichal ČihařApr 16, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.