Skip to content

Commit 41690c2

Browse files
committed
fix(component): tighten symlinks validation
Use the symlink validation in download paths and gettext add-ons.
1 parent a7fd394 commit 41690c2

File tree

12 files changed

+231
-37
lines changed

12 files changed

+231
-37
lines changed

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Weblate 5.17
2020

2121
.. rubric:: Bug fixes
2222

23+
* Component file handling now validates repository symlinks.
2324
* Prevented removing the last team from a project token.
2425
* Batch automatic translation now uses project-level machinery configuration instead of only site-wide settings.
2526
* Fixed sorting by the **Unreviewed** column in listings.

weblate/addons/gettext.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pathlib import Path
99
from typing import TYPE_CHECKING, ClassVar
1010

11+
from django.core.exceptions import ValidationError
1112
from django.core.management.utils import find_command
1213
from django.utils.translation import gettext_lazy
1314

@@ -104,6 +105,10 @@ def can_install(
104105
if component is None:
105106
return True
106107
path = cls.get_linguas_path(component)
108+
try:
109+
component.check_file_is_valid(path)
110+
except ValidationError:
111+
return False
107112
return bool(path) and os.path.exists(path)
108113

109114
@staticmethod
@@ -145,6 +150,8 @@ def update_linguas(lines: list[str], codes: set[str]) -> tuple[bool, list[str]]:
145150
return changed, lines
146151

147152
def sync_linguas(self, component: Component, path: str) -> bool:
153+
component.check_file_is_valid(path)
154+
148155
with open(path, encoding="utf-8") as handle:
149156
lines = handle.readlines()
150157

@@ -167,7 +174,11 @@ def post_add(
167174
) -> None:
168175
with translation.component.repository.lock:
169176
path = self.get_linguas_path(translation.component)
170-
if self.sync_linguas(translation.component, path):
177+
try:
178+
changed = self.sync_linguas(translation.component, path)
179+
except ValidationError:
180+
return
181+
if changed:
171182
translation.addon_commit_files.append(path)
172183

173184
def daily_component(
@@ -177,7 +188,11 @@ def daily_component(
177188
) -> None:
178189
with component.repository.lock:
179190
path = self.get_linguas_path(component)
180-
if self.sync_linguas(component, path):
191+
try:
192+
changed = self.sync_linguas(component, path)
193+
except ValidationError:
194+
return
195+
if changed:
181196
self.commit_and_push(component, [path])
182197

183198

@@ -195,10 +210,12 @@ class UpdateConfigureAddon(GettextBaseAddon):
195210

196211
@staticmethod
197212
def get_configure_paths(component: Component) -> Generator[str]:
198-
base = component.full_path
199213
for name in ("configure", "configure.in", "configure.ac"):
200-
path = os.path.join(base, name)
201-
if os.path.exists(path):
214+
try:
215+
path = component.get_validated_component_filename(name)
216+
except ValidationError:
217+
continue
218+
if path and os.path.exists(path):
202219
yield path
203220

204221
@classmethod

weblate/addons/tests.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,30 @@ def test_update_linguas(self) -> None:
354354
self.assertIn("LINGUAS", commit)
355355
self.assertIn("\n+cs de it", commit)
356356

357+
def test_update_linguas_rejects_symlink(self) -> None:
358+
translation = self.get_translation()
359+
addon = UpdateLinguasAddon.create(component=translation.component)
360+
361+
with tempfile.NamedTemporaryFile(
362+
delete=False, mode="w", encoding="utf-8"
363+
) as handle:
364+
handle.write("outside repository\n")
365+
self.addCleanup(os.unlink, handle.name)
366+
367+
linguas_path = os.path.join(self.component.full_path, "po", "LINGUAS")
368+
os.unlink(linguas_path)
369+
os.symlink(handle.name, linguas_path)
370+
371+
self.assertFalse(
372+
UpdateLinguasAddon.can_install(component=translation.component)
373+
)
374+
375+
addon.post_add(translation)
376+
self.assertEqual(translation.addon_commit_files, [])
377+
self.assertEqual(
378+
Path(handle.name).read_text(encoding="utf-8"), "outside repository\n"
379+
)
380+
357381
def assert_linguas(self, source, expected_add, expected_remove) -> None:
358382
# Test no-op
359383
self.assertEqual(
@@ -401,6 +425,17 @@ def test_update_configure(self) -> None:
401425
addon.post_add(translation)
402426
self.assertEqual(translation.addon_commit_files, [])
403427

428+
def test_update_configure_rejects_symlink(self) -> None:
429+
with tempfile.NamedTemporaryFile(delete=False) as handle:
430+
handle.write(b'ALL_LINGUAS="cs"\n')
431+
self.addCleanup(os.unlink, handle.name)
432+
433+
configure_path = os.path.join(self.component.full_path, "configure")
434+
os.unlink(configure_path)
435+
os.symlink(handle.name, configure_path)
436+
437+
self.assertFalse(UpdateConfigureAddon.can_install(component=self.component))
438+
404439
def test_generate(self) -> None:
405440
self.assertTrue(GenerateFileAddon.can_install(component=self.component))
406441
GenerateFileAddon.create(

weblate/api/tests.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

55
import os
6+
import tempfile
67
import zipfile
78
from copy import copy
89
from datetime import UTC, datetime, timedelta
@@ -3136,6 +3137,39 @@ def test_project_language_zip_contents(self) -> None:
31363137
self.assertNotIn(missing_new_base_rel, zip_names)
31373138
self.assertGreater(len(zip_names), 0)
31383139

3140+
def test_project_language_zip_skips_symlinked_new_base(self) -> None:
3141+
self.attach_component_template(self.component)
3142+
template_path = os.path.join(self.component.full_path, self.component.template)
3143+
3144+
with tempfile.NamedTemporaryFile(delete=False) as handle:
3145+
handle.write(b"outside repository")
3146+
self.addCleanup(os.unlink, handle.name)
3147+
3148+
os.unlink(template_path)
3149+
os.symlink(handle.name, template_path)
3150+
3151+
response = self.do_request(
3152+
"api:project-language-file",
3153+
{**self.project_kwargs, "language_code": "cs"},
3154+
method="get",
3155+
code=200,
3156+
superuser=True,
3157+
request={"format": "zip"},
3158+
)
3159+
with zipfile.ZipFile(BytesIO(response.content)) as zf:
3160+
zip_names = set(zf.namelist())
3161+
3162+
root = data_dir("vcs")
3163+
translation_filename = self.component.translation_set.get(
3164+
language__code="cs"
3165+
).get_filename()
3166+
self.assertIsNotNone(translation_filename)
3167+
translation_rel = os.path.relpath(translation_filename, root)
3168+
template_rel = os.path.relpath(template_path, root)
3169+
3170+
self.assertIn(translation_rel, zip_names)
3171+
self.assertNotIn(template_rel, zip_names)
3172+
31393173
def test_download_project_translations_language_path_filter(self) -> None:
31403174
other_component = self.create_po(name="Other", project=self.component.project)
31413175
self.attach_component_template(self.component)

weblate/trans/discovery.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,11 @@ def get_skip_reason(self, match):
446446
name = match[param]
447447
if not name:
448448
continue
449-
fullname = os.path.join(self.component.full_path, name)
450-
if not os.path.exists(fullname):
449+
try:
450+
fullname = self.component.get_validated_component_filename(name)
451+
except ValidationError:
452+
fullname = None
453+
if not fullname or not os.path.exists(fullname):
451454
return gettext("{filename} ({parameter}) does not exist.").format(
452455
filename=name,
453456
parameter=param,

weblate/trans/models/alert.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import sentry_sdk
1313
from django.conf import settings
14+
from django.core.exceptions import ValidationError
1415
from django.db import models
1516
from django.db.models import Count, Q
1617
from django.template.loader import render_to_string
@@ -672,11 +673,16 @@ def __init__(self, instance: Alert, files: list[str]) -> None:
672673

673674
@staticmethod
674675
def check_component(component: Component) -> bool | dict | None:
675-
missing_files = [
676-
name
677-
for name in (component.template, component.intermediate, component.new_base)
678-
if name and not os.path.exists(os.path.join(component.full_path, name))
679-
]
676+
missing_files = []
677+
for name in (component.template, component.intermediate, component.new_base):
678+
if not name:
679+
continue
680+
try:
681+
fullname = component.get_validated_component_filename(name)
682+
except ValidationError:
683+
fullname = None
684+
if not fullname or not os.path.exists(fullname):
685+
missing_files.append(name)
680686
if missing_files:
681687
return {"files": missing_files}
682688
return False

weblate/trans/models/component.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3627,30 +3627,24 @@ def clean(self) -> None:
36273627

36283628
def get_template_filename(self) -> str | None:
36293629
"""Create absolute filename for template."""
3630-
if not self.template:
3631-
return None
3632-
filename = os.path.join(self.full_path, self.template)
3633-
# Throws an exception in case of error
3634-
self.check_file_is_valid(filename)
3635-
return filename
3630+
return self.get_validated_component_filename(self.template)
36363631

36373632
def get_intermediate_filename(self) -> str | None:
36383633
"""Create absolute filename for intermediate."""
3639-
if not self.intermediate:
3640-
return None
3641-
filename = os.path.join(self.full_path, self.intermediate)
3642-
# Throws an exception in case of error
3643-
self.check_file_is_valid(filename)
3644-
return filename
3634+
return self.get_validated_component_filename(self.intermediate)
36453635

36463636
def get_new_base_filename(self) -> str | None:
36473637
"""Create absolute filename for base file for new translations."""
3648-
if not self.new_base:
3638+
return self.get_validated_component_filename(self.new_base)
3639+
3640+
def get_validated_component_filename(self, filename: str | None) -> str | None:
3641+
"""Create a validated absolute filename for a component-managed file."""
3642+
if not filename:
36493643
return None
3650-
filename = os.path.join(self.full_path, self.new_base)
3644+
fullname = os.path.join(self.full_path, filename)
36513645
# Throws an exception in case of error
3652-
self.check_file_is_valid(filename)
3653-
return filename
3646+
self.check_file_is_valid(fullname)
3647+
return fullname
36543648

36553649
def create_template_if_missing(self) -> None:
36563650
"""Create blank template in case intermediate language is enabled."""
@@ -4161,6 +4155,12 @@ def fail_message(message: StrOrPromise) -> None:
41614155
if create_translations:
41624156
self.commit_pending("add language", None)
41634157

4158+
try:
4159+
self.check_file_is_valid(fullname)
4160+
except ValidationError:
4161+
fail_message(gettext("Could not add new translation file."))
4162+
return None
4163+
41644164
# Create or get translation object
41654165
translation, created = self.translation_set.get_or_create(
41664166
language=language,

weblate/trans/tests/test_alert.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
"""Test for alerts."""
66

7+
import os
8+
import tempfile
79
from unittest.mock import patch
810

911
from django.test import override_settings
@@ -179,6 +181,22 @@ def test_duplicate_mask(self) -> None:
179181

180182
self.assertFalse(component.alert_set.filter(name="DuplicateFilemask").exists())
181183

184+
def test_inexistent_files_reject_symlinked_auxiliary_file(self) -> None:
185+
with tempfile.NamedTemporaryFile(delete=False) as handle:
186+
handle.write(b"outside repository")
187+
self.addCleanup(os.unlink, handle.name)
188+
189+
self.component.new_base = "alert-base.pot"
190+
self.component.save(update_fields=["new_base"])
191+
os.symlink(
192+
handle.name, os.path.join(self.component.full_path, "alert-base.pot")
193+
)
194+
195+
update_alerts(self.component, {"InexistantFiles"})
196+
197+
alert = self.component.alert_set.get(name="InexistantFiles")
198+
self.assertEqual(alert.details["files"], ["alert-base.pot"])
199+
182200

183201
class LanguageAlertTest(ViewTestCase):
184202
def create_component(self):

weblate/trans/tests/test_discovery.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
#
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

5+
import os
6+
import tempfile
57
from unittest.mock import patch
68

79
from django.test.utils import override_settings
@@ -249,6 +251,26 @@ def test_multi_language(self) -> None:
249251
self.assertEqual(len(deleted), 0)
250252
self.assertEqual(len(skipped), 0)
251253

254+
def test_skip_reason_rejects_symlinked_auxiliary_file(self) -> None:
255+
with tempfile.NamedTemporaryFile(delete=False) as handle:
256+
handle.write(b"outside repository")
257+
self.addCleanup(os.unlink, handle.name)
258+
259+
linked_name = "discovery-base.pot"
260+
linked_path = os.path.join(self.component.full_path, linked_name)
261+
os.symlink(handle.name, linked_path)
262+
263+
reason = self.discovery.get_skip_reason(
264+
{
265+
"mask": "discovered/*.po",
266+
"base_file": linked_name,
267+
"new_base": "",
268+
"intermediate": "",
269+
}
270+
)
271+
272+
self.assertEqual(reason, "discovery-base.pot (base_file) does not exist.")
273+
252274
def test_named_group(self) -> None:
253275
discovery = ComponentDiscovery(
254276
self.component,

weblate/trans/tests/test_files.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
from __future__ import annotations
88

9+
import os
10+
import tempfile
911
from io import BytesIO
12+
from pathlib import Path
13+
from zipfile import ZipFile
1014

1115
from django.contrib.messages import ERROR
1216
from django.test import SimpleTestCase
@@ -20,6 +24,7 @@
2024
from weblate.trans.models import Change, ComponentList, PendingUnitChange
2125
from weblate.trans.tests.test_views import ViewTestCase
2226
from weblate.trans.tests.utils import get_test_file
27+
from weblate.utils.data import data_dir
2328
from weblate.utils.state import STATE_READONLY
2429

2530
TEST_PO = get_test_file("cs.po")
@@ -853,6 +858,35 @@ def test_component_xlsx(self) -> None:
853858
content = self.assert_zip(response, "test-test-cs.xlsx")
854859
load_workbook(BytesIO(content))
855860

861+
def test_component_skips_symlinked_template(self) -> None:
862+
self.component.template = "template.pot"
863+
self.component.save(update_fields=["template"])
864+
865+
template_path = os.path.join(self.component.full_path, self.component.template)
866+
Path(template_path).write_bytes(Path(TEST_POT).read_bytes())
867+
868+
with tempfile.NamedTemporaryFile(delete=False) as handle:
869+
handle.write(b"outside repository")
870+
self.addCleanup(os.unlink, handle.name)
871+
872+
os.unlink(template_path)
873+
os.symlink(handle.name, template_path)
874+
875+
response = self.client.get(reverse("download", kwargs=self.kw_component))
876+
self.assertEqual(response.status_code, 200)
877+
878+
with ZipFile(BytesIO(response.content), "r") as zipfile:
879+
zip_names = set(zipfile.namelist())
880+
881+
root = data_dir("vcs")
882+
translation_filename = self.get_translation().get_filename()
883+
self.assertIsNotNone(translation_filename)
884+
translation_rel = os.path.relpath(translation_filename, root)
885+
template_rel = os.path.relpath(template_path, root)
886+
887+
self.assertIn(translation_rel, zip_names)
888+
self.assertNotIn(template_rel, zip_names)
889+
856890

857891
EXPECTED_CSV = """location,source,target,id,fuzzy,context,translator_comments,developer_comments\r
858892
,"Hello, world!

0 commit comments

Comments
 (0)