Skip to content

Commit e4b67a7

Browse files
committed
fix(project-backup): validate repository before restore
1 parent 0165055 commit e4b67a7

3 files changed

Lines changed: 100 additions & 1 deletion

File tree

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Weblate 5.17.1
2525
* Client-side popup notifications triggered by JavaScript now use Bootstrap toasts.
2626
* Borg backups that finish with warnings are no longer shown as failed in the management UI, and backup logs now show ``C`` entries for files that changed during the backup.
2727
* Git exporter no longer rejects shared-history fetches just because the first negotiated ``have`` revisions are newer than Weblate's local history.
28+
* Project backup import now revalidates component repository URLs before restore.
2829

2930
.. rubric:: Compatibility
3031

weblate/trans/backups.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@
5252
)
5353
from weblate.utils.data import data_path
5454
from weblate.utils.hash import checksum_to_hash, hash_to_checksum
55-
from weblate.utils.validators import validate_bitmap, validate_filename
55+
from weblate.utils.validators import (
56+
validate_bitmap,
57+
validate_filename,
58+
validate_repo_url,
59+
)
5660
from weblate.utils.version import VERSION
5761
from weblate.vcs.models import VCS_REGISTRY
5862

@@ -622,6 +626,7 @@ def load_component(
622626
with zipfile.open(filename) as handle:
623627
data = json.load(handle)
624628
validate_schema(data, "weblate-component.schema.json")
629+
self.validate_component_urls(data["component"])
625630
if skip_linked and data["component"]["repo"].startswith("weblate:"):
626631
return False
627632
if data["component"]["vcs"] not in VCS_REGISTRY:
@@ -648,6 +653,17 @@ def load_component(
648653
self.restore_component(zipfile, data, actor, changes)
649654
return True
650655

656+
@staticmethod
657+
def validate_component_urls(component: dict[str, Any]) -> None:
658+
for field in ("repo", "push"):
659+
value = component.get(field)
660+
if not value:
661+
continue
662+
try:
663+
validate_repo_url(value)
664+
except ValidationError as error:
665+
raise ValidationError({field: error.messages}) from error
666+
651667
@overload
652668
def load_components(
653669
self,

weblate/trans/tests/test_backups.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,34 @@
5252
class BackupsTest(ViewTestCase):
5353
CREATE_GLOSSARIES: bool = True
5454

55+
def write_tampered_component_backup(
56+
self, *, repo: str | None = None, push: str | None = None
57+
) -> str:
58+
backup = ProjectBackup()
59+
backup.backup_project(self.project)
60+
61+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_handle:
62+
temp_name = temp_handle.name
63+
64+
with (
65+
ZipFile(backup.filename, "r") as source_zip,
66+
ZipFile(temp_name, "w") as target_zip,
67+
):
68+
for item in source_zip.infolist():
69+
data = source_zip.read(item.filename)
70+
if item.filename.endswith(
71+
f"{self.component.slug}.json"
72+
) and item.filename.startswith("components/"):
73+
component_data = json.loads(data.decode("utf-8"))
74+
if repo is not None:
75+
component_data["component"]["repo"] = repo
76+
if push is not None:
77+
component_data["component"]["push"] = push
78+
data = json.dumps(component_data).encode("utf-8")
79+
target_zip.writestr(item, data)
80+
81+
return temp_name
82+
5583
def test_backup_creates_history_entry(self) -> None:
5684
backup = ProjectBackup()
5785

@@ -355,6 +383,60 @@ def test_restore_synthesizes_source_translation_check_flags(self) -> None:
355383

356384
self.assertEqual(restored_source.check_flags, "read-only")
357385

386+
def test_restore_rejects_invalid_repo_url(self) -> None:
387+
temp_name = self.write_tampered_component_backup(
388+
repo="https://private.example/repo.git"
389+
)
390+
391+
try:
392+
restore = ProjectBackup(temp_name)
393+
with (
394+
patch(
395+
"weblate.utils.outbound.socket.getaddrinfo",
396+
return_value=[(0, 0, 0, "", ("127.0.0.1", 443))],
397+
),
398+
self.assertRaises(ValidationError) as error,
399+
):
400+
restore.validate()
401+
402+
self.assertEqual(
403+
error.exception.message_dict,
404+
{
405+
"repo": [
406+
"This URL is prohibited because it points to an internal or non-public address."
407+
]
408+
},
409+
)
410+
finally:
411+
os.unlink(temp_name)
412+
413+
def test_restore_rejects_invalid_push_url(self) -> None:
414+
temp_name = self.write_tampered_component_backup(
415+
push="https://private.example/push.git"
416+
)
417+
418+
try:
419+
restore = ProjectBackup(temp_name)
420+
with (
421+
patch(
422+
"weblate.utils.outbound.socket.getaddrinfo",
423+
return_value=[(0, 0, 0, "", ("127.0.0.1", 443))],
424+
),
425+
self.assertRaises(ValidationError) as error,
426+
):
427+
restore.validate()
428+
429+
self.assertEqual(
430+
error.exception.message_dict,
431+
{
432+
"push": [
433+
"This URL is prohibited because it points to an internal or non-public address."
434+
]
435+
},
436+
)
437+
finally:
438+
os.unlink(temp_name)
439+
358440
def test_create_duplicate(self) -> None:
359441
def extract_names(qs) -> list[str]:
360442
return list(qs.order_by("name").values_list("name", flat=True))

0 commit comments

Comments
 (0)