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

PackageAffected versionsPatched versions
weblatePyPI
< 5.175.17

Affected products

1

Patches

1
5db3a2a2e047

fix(component): tighten symlinks validation

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

News mentions

0

No linked articles in our index yet.