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.
| Package | Affected versions | Patched versions |
|---|---|---|
weblatePyPI | < 5.17 | 5.17 |
Affected products
1Patches
1e30dbcb33ae7fix(vcs): improved symlink validation
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- github.com/WeblateOrg/weblate/commit/e30dbcb33ae78e754ecef192d54f996b89cb4e15nvdPatchWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-ffgh-3jrf-8wvhnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-ffgh-3jrf-8wvhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40256ghsaADVISORY
- github.com/WeblateOrg/weblate/pull/18847ghsaWEB
News mentions
0No linked articles in our index yet.