VYPR
Low severity3.1NVD Advisory· Published Apr 15, 2026· Updated Apr 21, 2026

CVE-2026-33212

CVE-2026-33212

Description

Weblate is a web based localization tool. In versions prior to 5.17, the tasks API didn't verify user access for pending tasks. This could expose logs of in-progress operations to users who don't have access to given scope. The attacker needs to brute-force the random UUID of the task, so exploiting this is unlikely with the default API rate limits. This issue has been fixed in version 5.17.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
weblatePyPI
< 5.175.17

Affected products

1

Patches

1
4e06b12cd05d

fix(api): Improved API access control for pending tasks

https://github.com/WeblateOrg/weblateMichal ČihařMar 18, 2026via ghsa
5 files changed · +181 33
  • docs/changes.rst+1 0 modified
    @@ -14,6 +14,7 @@ Weblate 5.17
     .. rubric:: Bug fixes
     
     * :ref:`addon-weblate.git.squash` better handle commits applied upstream.
    +* Improved API access control for pending tasks.
     
     .. rubric:: Compatibility
     
    
  • weblate/api/tests.py+79 0 modified
    @@ -12,6 +12,7 @@
     
     import responses
     from django.conf import settings
    +from django.core.cache import cache
     from django.core.files import File
     from django.test.utils import modify_settings
     from django.urls import reverse
    @@ -47,6 +48,7 @@
         fixup_languages_seq,
         get_test_file,
     )
    +from weblate.utils.celery import get_task_metadata_key
     from weblate.utils.data import data_dir
     from weblate.utils.django_hacks import immediate_on_commit, immediate_on_commit_leave
     from weblate.utils.state import (
    @@ -4003,6 +4005,83 @@ def test_patch(self) -> None:
             self.assertEqual(Language.objects.get(code="cs").name, "New Language")
     
     
    +class TasksAPITest(APIBaseTest):
    +    task_id = "01234567-89ab-cdef-0123-456789abcdef"
    +
    +    def tearDown(self) -> None:
    +        cache.delete(get_task_metadata_key(self.task_id))
    +        super().tearDown()
    +
    +    def test_retrieve_uses_cached_component_metadata(self) -> None:
    +        cache.set(
    +            get_task_metadata_key(self.task_id),
    +            {"component_id": self.component.id, "translation_id": None},
    +            3600,
    +        )
    +
    +        class DummyAsyncResult:
    +            def __init__(self, task_id):
    +                self.id = task_id
    +                self.result = None
    +                self.state = "PENDING"
    +
    +            def ready(self):
    +                return False
    +
    +        with patch("weblate.api.views.AsyncResult", DummyAsyncResult):
    +            response = self.do_request(
    +                "api:task-detail",
    +                kwargs={"pk": self.task_id},
    +                method="get",
    +                code=200,
    +            )
    +
    +        self.assertFalse(response.data["completed"])
    +
    +    def test_retrieve_denies_inaccessible_cached_component(self) -> None:
    +        other_component = self.create_acl()
    +        cache.set(
    +            get_task_metadata_key(self.task_id),
    +            {"component_id": other_component.id, "translation_id": None},
    +            3600,
    +        )
    +
    +        class DummyAsyncResult:
    +            def __init__(self, task_id):
    +                self.id = task_id
    +                self.result = None
    +                self.state = "PENDING"
    +
    +            def ready(self):
    +                return False
    +
    +        with patch("weblate.api.views.AsyncResult", DummyAsyncResult):
    +            self.do_request(
    +                "api:task-detail",
    +                kwargs={"pk": self.task_id},
    +                method="get",
    +                code=403,
    +            )
    +
    +    def test_retrieve_requires_cached_metadata(self) -> None:
    +        class DummyAsyncResult:
    +            def __init__(self, task_id):
    +                self.id = task_id
    +                self.result = None
    +                self.state = "PENDING"
    +
    +            def ready(self):
    +                return False
    +
    +        with patch("weblate.api.views.AsyncResult", DummyAsyncResult):
    +            self.do_request(
    +                "api:task-detail",
    +                kwargs={"pk": self.task_id},
    +                method="get",
    +                code=404,
    +            )
    +
    +
     class MemoryAPITest(APIBaseTest):
         def test_get(self) -> None:
             self.do_request(
    
  • weblate/api/views.py+14 22 modified
    @@ -116,7 +116,7 @@
     from weblate.trans.tasks import category_removal, component_removal, project_removal
     from weblate.trans.views.files import download_multi
     from weblate.trans.views.reports import generate_credits
    -from weblate.utils.celery import get_task_progress
    +from weblate.utils.celery import get_task_metadata, get_task_progress
     from weblate.utils.docs import get_doc_url
     from weblate.utils.errors import report_error
     from weblate.utils.lock import WeblateLockTimeoutError
    @@ -2819,30 +2819,22 @@ def get_task(
             obj: Model
             component: Component
             task = AsyncResult(str(pk))
    -        result = task.result
    -        if task.state == "PENDING" or isinstance(result, Exception):
    -            component = None
    +        metadata = get_task_metadata(str(pk)) or {}
    +        if translation_id := metadata.get("translation_id"):
    +            obj = get_object_or_404(Translation, pk=translation_id)
    +            component = obj.component
    +        elif component_id := metadata.get("component_id"):
    +            component = obj = get_object_or_404(Component, pk=component_id)
             else:
    -            if result is None:
    -                msg = "Task not found"
    -                raise Http404(msg)
    -
    -            # Extract related object for permission check
    -            if "translation" in result:
    -                obj = get_object_or_404(Translation, pk=result["translation"])
    -                component = obj.component
    -            elif "component" in result:
    -                component = obj = get_object_or_404(Component, pk=result["component"])
    -            else:
    -                msg = "Invalid task"
    -                raise Http404(msg)
    +            msg = "Invalid task"
    +            raise Http404(msg)
     
    -            # Check access or permission
    -            if permission:
    -                if not request.user.has_perm(permission, obj):
    -                    raise PermissionDenied
    -            elif not request.user.can_access_component(component):
    +        # Check access or permission
    +        if permission:
    +            if not request.user.has_perm(permission, obj):
                     raise PermissionDenied
    +        elif not request.user.can_access_component(component):
    +            raise PermissionDenied
     
             return task, component
     
    
  • weblate/trans/models/component.py+27 10 modified
    @@ -92,7 +92,11 @@
         validate_language_code,
     )
     from weblate.utils import messages
    -from weblate.utils.celery import get_task_progress
    +from weblate.utils.celery import (
    +    delete_task_metadata,
    +    get_task_progress,
    +    store_task_metadata,
    +)
     from weblate.utils.colors import ColorChoices
     from weblate.utils.decorators import disable_for_loaddata
     from weblate.utils.errors import report_error
    @@ -1039,7 +1043,8 @@ def save(self, *args, **kwargs) -> None:
                     create=create,
                 )
             else:
    -            component_after_save.delay_on_commit(
    +            self.queue_background_task(
    +                component_after_save,
                     self.pk,
                     changed_git=changed_git,
                     changed_setup=changed_setup,
    @@ -1189,6 +1194,7 @@ def update_key(self) -> str:
             return f"component-update-{self.pk}"
     
         def delete_background_task(self) -> None:
    +        delete_task_metadata(self.background_task_id)
             cache.delete(self.update_key)
     
         def store_background_task(self, task=None) -> None:
    @@ -1197,6 +1203,12 @@ def store_background_task(self, task=None) -> None:
                     return
                 task = current_task.request
             cache.set(self.update_key, task.id, 6 * 3600)
    +        store_task_metadata(task.id, component_id=self.pk)
    +
    +    def queue_background_task(self, task, /, *args, **kwargs) -> None:
    +        transaction.on_commit(
    +            lambda: self.store_background_task(task.delay(*args, **kwargs))
    +        )
     
         @cached_property
         def background_task_id(self):
    @@ -1962,8 +1974,8 @@ def push_if_needed(self, do_update=True) -> None:
                 from weblate.trans.tasks import perform_push
     
                 self.log_info("scheduling push")
    -            perform_push.delay_on_commit(
    -                self.pk, None, force_commit=False, do_update=do_update
    +            self.queue_background_task(
    +                perform_push, self.pk, None, force_commit=False, do_update=do_update
                 )
     
         @perform_on_link
    @@ -2162,7 +2174,8 @@ def do_reset(
     
             if keep_changes:
                 # Trigger commit and scan in the background
    -            perform_commit.delay_on_commit(
    +            self.queue_background_task(
    +                perform_commit,
                     self.pk,
                     "reset-sync",
                     user_id=request.user.id if request else None,
    @@ -2223,8 +2236,11 @@ def do_file_sync(
             )
     
             if do_commit:
    -            perform_commit.delay_on_commit(
    -                self.pk, "file-sync", user_id=request.user.id if request else None
    +            self.queue_background_task(
    +                perform_commit,
    +                self.pk,
    +                "file-sync",
    +                user_id=request.user.id if request else None,
                 )
     
         @perform_on_link
    @@ -2754,7 +2770,8 @@ def create_translations(
             from weblate.trans.tasks import perform_load
     
             self.log_info("scheduling update in background")
    -        perform_load.delay_on_commit(
    +        self.queue_background_task(
    +            perform_load,
                 pk=self.pk,
                 force=force,
                 force_scan=force_scan,
    @@ -4145,8 +4162,8 @@ def get_lock_change(
                 details={"auto": auto},
             )
             if lock and not auto:
    -            perform_commit.delay_on_commit(
    -                self.pk, "lock", user_id=user.id if user else None
    +            self.queue_background_task(
    +                perform_commit, self.pk, "lock", user_id=user.id if user else None
                 )
             return change
     
    
  • weblate/utils/celery.py+60 1 modified
    @@ -15,7 +15,7 @@
     
     from celery import Celery
     from celery.contrib.django.task import DjangoTask
    -from celery.signals import after_setup_logger, task_failure
    +from celery.signals import after_setup_logger, before_task_publish, task_failure
     from django.conf import settings
     from django.core.cache import cache
     from django.core.checks import run_checks
    @@ -38,6 +38,65 @@
     # Load task modules from all registered Django app configs.
     app.autodiscover_tasks()
     
    +TASK_METADATA_TTL = 6 * 3600
    +
    +
    +def get_task_metadata_key(task_id: str) -> str:
    +    return f"task-meta-{task_id}"
    +
    +
    +def store_task_metadata(
    +    task_id: str | None,
    +    *,
    +    component_id: int | None = None,
    +    translation_id: int | None = None,
    +) -> None:
    +    if not task_id:
    +        return
    +    cache.set(
    +        get_task_metadata_key(task_id),
    +        {
    +            "component_id": component_id,
    +            "translation_id": translation_id,
    +        },
    +        TASK_METADATA_TTL,
    +    )
    +
    +
    +def get_task_metadata(task_id: str) -> dict[str, int | None] | None:
    +    return cache.get(get_task_metadata_key(task_id))
    +
    +
    +def delete_task_metadata(task_id: str | None) -> None:
    +    if not task_id:
    +        return
    +    cache.delete(get_task_metadata_key(task_id))
    +
    +
    +def extract_task_kwargs(body) -> dict[str, Any]:
    +    if isinstance(body, dict):
    +        kwargs = body.get("kwargs")
    +        return kwargs if isinstance(kwargs, dict) else {}
    +    if isinstance(body, (list, tuple)) and len(body) >= 2 and isinstance(body[1], dict):
    +        return body[1]
    +    return {}
    +
    +
    +@before_task_publish.connect
    +def store_published_task_metadata(headers=None, body=None, **kwargs) -> None:
    +    if not isinstance(headers, dict):
    +        return
    +    task_kwargs = extract_task_kwargs(body)
    +    component_id = task_kwargs.get("component_id")
    +    translation_id = task_kwargs.get("translation_id")
    +    if component_id is None and translation_id is None:
    +        return
    +    store_task_metadata(
    +        headers.get("id"),
    +        component_id=component_id,
    +        translation_id=translation_id,
    +    )
    +
     
     @task_failure.connect
     def handle_task_failure(task_id="", exception=None, **kwargs) -> None:
    

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

5

News mentions

0

No linked articles in our index yet.