High severity7.7NVD Advisory· Published Apr 15, 2026· Updated Apr 21, 2026
CVE-2026-34242
CVE-2026-34242
Description
Weblate is a web based localization tool. In versions prior to 5.17, the ZIP download feature didn't verify downloaded files, potentially following symlinks outside the repository. 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
15db3a2a2e047fix(component): tighten symlinks validation
12 files changed · +250 −40
docs/changes.rst+1 −0 modified@@ -20,6 +20,7 @@ Weblate 5.17 .. rubric:: Bug fixes +* Component file handling now validates repository symlinks. * Prevented removing the last team from a project token. * Batch automatic translation now uses project-level machinery configuration instead of only site-wide settings. * Fixed sorting by the **Unreviewed** column in listings.
weblate/addons/gettext.py+25 −8 modified@@ -8,6 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar +from django.core.exceptions import ValidationError from django.core.management.utils import find_command from django.utils.translation import gettext_lazy @@ -103,7 +104,11 @@ def can_install( return False if component is None: return True - path = cls.get_linguas_path(component) + try: + path = cls.get_linguas_path(component) + component.check_file_is_valid(path) + except ValidationError: + return False return bool(path) and os.path.exists(path) @staticmethod @@ -145,6 +150,8 @@ def update_linguas(lines: list[str], codes: set[str]) -> tuple[bool, list[str]]: return changed, lines def sync_linguas(self, component: Component, path: str) -> bool: + component.check_file_is_valid(path) + with open(path, encoding="utf-8") as handle: lines = handle.readlines() @@ -166,8 +173,12 @@ def post_add( self, translation: Translation, activity_log_id: int | None = None ) -> None: with translation.component.repository.lock: - path = self.get_linguas_path(translation.component) - if self.sync_linguas(translation.component, path): + try: + path = self.get_linguas_path(translation.component) + changed = self.sync_linguas(translation.component, path) + except ValidationError: + return + if changed: translation.addon_commit_files.append(path) def daily_component( @@ -176,8 +187,12 @@ def daily_component( activity_log_id: int | None = None, ) -> None: with component.repository.lock: - path = self.get_linguas_path(component) - if self.sync_linguas(component, path): + try: + path = self.get_linguas_path(component) + changed = self.sync_linguas(component, path) + except ValidationError: + return + if changed: self.commit_and_push(component, [path]) @@ -195,10 +210,12 @@ class UpdateConfigureAddon(GettextBaseAddon): @staticmethod def get_configure_paths(component: Component) -> Generator[str]: - base = component.full_path for name in ("configure", "configure.in", "configure.ac"): - path = os.path.join(base, name) - if os.path.exists(path): + try: + path = component.get_validated_component_filename(name) + except ValidationError: + continue + if path and os.path.exists(path): yield path @classmethod
weblate/addons/tests.py+51 −0 modified@@ -354,6 +354,46 @@ def test_update_linguas(self) -> None: self.assertIn("LINGUAS", commit) self.assertIn("\n+cs de it", commit) + def test_update_linguas_rejects_symlink(self) -> None: + translation = self.get_translation() + addon = UpdateLinguasAddon.create(component=translation.component) + + with tempfile.NamedTemporaryFile( + delete=False, mode="w", encoding="utf-8" + ) as handle: + handle.write("outside repository\n") + self.addCleanup(os.unlink, handle.name) + + linguas_path = os.path.join(self.component.full_path, "po", "LINGUAS") + os.unlink(linguas_path) + os.symlink(handle.name, linguas_path) + + self.assertFalse( + UpdateLinguasAddon.can_install(component=translation.component) + ) + + addon.post_add(translation) + self.assertEqual(translation.addon_commit_files, []) + self.assertEqual( + Path(handle.name).read_text(encoding="utf-8"), "outside repository\n" + ) + + def test_update_linguas_invalid_new_base_returns_false(self) -> None: + translation = self.get_translation() + addon = UpdateLinguasAddon.create(component=self.component) + + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b"outside repository") + self.addCleanup(os.unlink, handle.name) + + new_base_path = os.path.join(self.component.full_path, self.component.new_base) + os.unlink(new_base_path) + os.symlink(handle.name, new_base_path) + + self.assertFalse(UpdateLinguasAddon.can_install(component=self.component)) + addon.post_add(translation) + self.assertEqual(translation.addon_commit_files, []) + def assert_linguas(self, source, expected_add, expected_remove) -> None: # Test no-op self.assertEqual( @@ -401,6 +441,17 @@ def test_update_configure(self) -> None: addon.post_add(translation) self.assertEqual(translation.addon_commit_files, []) + def test_update_configure_rejects_symlink(self) -> None: + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b'ALL_LINGUAS="cs"\n') + self.addCleanup(os.unlink, handle.name) + + configure_path = os.path.join(self.component.full_path, "configure") + os.unlink(configure_path) + os.symlink(handle.name, configure_path) + + self.assertFalse(UpdateConfigureAddon.can_install(component=self.component)) + def test_generate(self) -> None: self.assertTrue(GenerateFileAddon.can_install(component=self.component)) GenerateFileAddon.create(
weblate/api/tests.py+34 −0 modified@@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import os +import tempfile import zipfile from copy import copy from datetime import UTC, datetime, timedelta @@ -3136,6 +3137,39 @@ def test_project_language_zip_contents(self) -> None: self.assertNotIn(missing_new_base_rel, zip_names) self.assertGreater(len(zip_names), 0) + def test_project_language_zip_skips_symlinked_template(self) -> None: + self.attach_component_template(self.component) + template_path = os.path.join(self.component.full_path, self.component.template) + + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b"outside repository") + self.addCleanup(os.unlink, handle.name) + + os.unlink(template_path) + os.symlink(handle.name, template_path) + + response = self.do_request( + "api:project-language-file", + {**self.project_kwargs, "language_code": "cs"}, + method="get", + code=200, + superuser=True, + request={"format": "zip"}, + ) + with zipfile.ZipFile(BytesIO(response.content)) as zf: + zip_names = set(zf.namelist()) + + root = data_dir("vcs") + translation_filename = self.component.translation_set.get( + language__code="cs" + ).get_filename() + self.assertIsNotNone(translation_filename) + translation_rel = os.path.relpath(translation_filename, root) + template_rel = os.path.relpath(template_path, root) + + self.assertIn(translation_rel, zip_names) + self.assertNotIn(template_rel, zip_names) + def test_download_project_translations_language_path_filter(self) -> None: other_component = self.create_po(name="Other", project=self.component.project) self.attach_component_template(self.component)
weblate/trans/discovery.py+5 −2 modified@@ -446,8 +446,11 @@ def get_skip_reason(self, match): name = match[param] if not name: continue - fullname = os.path.join(self.component.full_path, name) - if not os.path.exists(fullname): + try: + fullname = self.component.get_validated_component_filename(name) + except ValidationError: + fullname = None + if not fullname or not os.path.exists(fullname): return gettext("{filename} ({parameter}) does not exist.").format( filename=name, parameter=param,
weblate/trans/models/alert.py+11 −5 modified@@ -11,6 +11,7 @@ import sentry_sdk from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Count, Q from django.template.loader import render_to_string @@ -672,11 +673,16 @@ def __init__(self, instance: Alert, files: list[str]) -> None: @staticmethod def check_component(component: Component) -> bool | dict | None: - missing_files = [ - name - for name in (component.template, component.intermediate, component.new_base) - if name and not os.path.exists(os.path.join(component.full_path, name)) - ] + missing_files = [] + for name in (component.template, component.intermediate, component.new_base): + if not name: + continue + try: + fullname = component.get_validated_component_filename(name) + except ValidationError: + fullname = None + if not fullname or not os.path.exists(fullname): + missing_files.append(name) if missing_files: return {"files": missing_files} return False
weblate/trans/models/component.py+16 −16 modified@@ -3627,30 +3627,24 @@ def clean(self) -> None: def get_template_filename(self) -> str | None: """Create absolute filename for template.""" - if not self.template: - return None - filename = os.path.join(self.full_path, self.template) - # Throws an exception in case of error - self.check_file_is_valid(filename) - return filename + return self.get_validated_component_filename(self.template) def get_intermediate_filename(self) -> str | None: """Create absolute filename for intermediate.""" - if not self.intermediate: - return None - filename = os.path.join(self.full_path, self.intermediate) - # Throws an exception in case of error - self.check_file_is_valid(filename) - return filename + return self.get_validated_component_filename(self.intermediate) def get_new_base_filename(self) -> str | None: """Create absolute filename for base file for new translations.""" - if not self.new_base: + return self.get_validated_component_filename(self.new_base) + + def get_validated_component_filename(self, filename: str | None) -> str | None: + """Create a validated absolute filename for a component-managed file.""" + if not filename: return None - filename = os.path.join(self.full_path, self.new_base) + fullname = os.path.join(self.full_path, filename) # Throws an exception in case of error - self.check_file_is_valid(filename) - return filename + self.check_file_is_valid(fullname) + return fullname def create_template_if_missing(self) -> None: """Create blank template in case intermediate language is enabled.""" @@ -4161,6 +4155,12 @@ def fail_message(message: StrOrPromise) -> None: if create_translations: self.commit_pending("add language", None) + try: + self.check_file_is_valid(fullname) + except ValidationError: + fail_message(gettext("Could not add new translation file.")) + return None + # Create or get translation object translation, created = self.translation_set.get_or_create( language=language,
weblate/trans/tests/test_alert.py+18 −0 modified@@ -4,6 +4,8 @@ """Test for alerts.""" +import os +import tempfile from unittest.mock import patch from django.test import override_settings @@ -179,6 +181,22 @@ def test_duplicate_mask(self) -> None: self.assertFalse(component.alert_set.filter(name="DuplicateFilemask").exists()) + def test_inexistent_files_reject_symlinked_auxiliary_file(self) -> None: + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b"outside repository") + self.addCleanup(os.unlink, handle.name) + + self.component.new_base = "alert-base.pot" + self.component.save(update_fields=["new_base"]) + os.symlink( + handle.name, os.path.join(self.component.full_path, "alert-base.pot") + ) + + update_alerts(self.component, {"InexistantFiles"}) + + alert = self.component.alert_set.get(name="InexistantFiles") + self.assertEqual(alert.details["files"], ["alert-base.pot"]) + class LanguageAlertTest(ViewTestCase): def create_component(self):
weblate/trans/tests/test_discovery.py+22 −0 modified@@ -2,6 +2,8 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +import os +import tempfile from unittest.mock import patch from django.test.utils import override_settings @@ -249,6 +251,26 @@ def test_multi_language(self) -> None: self.assertEqual(len(deleted), 0) self.assertEqual(len(skipped), 0) + def test_skip_reason_rejects_symlinked_auxiliary_file(self) -> None: + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b"outside repository") + self.addCleanup(os.unlink, handle.name) + + linked_name = "discovery-base.pot" + linked_path = os.path.join(self.component.full_path, linked_name) + os.symlink(handle.name, linked_path) + + reason = self.discovery.get_skip_reason( + { + "mask": "discovered/*.po", + "base_file": linked_name, + "new_base": "", + "intermediate": "", + } + ) + + self.assertEqual(reason, "discovery-base.pot (base_file) does not exist.") + def test_named_group(self) -> None: discovery = ComponentDiscovery( self.component,
weblate/trans/tests/test_files.py+34 −0 modified@@ -6,7 +6,11 @@ from __future__ import annotations +import os +import tempfile from io import BytesIO +from pathlib import Path +from zipfile import ZipFile from django.contrib.messages import ERROR from django.test import SimpleTestCase @@ -20,6 +24,7 @@ from weblate.trans.models import Change, ComponentList, PendingUnitChange from weblate.trans.tests.test_views import ViewTestCase from weblate.trans.tests.utils import get_test_file +from weblate.utils.data import data_dir from weblate.utils.state import STATE_READONLY TEST_PO = get_test_file("cs.po") @@ -853,6 +858,35 @@ def test_component_xlsx(self) -> None: content = self.assert_zip(response, "test-test-cs.xlsx") load_workbook(BytesIO(content)) + def test_component_skips_symlinked_template(self) -> None: + self.component.template = "template.pot" + self.component.save(update_fields=["template"]) + + template_path = os.path.join(self.component.full_path, self.component.template) + Path(template_path).write_bytes(Path(TEST_POT).read_bytes()) + + with tempfile.NamedTemporaryFile(delete=False) as handle: + handle.write(b"outside repository") + self.addCleanup(os.unlink, handle.name) + + os.unlink(template_path) + os.symlink(handle.name, template_path) + + response = self.client.get(reverse("download", kwargs=self.kw_component)) + self.assertEqual(response.status_code, 200) + + with ZipFile(BytesIO(response.content), "r") as zipfile: + zip_names = set(zipfile.namelist()) + + root = data_dir("vcs") + translation_filename = self.get_translation().get_filename() + self.assertIsNotNone(translation_filename) + translation_rel = os.path.relpath(translation_filename, root) + template_rel = os.path.relpath(template_path, root) + + self.assertIn(translation_rel, zip_names) + self.assertNotIn(template_rel, zip_names) + EXPECTED_CSV = """location,source,target,id,fuzzy,context,translator_comments,developer_comments\r ,"Hello, world!
weblate/trans/tests/test_newlang.py+22 −0 modified@@ -4,10 +4,14 @@ """Test for adding new language.""" +import os +import tempfile + from django.core import mail from django.urls import reverse from weblate.auth.models import Permission, Role +from weblate.lang.models import Language from weblate.utils.ratelimit import reset_rate_limit from .test_views import ViewTestCase @@ -219,6 +223,24 @@ class AndroidNewLangTest(NewLangTest): def create_component(self): return self.create_android(new_lang="add") + def test_add_rejects_symlinked_target(self) -> None: + request = self.get_request() + target_dir = os.path.join(self.component.full_path, "android", "values-af") + + with tempfile.TemporaryDirectory() as tempdir: + os.symlink(tempdir, target_dir) + + self.assertIsNone( + self.component.add_new_language( + Language.objects.get(code="af"), request, show_messages=True + ) + ) + self.assertFalse(os.path.exists(os.path.join(tempdir, "strings.xml"))) + + self.assertFalse( + self.component.translation_set.filter(language__code="af").exists() + ) + class AppStoreNewLangTest(NewLangTest): expected_lang_code = "pt-BR"
weblate/trans/views/files.py+11 −9 modified@@ -6,7 +6,7 @@ import os from typing import TYPE_CHECKING -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.utils.translation import gettext @@ -89,15 +89,17 @@ def download_multi( if translation.component_id in components: continue components.add(translation.component_id) - for filename in ( - translation.component.template, - translation.component.new_base, - translation.component.intermediate, + for getter in ( + translation.component.get_template_filename, + translation.component.get_new_base_filename, + translation.component.get_intermediate_filename, ): - if filename: - fullname = os.path.join(translation.component.full_path, filename) - if os.path.exists(fullname): - filenames.add(fullname) + try: + fullname = getter() + except ValidationError: + continue + if fullname and os.path.exists(fullname): + filenames.add(fullname) return zip_download(data_dir("vcs"), sorted(filenames), name, extra=extra)
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
4- github.com/WeblateOrg/weblate/commit/5db3a2a2e047ecaab627a8731cd744a30b2f51d3nvdPatchWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-hv99-mxm5-q397nvdThird Party AdvisoryWEB
- github.com/advisories/GHSA-hv99-mxm5-q397ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34242ghsaADVISORY
News mentions
0No linked articles in our index yet.