Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/admin/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Defaults to `[*]` which will allow all domains.
This is currently used in the following places:

* Screenshot uploads, see :ref:`screenshots`
* Remote HTML downloads for the :ref:`addon-weblate.cdn.cdnjs` add-on
Comment thread
nijel marked this conversation as resolved.

.. seealso::

Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Weblate 5.17
.. rubric:: Bug fixes

* :ref:`addon-weblate.git.squash` better handle commits applied upstream.
* :ref:`addon-weblate.cdn.cdnjs` validates parsed locations.
Comment thread
nijel marked this conversation as resolved.
* Removed unintended API endpoints for translation memory.
* Improved API access control for pending tasks.

Expand Down
3 changes: 2 additions & 1 deletion docs/devel/html.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ The translation strings have to be present in Weblate. You can either manage
these manually, use API to create them or list files or URLs using
:guilabel:`Extract strings from HTML files` and Weblate will extract them
automatically. The files have to present in the repository or contain remote
URLs which will be download and parsed regularly by Weblate.
URLs which will be download and parsed regularly by Weblate. Remote URLs are
restricted by :setting:`ALLOWED_ASSET_DOMAINS`.

The default configuration for :guilabel:`CSS selector` extracts elements with
CSS class ``l10n``, for example it would extract two strings from following
Expand Down
22 changes: 22 additions & 0 deletions weblate/addons/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from weblate.utils.render import validate_render, validate_render_translation
from weblate.utils.validators import (
DomainOrIPValidator,
validate_asset_url,
validate_filename,
validate_re,
validate_re_nonempty,
Expand Down Expand Up @@ -473,6 +474,27 @@ def clean_css_selector(self):
) from error
return self.cleaned_data["css_selector"]

def clean_files(self):
files = self.cleaned_data["files"]
errors: list[str] = []

for filename in files.splitlines():
filename = filename.strip()
if not filename:
continue
try:
if filename.startswith(("http://", "https://")):
validate_asset_url(filename)
else:
validate_filename(filename)
except forms.ValidationError as error:
errors.extend(error.messages)

if errors:
raise forms.ValidationError(errors)

return files


class TranslationLanguageChoiceField(CachedModelChoiceField):
def label_from_instance(self, obj):
Expand Down
16 changes: 11 additions & 5 deletions weblate/addons/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

from __future__ import annotations

import os
from datetime import timedelta
from pathlib import Path

from celery.schedules import crontab
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count, F, Q
from django.http import HttpRequest
Expand All @@ -26,10 +26,17 @@
from weblate.utils.hash import calculate_checksum
from weblate.utils.lock import WeblateLockTimeoutError
from weblate.utils.requests import http_request
from weblate.utils.validators import validate_asset_url, validate_filename

IGNORED_TAGS = {"script", "style"}


def read_component_file(component: Component, filename: str) -> str:
validate_filename(filename)
resolved = component.repository.resolve_symlinks(filename)
return Path(component.full_path, resolved).read_text(encoding="utf-8")


@app.task(trail=False)
def cdn_parse_html(addon_id: int, component_id: int) -> None:
try:
Expand All @@ -47,13 +54,12 @@ def cdn_parse_html(addon_id: int, component_id: int) -> None:
filename = filename.strip()
Comment thread
nijel marked this conversation as resolved.
try:
if filename.startswith(("http://", "https://")):
validate_asset_url(filename)
with http_request("get", filename) as handle:
content = handle.text
else:
content = Path(os.path.join(component.full_path, filename)).read_text(
encoding="utf-8"
)
except OSError as error:
content = read_component_file(component, filename)
except (OSError, ValidationError, ValueError) as error:
errors.append({"filename": filename, "error": str(error)})
continue

Expand Down
52 changes: 52 additions & 0 deletions weblate/addons/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,14 @@

class TestAddonMixin:
def setUp(self) -> None:
super().setUp()

Check failure on line 135 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

"setUp" undefined in superclass
ADDONS.data[NoOpAddon.name] = NoOpAddon

Check failure on line 136 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "type[NoOpAddon]", target has type "BaseAddon")
ADDONS.data[ExampleAddon.name] = ExampleAddon

Check failure on line 137 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "type[ExampleAddon]", target has type "BaseAddon")
ADDONS.data[CrashAddon.name] = CrashAddon

Check failure on line 138 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "type[CrashAddon]", target has type "BaseAddon")
ADDONS.data[ExamplePreAddon.name] = ExamplePreAddon

Check failure on line 139 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "type[ExamplePreAddon]", target has type "BaseAddon")

def tearDown(self) -> None:
super().tearDown()

Check failure on line 142 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

