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.
| Package | Affected versions | Patched versions |
|---|---|---|
weblatePyPI | < 5.17 | 5.17 |
Affected products
1Patches
14e06b12cd05dfix(api): Improved API access control for pending tasks
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- github.com/WeblateOrg/weblate/commit/4e06b12cd05d087db68384e09d5f70fe883f2b70nvdPatchWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-vj45-x3pj-f4w4nvdMitigationPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-vj45-x3pj-f4w4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33212ghsaADVISORY
- github.com/WeblateOrg/weblate/pull/18515ghsaWEB
News mentions
0No linked articles in our index yet.