VYPR
Medium severity5.0NVD Advisory· Published Apr 15, 2026· Updated Apr 21, 2026

CVE-2026-40256

CVE-2026-40256

Description

Weblate is a web based localization tool. In versions prior to 5.17, repository-boundary validation relies on string prefix checks on resolved absolute paths. In multiple code paths, the check uses startswith against the repository root path. This is not path-segment aware and can be bypassed when the external path shares the same string prefix as the repository path (for example, repo and repo_outside). 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
e30dbcb33ae7

fix(vcs): improved symlink validation

https://github.com/WeblateOrg/weblateMichal ČihařApr 8, 2026via ghsa
7 files changed · +156 24
  • docs/changes.rst+1 0 modified
    @@ -31,6 +31,7 @@ Weblate 5.17
     
     .. rubric:: Bug fixes
     
    +* Tightened repository boundary checks for symlink targets.
     * Matching exporters now honor component file format parameters.
     * Project token cleanup now removes stale bots on project deletion and upgrade.
     * Component file handling now validates repository symlinks.
    
  • weblate/trans/discovery.py+9 2 modified
    @@ -5,6 +5,7 @@
     
     import os
     from itertools import chain
    +from pathlib import Path
     from typing import TYPE_CHECKING, NotRequired, Required, TypedDict, cast
     
     from django.core.exceptions import ValidationError
    @@ -19,6 +20,7 @@
     from weblate.trans.tasks import create_component
     from weblate.trans.util import path_separator
     from weblate.utils.errors import report_error
    +from weblate.utils.files import is_path_within_resolved_directory
     from weblate.utils.regex import compile_regex, regex_match
     from weblate.utils.render import render_template
     
    @@ -186,14 +188,19 @@ def compile_match(self, match: str):
         def matches(self):
             """Return matched files together with match groups and mask."""
             result = []
    -        base = os.path.realpath(self.path)
    +        base = Path(self.path).resolve()
             timeout_detected = False
             for root, dirnames, filenames in os.walk(self.path, followlinks=True):
    +            dirnames[:] = [
    +                dirname
    +                for dirname in dirnames
    +                if is_path_within_resolved_directory(os.path.join(root, dirname), base)
    +            ]
                 for filename in chain(filenames, dirnames):
                     fullname = os.path.join(root, filename)
     
                     # Skip files outside our root
    -                if not os.path.realpath(fullname).startswith(base):
    +                if not is_path_within_resolved_directory(fullname, base):
                         continue
     
                     # Calculate relative path
    
  • weblate/trans/tests/test_discovery.py+48 0 modified
    @@ -3,13 +3,15 @@
     # SPDX-License-Identifier: GPL-3.0-or-later
     
     import os
    +import pathlib
     import tempfile
     from unittest.mock import patch
     
     from django.test.utils import override_settings
     
     from weblate.trans.discovery import ComponentDiscovery
     from weblate.trans.tests.test_models import RepoTestCase
    +from weblate.utils.files import remove_tree
     
     
     class ComponentDiscoveryTest(RepoTestCase):
    @@ -271,6 +273,52 @@ def test_skip_reason_rejects_symlinked_auxiliary_file(self) -> None:
     
             self.assertEqual(reason, "discovery-base.pot (base_file) does not exist.")
     
    +    def test_matches_ignore_prefix_collision_symlink_targets(self) -> None:
    +        repo_path = os.path.realpath(self.component.full_path)
    +        outside_path = f"{repo_path}_outside"
    +        os.makedirs(outside_path)
    +        self.addCleanup(remove_tree, outside_path, True)
    +
    +        pathlib.Path(os.path.join(outside_path, "cs.po")).write_text(
    +            'msgid "prefix-collision"\nmsgstr ""\n', encoding="utf-8"
    +        )
    +
    +        os.symlink(
    +            outside_path, os.path.join(self.component.full_path, "prefix-collision")
    +        )
    +
    +        self.assertNotIn("prefix-collision/cs.po", self.discovery.matched_files)
    +
    +    def test_matches_prune_prefix_collision_symlink_directories(self) -> None:
    +        repo_path = os.path.realpath(self.component.full_path)
    +        outside_path = f"{repo_path}_outside"
    +        os.makedirs(outside_path)
    +        self.addCleanup(remove_tree, outside_path, True)
    +
    +        os.symlink(
    +            outside_path, os.path.join(self.component.full_path, "prefix-collision")
    +        )
    +
    +        walk_calls: list[str] = []
    +
    +        def fake_walk(path: str, *, followlinks: bool):
    +            self.assertEqual(path, self.discovery.path)
    +            self.assertTrue(followlinks)
    +
    +            dirnames = ["prefix-collision"]
    +            walk_calls.append(path)
    +            yield path, dirnames, []
    +
    +            if "prefix-collision" in dirnames:
    +                nested = os.path.join(path, "prefix-collision")
    +                walk_calls.append(nested)
    +                yield nested, [], ["cs.po"]
    +
    +        with patch("weblate.trans.discovery.os.walk", side_effect=fake_walk):
    +            self.assertEqual(self.discovery.matches, [])
    +
    +        self.assertEqual(walk_calls, [self.discovery.path])
    +
         def test_named_group(self) -> None:
             discovery = ComponentDiscovery(
                 self.component,
    
  • weblate/utils/files.py+35 14 modified
    @@ -6,6 +6,7 @@
     import os
     import shutil
     import stat
    +from pathlib import Path
     from typing import TYPE_CHECKING
     
     from django.conf import settings
    @@ -17,7 +18,6 @@
     
     if TYPE_CHECKING:
         from collections.abc import Callable
    -    from pathlib import Path
     
         from django.core.files.base import File
     
    @@ -78,19 +78,20 @@ def remove_tree(path: str | Path, ignore_errors: bool = False) -> None:
     
     def should_skip(location):
         """Check for skipping location in manage commands."""
    -    location = os.path.abspath(location)
    -    return not location.startswith(WEBLATE_DIR) or location.startswith(
    -        (
    -            VENV_DIR,
    -            settings.DATA_DIR,
    -            DEFAULT_DATA_DIR,
    -            BUILD_DIR,
    -            DEFAULT_TEST_DIR,
    -            DOCS_DIR,
    -            SCRIPTS_DIR,
    -            CLIENT_DIR,
    -            EXAMPLES_DIR,
    -        )
    +    excluded_directories = (
    +        VENV_DIR,
    +        settings.DATA_DIR,
    +        DEFAULT_DATA_DIR,
    +        BUILD_DIR,
    +        DEFAULT_TEST_DIR,
    +        DOCS_DIR,
    +        SCRIPTS_DIR,
    +        CLIENT_DIR,
    +        EXAMPLES_DIR,
    +    )
    +    return not is_path_within_directory(location, WEBLATE_DIR) or any(
    +        is_path_within_directory(location, excluded_directory)
    +        for excluded_directory in excluded_directories
         )
     
     
    @@ -99,6 +100,26 @@ def is_excluded(path: str) -> bool:
         return any(exclude in f"/{path}/" for exclude in PATH_EXCLUDES) or ".." in path
     
     
    +def is_path_within_directory(path: str, directory: str) -> bool:
    +    """Check whether resolved path is contained within resolved directory."""
    +    try:
    +        resolved_directory = Path(directory).resolve(strict=False)
    +    except OSError:
    +        return False
    +    return is_path_within_resolved_directory(path, resolved_directory)
    +
    +
    +def is_path_within_resolved_directory(
    +    path: str | Path, resolved_directory: Path
    +) -> bool:
    +    """Check whether resolved path is contained within a resolved directory."""
    +    try:
    +        resolved_path = Path(path).resolve(strict=False)
    +    except OSError:
    +        return False
    +    return resolved_path.is_relative_to(resolved_directory)
    +
    +
     def cleanup_error_message(text: str) -> str:
         """Remove absolute paths from the text."""
         return text.replace(settings.CACHE_DIR or "NONEXISTING_CACHE", "...").replace(
    
  • weblate/utils/tests/test_files.py+39 1 modified
    @@ -3,6 +3,7 @@
     # SPDX-License-Identifier: GPL-3.0-or-later
     
     import os
    +import tempfile
     from io import BytesIO
     from pathlib import Path
     from typing import cast
    @@ -12,7 +13,12 @@
     from django.core.files.base import File
     from django.test import SimpleTestCase
     
    -from weblate.utils.files import read_file_bytes, remove_tree
    +from weblate.utils.files import (
    +    is_path_within_directory,
    +    read_file_bytes,
    +    remove_tree,
    +    should_skip,
    +)
     from weblate.utils.unittest import tempdir_setting
     
     
    @@ -61,3 +67,35 @@ def test_read_file_bytes_resets_position_to_start(self) -> None:
     
             self.assertEqual(read_file_bytes(filelike, max_size=10), b"test")
             self.assertEqual(filelike.tell(), 0)
    +
    +    def test_is_path_within_directory_accepts_descendants(self) -> None:
    +        with tempfile.TemporaryDirectory() as tempdir:
    +            repo_path = os.path.join(tempdir, "repo")
    +            nested_path = os.path.join(repo_path, "locale", "cs.po")
    +            os.makedirs(os.path.dirname(nested_path))
    +            Path(nested_path).write_text("test", encoding="utf-8")
    +
    +            self.assertTrue(is_path_within_directory(nested_path, repo_path))
    +
    +    def test_is_path_within_directory_rejects_prefix_collision(self) -> None:
    +        with tempfile.TemporaryDirectory() as tempdir:
    +            repo_path = os.path.join(tempdir, "repo")
    +            outside_path = os.path.join(tempdir, "repo_outside", "secrets.po")
    +            os.makedirs(repo_path)
    +            os.makedirs(os.path.dirname(outside_path))
    +            Path(outside_path).write_text("test", encoding="utf-8")
    +
    +            self.assertFalse(is_path_within_directory(outside_path, repo_path))
    +
    +    def test_should_skip_rejects_prefix_collision(self) -> None:
    +        with (
    +            tempfile.TemporaryDirectory() as tempdir,
    +            self.settings(
    +                DATA_DIR=os.path.join(tempdir, "data"),
    +            ),
    +        ):
    +            location = os.path.join(tempdir, "weblate-other", "locale", "django.po")
    +            os.makedirs(os.path.dirname(location))
    +            Path(location).write_text("test", encoding="utf-8")
    +
    +            self.assertTrue(should_skip(location))
    
  • weblate/vcs/base.py+9 6 modified
    @@ -25,7 +25,7 @@
     from weblate.utils.commands import get_clean_env
     from weblate.utils.data import data_path
     from weblate.utils.errors import add_breadcrumb
    -from weblate.utils.files import is_excluded
    +from weblate.utils.files import is_excluded, is_path_within_resolved_directory
     from weblate.utils.lock import WeblateLock
     from weblate.vcs.ssh import SSH_WRAPPER
     
    @@ -174,18 +174,21 @@ def create_blank_repository(cls, path: str) -> None:
         def resolve_symlinks(self, path: str) -> str:
             """Resolve any symlinks in the path."""
             # Resolve symlinks first
    -        real_path = path_separator(os.path.realpath(os.path.join(self.path, path)))
    -        repository_path = path_separator(os.path.realpath(self.path))
    +        real_path = Path(os.path.realpath(os.path.join(self.path, path)))
    +        repository_path = Path(os.path.realpath(self.path))
     
    -        if not real_path.startswith(repository_path):
    +        if not is_path_within_resolved_directory(real_path, repository_path):
                 msg = "Too many symlinks or link outside tree"
                 raise RepositorySymlinkError(msg)
     
    -        if is_excluded(real_path):
    +        if is_excluded(path_separator(os.fspath(real_path))):
                 msg = "Link to a restricted location"
                 raise RepositorySymlinkError(msg)
     
    -        return real_path[len(repository_path) :].lstrip("/")
    +        relative_path = os.path.relpath(real_path, repository_path)
    +        if relative_path == ".":
    +            return ""
    +        return path_separator(relative_path)
     
         @staticmethod
         def _getenv(
    
  • weblate/vcs/tests/test_vcs.py+15 1 modified
    @@ -23,7 +23,7 @@
     
     from weblate.trans.models import Component, Project
     from weblate.trans.tests.utils import RepoTestMixin, TempDirMixin
    -from weblate.vcs.base import RepositoryError
    +from weblate.vcs.base import RepositoryError, RepositorySymlinkError
     from weblate.vcs.git import (
         AzureDevOpsRepository,
         BitbucketCloudRepository,
    @@ -263,6 +263,20 @@ def test_cleanup(self) -> None:
             with self.repo.lock:
                 self.repo.cleanup()
     
    +    def test_resolve_symlinks_rejects_prefix_collision(self) -> None:
    +        repo_path = os.path.realpath(self.repo.path)
    +        outside_path = f"{repo_path}_outside"
    +        os.makedirs(outside_path)
    +        self.addCleanup(shutil.rmtree, outside_path, True)
    +
    +        Path(os.path.join(outside_path, "secrets.po")).write_text(
    +            "TOPSECRET\n", encoding="utf-8"
    +        )
    +        os.symlink(outside_path, os.path.join(self.repo.path, "prefix-collision"))
    +
    +        with self.assertRaises(RepositorySymlinkError):
    +            self.repo.resolve_symlinks("prefix-collision/secrets.po")
    +
         def test_merge_commit(self) -> None:
             self.test_commit()
             self.test_merge()
    

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.