Medium severity5.0NVD Advisory· Published Apr 15, 2026· Updated Apr 21, 2026
CVE-2026-33440
CVE-2026-33440
Description
Weblate is a web based localization tool. In versions prior to 5.17, the ALLOWED_ASSET_DOMAINS setting applied only to the first issued requests and didn't restrict possible redirects. 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
18be80625a864fix(validation): extend asset URL validation
10 files changed · +292 −37
docs/admin/config.rst+5 −1 modified@@ -51,11 +51,15 @@ This enhances security by preventing loading assets from untrusted sources. Assets are downloaded once by the Weblate server and stored locally, rather than being served directly from external domains to users. +The allowlist is applied to the initial URL and to every HTTP redirect target +before Weblate follows it. Redirects to hosts outside of this allowlist are +rejected. + It expects a list of host/domain names. You can use fully qualified names (e.g ``www.example.com``) or prepend with a period as a wildcard to match all subdomains (e.g ``.example.com`` will match ``cdn.example.com`` or ``static.example.com``). -Defaults to `[*]` which will allow all domains. +Defaults to ``["*"]``, which allows all domains. **Example**
docs/admin/translating.rst+2 −1 modified@@ -145,7 +145,8 @@ management and source string association: You can upload a screenshot from a local file or provide a URL to download an image from an external source. URL-based uploads may be restricted based on the :setting:`ALLOWED_ASSET_DOMAINS` setting, which controls which domains are trusted -for downloading external assets, and :setting:`ALLOWED_ASSET_SIZE` which +for downloading external assets, including any redirects followed while fetching +the image, and :setting:`ALLOWED_ASSET_SIZE` which limits maximal size for the asset. You can add or update screenshots directly from your
docs/changes.rst+1 −0 modified@@ -16,6 +16,7 @@ Weblate 5.17 * :ref:`addon-weblate.git.squash` better handle commits applied upstream. * :ref:`addon-weblate.cdn.cdnjs` validates parsed locations. +* Asset downloads now enforce :setting:`ALLOWED_ASSET_DOMAINS` across HTTP redirects for screenshot URL uploads and remote HTML fetching in :ref:`addon-weblate.cdn.cdnjs`. * Removed unintended API endpoints for translation memory. * Improved API access control for pending tasks. * Faster category removal.
docs/devel/html.rst+1 −1 modified@@ -69,7 +69,7 @@ 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. Remote URLs are -restricted by :setting:`ALLOWED_ASSET_DOMAINS`. +restricted by :setting:`ALLOWED_ASSET_DOMAINS`, including any redirect targets. The default configuration for :guilabel:`CSS selector` extracts elements with CSS class ``l10n``, for example it would extract two strings from following
weblate/addons/tasks.py+3 −4 modified@@ -30,8 +30,8 @@ from weblate.utils.celery import app 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 +from weblate.utils.requests import asset_request +from weblate.utils.validators import validate_filename IGNORED_TAGS = {"script", "style"} @@ -59,8 +59,7 @@ def cdn_parse_html(addon_id: int, component_id: int) -> None: filename = filename.strip() try: if filename.startswith(("http://", "https://")): - validate_asset_url(filename) - with http_request("get", filename) as handle: + with asset_request("get", filename) as handle: content = handle.text else: content = read_component_file(component, filename)
weblate/addons/tests.py+45 −0 modified@@ -1721,6 +1721,51 @@ def test_extract_refuses_disallowed_remote_domain(self) -> None: alert = self.component.alert_set.get(name="CDNAddonError") self.assertIn("domain is not allowed", alert.details["occurrences"][0]["error"]) + @responses.activate + @tempdir_setting("LOCALIZE_CDN_PATH") + @override_settings( + LOCALIZE_CDN_URL="http://localhost/", ALLOWED_ASSET_DOMAINS=[".allowed.com"] + ) + def test_extract_refuses_disallowed_remote_redirect_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 + ) + + responses.add( + responses.GET, + "https://cdn.allowed.com/messages.html", + status=302, + headers={"Location": "https://blocked.example.com/messages.html"}, + ) + responses.add( + responses.GET, + "https://blocked.example.com/messages.html", + status=200, + body="<html><body><div class='l10n'>Blocked</div></body></html>", + ) + + CDNJSAddon.create( + component=self.component, + configuration={ + "threshold": 0, + "files": "https://cdn.allowed.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"]) + self.assertNotIn( + "https://blocked.example.com/messages.html", + [call.request.url for call in responses.calls], + ) + class SiteWideAddonsTest(ViewTestCase): def create_component(self):
weblate/screenshots/forms.py+17 −25 modified@@ -4,26 +4,27 @@ import io from typing import Any, cast -from urllib.parse import urlparse import requests from django import forms from django.conf import settings from django.core.files.uploadedfile import InMemoryUploadedFile from django.forms.forms import BaseForm -from django.http.request import validate_host from django.template.loader import render_to_string from django.utils.html import format_html from django.utils.translation import gettext, gettext_lazy from weblate.screenshots.models import Screenshot from weblate.trans.forms import QueryField from weblate.utils.forms import SortedSelect -from weblate.utils.requests import http_request +from weblate.utils.requests import asset_request from weblate.utils.validators import ALLOWED_IMAGES, WeblateURLValidator class ScreenshotImageValidationMixin(BaseForm): + def raise_image_url_error(self, message) -> None: + raise forms.ValidationError({"image_url": message}) + def clean_images( self, cleaned_data: dict[str, Any], edit: bool = False ) -> dict[str, Any]: @@ -47,22 +48,14 @@ def clean_images( def download_image(self, url: str) -> InMemoryUploadedFile: """Download image from the provided URL.""" - if not validate_host( - urlparse(url).hostname or "", settings.ALLOWED_ASSET_DOMAINS - ): - raise forms.ValidationError( - {"image_url": gettext("Image URL domain is not allowed.")} - ) try: - with http_request("get", url, stream=True) as response: + with asset_request("get", url, stream=True) as response: if response.status_code != 200: - raise forms.ValidationError( - { - "image_url": gettext( - "Unable to download image from the provided URL (HTTP status code: %(code)s)." - ) - % {"code": response.status_code} - } + self.raise_image_url_error( + gettext( + "Unable to download image from the provided URL (HTTP status code: %(code)s)." + ) + % {"code": response.status_code} ) content = b"" for chunk in response.iter_content( @@ -76,17 +69,16 @@ def download_image(self, url: str) -> InMemoryUploadedFile: if len(content) > settings.ALLOWED_ASSET_SIZE: break if len(content) > settings.ALLOWED_ASSET_SIZE: - raise forms.ValidationError( - {"image_url": gettext("Image is too big.")} - ) + self.raise_image_url_error(gettext("Image is too big.")) content_type = response.headers.get("Content-Type") if not content_type or content_type not in ALLOWED_IMAGES: - raise forms.ValidationError( - { - "image_url": gettext("Unsupported image type: %s") - % content_type - } + self.raise_image_url_error( + gettext("Unsupported image type: %s") % content_type ) + except forms.ValidationError as error: + if hasattr(error, "error_dict"): + raise + self.raise_image_url_error(error.messages[0]) except requests.RequestException as e: raise forms.ValidationError( {
weblate/screenshots/tests.py+52 −1 modified@@ -447,7 +447,58 @@ def test_disallowed_image_url_domain(self) -> None: response = self.do_upload( image="", image_url="https://example.com/not-allowed-image.png" ) - self.assertContains(response, "Image URL domain is not allowed.") + self.assertContains(response, "URL domain is not allowed.") + + @responses.activate + @override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"]) + def test_disallowed_image_url_redirect_domain(self) -> None: + """Reject redirects leaving the allowed asset domains.""" + self.make_manager() + responses.add( + responses.GET, + "https://images.allowed.com/redirect-image.png", + status=302, + headers={"Location": "https://proof.example.com/final-image.png"}, + ) + responses.add( + responses.GET, + "https://proof.example.com/final-image.png", + content_type="image/png", + body=Path(TEST_SCREENSHOT).read_bytes(), + ) + + response = self.do_upload( + image="", image_url="https://images.allowed.com/redirect-image.png" + ) + + self.assertContains(response, "URL domain is not allowed.") + self.assertEqual(Screenshot.objects.count(), 0) + + @responses.activate + @override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"]) + def test_allowed_image_url_redirect_domain(self) -> None: + """Allow redirects that stay within the allowed asset domains.""" + self.make_manager() + responses.add( + responses.GET, + "https://images.allowed.com/redirect-image.png", + status=302, + headers={"Location": "https://cdn.allowed.com/final-image.png"}, + ) + responses.add( + responses.GET, + "https://cdn.allowed.com/final-image.png", + content_type="image/png", + body=Path(TEST_SCREENSHOT).read_bytes(), + ) + + response = self.do_upload( + image="", image_url="https://images.allowed.com/redirect-image.png" + ) + + screenshot = Screenshot.objects.get() + self.assertContains(response, screenshot.name) + self.assertEqual(screenshot.image.size, Path(TEST_SCREENSHOT).stat().st_size) class ScreenshotVCSTest(APITestCase, RepoTestCase):
weblate/utils/requests.py+70 −4 modified@@ -4,15 +4,20 @@ from __future__ import annotations +from contextlib import contextmanager from typing import TYPE_CHECKING +from urllib.parse import urljoin import requests from django.core.cache import cache from weblate.logger import LOGGER +from weblate.utils.validators import validate_asset_url from weblate.utils.version import USER_AGENT if TYPE_CHECKING: + from collections.abc import Generator + from requests import Response @@ -26,16 +31,77 @@ def http_request( **kwargs, ) -> Response: agent = {"User-Agent": USER_AGENT} - if headers is None: - headers = agent - else: - headers.update(agent) + headers = {**headers, **agent} if headers is not None else agent response = requests.request(method, url, headers=headers, timeout=timeout, **kwargs) if raise_for_status: response.raise_for_status() return response +@contextmanager +def asset_request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + timeout: float = 5, + raise_for_status: bool = True, + max_redirects: int = 5, + **kwargs, +) -> Generator[Response, None, None]: + """Fetch an asset while validating each redirect target before following it.""" + history: list[Response] = [] + current_url = url + current_method = method + request_kwargs = kwargs.copy() + request_kwargs["allow_redirects"] = False + + agent = {"User-Agent": USER_AGENT} + request_headers = {**headers, **agent} if headers is not None else agent + + with requests.Session() as session: + for _ in range(max_redirects + 1): + validate_asset_url(current_url) + response = session.request( + current_method, + current_url, + headers=request_headers, + timeout=timeout, + **request_kwargs, + ) + response.history = history.copy() + if not response.is_redirect: + try: + if raise_for_status: + response.raise_for_status() + with response: + yield response + finally: + response.close() + return + + try: + next_url = urljoin(response.url, response.headers["location"]) + validate_asset_url(next_url) + session.cookies.update(response.cookies) + history.append(response) + finally: + response.close() + current_url = next_url + + if ( + response.status_code in {301, 302, 303} + and current_method.upper() != "HEAD" + ): + current_method = "GET" + request_kwargs.pop("data", None) + request_kwargs.pop("json", None) + request_kwargs.pop("files", None) + + msg = f"Exceeded {max_redirects} redirects." + raise requests.TooManyRedirects(msg) + + def get_uri_error(uri: str) -> str | None: """Return error for fetching the URL or None if it works.""" if uri.startswith("https://nonexisting.weblate.org/"):
weblate/utils/tests/test_requests.py+96 −0 added@@ -0,0 +1,96 @@ +# Copyright © Michal Čihař <michal@weblate.org> +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import responses +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase +from django.test.utils import override_settings + +from weblate.utils.requests import asset_request + + +class AssetRequestTest(SimpleTestCase): + @responses.activate + @override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"]) + def test_asset_request_follows_allowed_redirect(self) -> None: + responses.add( + responses.GET, + "https://images.allowed.com/redirect-image.png", + status=302, + headers={"Location": "https://cdn.allowed.com/final-image.png"}, + ) + responses.add( + responses.GET, + "https://cdn.allowed.com/final-image.png", + status=200, + body=b"image-data", + ) + + with asset_request( + "get", "https://images.allowed.com/redirect-image.png" + ) as response: + self.assertEqual(response.content, b"image-data") + + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + responses.calls[1].request.url, + "https://cdn.allowed.com/final-image.png", + ) + + @responses.activate + @override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"]) + def test_asset_request_blocks_disallowed_redirect(self) -> None: + responses.add( + responses.GET, + "https://images.allowed.com/redirect-image.png", + status=302, + headers={"Location": "https://proof.example.com/final-image.png"}, + ) + responses.add( + responses.GET, + "https://proof.example.com/final-image.png", + status=200, + body=b"should-not-be-fetched", + ) + + with ( + self.assertRaises(ValidationError), + asset_request("get", "https://images.allowed.com/redirect-image.png"), + ): + pass + + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + responses.calls[0].request.url, + "https://images.allowed.com/redirect-image.png", + ) + + @responses.activate + @override_settings(ALLOWED_ASSET_DOMAINS=[".allowed.com"]) + def test_asset_request_preserves_redirect_cookies(self) -> None: + responses.add( + responses.GET, + "https://images.allowed.com/redirect-image.png", + status=302, + headers={ + "Location": "https://cdn.allowed.com/final-image.png", + "Set-Cookie": "asset-token=allowed; Domain=.allowed.com; Path=/", + }, + ) + responses.add( + responses.GET, + "https://cdn.allowed.com/final-image.png", + status=200, + body=b"image-data", + ) + + with asset_request( + "get", "https://images.allowed.com/redirect-image.png" + ) as response: + self.assertEqual(response.content, b"image-data") + + self.assertEqual( + responses.calls[1].request.headers["Cookie"], + "asset-token=allowed", + )
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/8be80625a864c8db5854503872a65e8a0b7399a6nvdPatchWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-5fhx-9jwj-867mnvdThird Party AdvisoryWEB
- github.com/advisories/GHSA-5fhx-9jwj-867mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33440ghsaADVISORY
- github.com/WeblateOrg/weblate/pull/18550ghsaWEB
News mentions
0No linked articles in our index yet.