Skip to content

Latest commit

 

History

History
187 lines (131 loc) · 10.5 KB

File metadata and controls

187 lines (131 loc) · 10.5 KB

CVE-2026-41519 — Weblate API tokens survive password changes

A stolen Weblate API token (wlu_…) keeps working even after the victim changes their password. Browser sessions are correctly cycled; DRF authtoken rows are not — so password rotation is not a complete incident-response action against a token that has already leaked.

CVE CVE-2026-41519 (NVD)
GHSA GHSA-6j8j-4qp3-36p2
Severity Moderate — CVSS 4.2
Vector CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N
CWE CWE-613: Insufficient Session Expiration
Affected Weblate < 5.17.1
Fixed 5.17.1
Authentication Pre-existing token holder (the bug is about retention of access already obtained)
Reporter @whatisproblem
Fix PR WeblateOrg/weblate#19057 (merged 2026-04-17, commit 649a2da)

Summary

Weblate is a self-hosted continuous-localisation platform built on Django. Every Weblate user account is automatically issued a Django REST Framework (DRF) authtoken on creation, prefixed wlu_ for human users (wlp_ for bots). That token grants full API-level access on the user's behalf and is never sent through Django's normal session machinery.

When a user changes their password — through the in-app "change password" form or via the password-reset-by-email flow — Weblate's SetPasswordForm.save() correctly invalidates browser sessions (via cycle_session_keys()) and clears outstanding password-reset codes. It does not touch the user's API token row. The wlu_… value continues to authenticate against every Weblate API endpoint as if nothing happened.

The realistic threat model: a token leaks (committed to a public repo, captured by malware, exfiltrated by a contractor, sniffed off a misconfigured CI runner), the user notices and rotates their password, and assumes that has cut off the attacker. It hasn't.

Root cause

Pre-fix, the password-change form in weblate/accounts/forms.pySetPasswordForm.save():

@transaction.atomic
def save(self, request, delete_session=False):
    super().save(commit=True)

    # Cycle session keys so all browser sessions are invalidated
    cycle_session_keys(request, self.user)

    # Invalidate password-reset codes
    invalidate_reset_codes(self.user)

    # ← nothing here for rest_framework.authtoken.Token
    if delete_session:
        request.session.flush()

Token creation happens once, in weblate/accounts/models.pycreate_profile_callback (a post_save signal):

if created:
    instance.auth_token = Token.objects.create(
        user=instance,
        key=get_token("wlp" if instance.is_bot else "wlu"),
    )

…and the only place a user's Token row is rewritten before this fix is the manual reset_api_key view, which the user must explicitly visit and click. Password change has no link into that path.

The authtoken_token row is keyed by user but its key column is the actual bearer credential. As long as the row exists and the key is unchanged, the API client holding that key authenticates.

Why this is a real gap, not a theoretical one

DRF authtoken is a "long-lived bearer token" model: there is no expiry, no refresh, no per-request server-side validation beyond row lookup. Tokens leak through the same channels that web sessions don't:

  • CI/CD configurations — committed to .env files, GitHub Actions secrets exfiltrated by a malicious workflow, hard-coded in scripts.
  • Local tooling — saved in ~/.config/<some-tool>/credentials, rsync'd to backups, surfaced by ls -la to coworkers.
  • Browser extensions / IDE plugins that wrap the Weblate API and store tokens in plaintext config.
  • Supply-chain compromise of a Weblate-integrating package that captures the token at runtime.

In every one of those scenarios, the canonical user reaction is "I'll change my password" — which closes the browser session vector but leaves the token vector wide open. That mismatch between user mental model and actual revocation behaviour is the bug.

CVSS 4.2 (Moderate) reflects that this is a defence-in-depth failure, not an initial-compromise primitive. The attacker must already have the token. But "post-incident hygiene fails silently" is exactly the class of bug that turns a one-time compromise into persistent access.

Reproduction

Setup

