VYPR
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.

PackageAffected versionsPatched versions
weblatePyPI
< 5.175.17

Affected products

1

Patches

1
8be80625a864

fix(validation): extend asset URL validation

https://github.com/WeblateOrg/weblateMichal ČihařMar 20, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.