"tearDown" undefined in superclass
del ADDONS.data[NoOpAddon.name]
del ADDONS.data[ExampleAddon.name]
del ADDONS.data[CrashAddon.name]
Expand All @@ -153,7 +153,7 @@
def test_example(self) -> None:
self.assertTrue(ExampleAddon.can_install(component=self.component))
addon = ExampleAddon.create(component=self.component)
addon.pre_commit(None, "", True)

Check failure on line 156 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "pre_commit" of "BaseAddon" has incompatible type "None"; expected "Translation"

def test_create(self) -> None:
addon = NoOpAddon.create(component=self.component)
Expand All @@ -176,8 +176,8 @@

def test_add_form(self) -> None:
form = NoOpAddon.get_add_form(None, component=self.component, data={})
self.assertTrue(form.is_valid())

Check failure on line 179 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "BaseAddonForm | None" has no attribute "is_valid"
form.save()

Check failure on line 180 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "BaseAddonForm | None" has no attribute "save"
self.assertEqual(self.component.addon_set.count(), 1)

addon = self.component.addon_set.all()[0]
Expand All @@ -185,7 +185,7 @@

def test_add_form_project_addon(self) -> None:
form = NoOpAddon.get_add_form(None, project=self.component.project, data={})
self.assertTrue(form.is_valid())

Check failure on line 188 in weblate/addons/tests.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "BaseAddonForm | None" has no attribute "is_valid"
form.save()
self.assertEqual(self.component.project.addon_set.count(), 1)

Expand Down Expand Up @@ -1584,6 +1584,58 @@
# The error should be there
self.assertTrue(self.component.alert_set.filter(name="CDNAddonError").exists())

@tempdir_setting("LOCALIZE_CDN_PATH")
@override_settings(LOCALIZE_CDN_URL="http://localhost/")
def test_extract_refuses_outside_repository(self) -> None:
self.make_manager()
self.assertTrue(CDNJSAddon.can_install(component=self.component))
self.assertEqual(
Unit.objects.filter(translation__component=self.component).count(), 8
)

CDNJSAddon.create(
component=self.component,
configuration={
"threshold": 0,
"files": "../../../../../etc/hosts",
"cookie_name": "django_languages",
"css_selector": "*",
},
)

self.assertEqual(
Unit.objects.filter(translation__component=self.component).count(), 8
)
alert = self.component.alert_set.get(name="CDNAddonError")
self.assertIn("parent directory", alert.details["occurrences"][0]["error"])

@tempdir_setting("LOCALIZE_CDN_PATH")
@override_settings(
LOCALIZE_CDN_URL="http://localhost/", ALLOWED_ASSET_DOMAINS=[".allowed.com"]
)
def test_extract_refuses_disallowed_remote_domain(self) -> None:
self.make_manager()
self.assertTrue(CDNJSAddon.can_install(component=self.component))
self.assertEqual(
Unit.objects.filter(translation__component=self.component).count(), 8
)

CDNJSAddon.create(
component=self.component,
configuration={
"threshold": 0,
"files": "https://blocked.example.com/messages.html",
"cookie_name": "django_languages",
"css_selector": "*",
},
)

self.assertEqual(
Unit.objects.filter(translation__component=self.component).count(), 8
)
alert = self.component.alert_set.get(name="CDNAddonError")
self.assertIn("domain is not allowed", alert.details["occurrences"][0]["error"])


class SiteWideAddonsTest(ViewTestCase):
def create_component(self):
Expand Down
7 changes: 7 additions & 0 deletions weblate/utils/tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
WeblateServiceURLValidator,
WeblateURLValidator,
clean_fullname,
validate_asset_url,
validate_backup_path,
validate_filename,
validate_fullname,
Expand Down Expand Up @@ -259,6 +260,12 @@ def test_service_url_validator(self) -> None:
self.verify_validator(validator)
validator("https://domain:5000")

@override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"])
def test_asset_url_validator(self) -> None:
validate_asset_url("https://cdn.allowed.com/image.png")
with self.assertRaises(ValidationError):
validate_asset_url("https://blocked.example.com/image.png")


class BackupTest(SimpleTestCase):
def test_ssh(self) -> None:
Expand Down
9 changes: 9 additions & 0 deletions weblate/utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
validate_domain_name,
validate_ipv46_address,
)
from django.http.request import validate_host
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext, gettext_lazy
from PIL import Image
Expand Down Expand Up @@ -384,6 +385,14 @@ def __call__(self, value: str | None) -> None:
)


def validate_asset_url(value: str) -> None:
WeblateURLValidator()(value)
if not validate_host(
urlparse(value).hostname or "", settings.ALLOWED_ASSET_DOMAINS
):
raise ValidationError(gettext("URL domain is not allowed."))


class WeblateEditorURLValidator(WeblateURLValidator):
schemes: list[str] = [ # noqa: RUF012
"editor",
Expand Down
Loading