git clone https://github.com/WeblateOrg/weblate
cd weblate && git checkout weblate-5.17.0          # last vulnerable release
docker compose -f docker-compose.yml up -d

Wait for the stack to come up. Create an admin via weblate createadmin if needed.

Step 1 — capture the user's API token

Sign in as a normal user. Visit /accounts/profile/#api to read the user's API token (or query the DB directly):

SELECT u.username, t.key, t.created
FROM authtoken_token t JOIN auth_user u ON u.id = t.user_id
WHERE u.username = 'victim';

Save the value, e.g. wlu_AbCdEf1234….

Step 2 — verify the token works

curl -H "Authorization: Token wlu_AbCdEf1234…" \
     http://localhost:8080/api/users/current/
# → 200 OK with the victim's user JSON

Step 3 — victim changes their password

As the victim user, navigate to Account → Password and submit a new password through the standard SetPasswordForm. (Or trigger a password-reset-by-email flow and complete it.) The success flash reads "Your password has been changed".

Step 4 — verify the token still works

curl -H "Authorization: Token wlu_AbCdEf1234…" \
     http://localhost:8080/api/users/current/
# → 200 OK — same user, same token

Browser cookies issued before the change are dead (cycle_session_keys handled that). The API token isn't.

Demonstrative scope only. A real attacker would have captured the token via the leakage vectors enumerated above; the PoC above just demonstrates persistence by manually reading the row. No data is exfiltrated beyond the user's own profile JSON.

Fix

Patched in Weblate 5.17.1 by PR #19057 (merge commit 649a2da).

The patch refactors token-lifecycle code into three reusable helpers in weblate/accounts/utils.py:

def create_api_token(user: User) -> Token:
    return Token.objects.create(
        user=user, key=get_token("wlp" if user.is_bot else "wlu")
    )

def delete_api_tokens(user: User) -> None:
    Token.objects.filter(user=user).delete()

def reset_api_token(user: User) -> Token:
    delete_api_tokens(user)
    return create_api_token(user)

…and adds a checkbox to SetPasswordForm:

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,                     # ← secure-by-default
)

The form's save() now calls reset_api_token(self.user) when the box is checked. The default-on choice is the right call: most users mentally model "change password" as "rotate everything", and the help text gives the small minority of users with long-running automation a documented opt-out instead of a silent failure.

The reset_api_key view (manual rotation) was also refactored to use the new helper, eliminating the duplicate Token.objects.filter(...).delete(); Token.objects.create(...) pattern across the codebase.

The release-notes line that ships with the fix:

Password updates now regenerate your personal API key by default.

That single sentence is what users read, and it accurately states the new behaviour without alarming them about the bug.

Timeline

  • 2026-04-17 — Fix PR (#19057) merged to main.
  • 2026-04 — Patched release 5.17.1 published.
  • 2026-04-30 — GHSA-6j8j-4qp3-36p2 published; CVE-2026-41519 assigned.

Lessons

  • "Change password" is a security action; everything that grants access from the same identity should rotate with it. Sessions, persistent API tokens, OAuth refresh tokens, "remember me" cookies, application passwords, recovery codes — if any of those survive a password change, the user's mental model is wrong about what happened.
  • DRF authtoken is fine for low-stakes APIs and a footgun for high-stakes ones. It has no expiry and no scoping. If the credential it produces is sensitive, the application has to build its own rotation hooks. Weblate now has those; many DRF-based projects don't.
  • Secure-by-default opt-out beats secure opt-in. The fix could have shipped as "tick this box if you also want to rotate your API key" — that would have left the same hole open for everyone who didn't realise the box existed. Defaulting to on with a clearly-explained opt-out is the right shape: the safe path is the no-thought path.
  • Symmetry between auth credentials matters. Browser sessions and API tokens authenticate the same identity to the same backend; treating them differently in lifecycle code creates exactly this kind of cross-channel survival bug. A useful audit pattern: grep your codebase for every site that calls cycle_session_keys() (or your framework's equivalent) and ask "what other credential of this user lives elsewhere?".

References