VYPR
Low severityOSV Advisory· Published Jan 14, 2026· Updated Jan 14, 2026

Weblate leaks information via screenshots

CVE-2026-21889

Description

Weblate is a web based localization tool. Prior to 5.15.2, the screenshot images were served directly by the HTTP server without proper access control. This could allow an unauthenticated user to access screenshots after guessing their filename. This vulnerability is fixed in 5.15.2.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
weblatePyPI
< 5.15.25.15.2

Affected products

1

Patches

1
a6eb5fd02997

feat: proxy screenshot view

https://github.com/WeblateOrg/weblateMichal ČihařJan 6, 2026via ghsa
17 files changed · +79 67
  • docs/admin/install/docker.rst+0 5 modified
    @@ -856,11 +856,6 @@ Generic settings
     
        Configures URL prefix where Weblate is running, see :setting:`URL_PREFIX`.
     
    -.. envvar:: WEBLATE_MEDIA_URL
    -
    -   Configures URL that handles the media served from
    -   :setting:`django:MEDIA_ROOT`.
    -
     .. envvar:: WEBLATE_STATIC_URL
     
        Configures URL prefix for static files server from :setting:`CACHE_DIR`.
    
  • docs/admin/install.rst+3 2 modified
    @@ -1449,6 +1449,9 @@ For testing purposes, you can use the built-in web server in Django:
     Serving static files
     ++++++++++++++++++++
     
    +.. versionchanged:: 5.15.2
    +   :file:`/media/` is no longer used for serving screenshots.
    +
     Django needs to collect its static files in a single directory. To do so,
     execute :samp:`weblate collectstatic --noinput`. This will copy the static
     files into a directory specified by the :setting:`django:STATIC_ROOT` setting (this defaults to
    @@ -1460,8 +1463,6 @@ use that for the following paths:
     :file:`/static/`
         Serves static files for Weblate and the admin interface
         (from defined by :setting:`django:STATIC_ROOT`).
    -:file:`/media/`
    -    Used for user media uploads (e.g. screenshots).
     :file:`/favicon.ico`
         Should be rewritten to rewrite a rule to serve :file:`/static/favicon.ico`.
     
    
  • docs/changes.rst+2 0 modified
    @@ -21,6 +21,8 @@ Weblate 5.15.2
     
     .. rubric:: Compatibility
     
    +* Screenshot images are no longer served directly by the HTTP server, please adjust your HTTP server by removing serving of ``/media/``.
    +
     .. rubric:: Upgrading
     
     Please follow :ref:`generic-upgrade-instructions` in order to perform update.
    
  • weblate/examples/apache.conf+0 6 modified
    @@ -23,12 +23,6 @@
             Require all granted
         </Directory>
     
    -    # DATA_DIR/media/
    -    Alias /media/ /home/weblate/data/media/
    -    <Directory /home/weblate/data/media/>
    -        Require all granted
    -    </Directory>
    -
         # Path to your Weblate virtualenv
         WSGIDaemonProcess weblate python-home=/home/weblate/weblate-env user=weblate request-timeout=600
         WSGIProcessGroup weblate
    
  • weblate/examples/apache.gunicorn.conf+0 7 modified
    @@ -22,20 +22,13 @@
             Require all granted
         </Directory>
     
    -    # DATA_DIR/media/
    -    Alias /media/ /home/weblate/data/media/
    -    <Directory /home/weblate/data/media/>
    -        Require all granted
    -    </Directory>
    -
         SSLEngine on
         SSLCertificateFile /etc/apache2/ssl/https_cert.cert
         SSLCertificateKeyFile /etc/apache2/ssl/https_key.pem
         SSLProxyEngine On
     
         ProxyPass /favicon.ico !
         ProxyPass /static/ !
    -    ProxyPass /media/ !
     
         ProxyPass / http://localhost:8000/
         ProxyPassReverse / http://localhost:8000/
    
  • weblate/examples/apache-path.conf+0 6 modified
    @@ -23,12 +23,6 @@
             Require all granted
         </Directory>
     
    -    # DATA_DIR/media/
    -    Alias /weblate/media/ /home/weblate/data/media/
    -    <Directory /home/weblate/data/media/>
    -        Require all granted
    -    </Directory>
    -
         # Path to your Weblate virtualenv
         WSGIDaemonProcess weblate python-home=/home/weblate/weblate-env user=weblate request-timeout=600
         WSGIProcessGroup weblate
    
  • weblate/examples/weblate.nginx.conf+0 6 modified
    @@ -28,12 +28,6 @@ server {
             expires 30d;
         }
     
    -    location /media/ {
    -        # DATA_DIR/media/
    -        alias /home/weblate/data/media/;
    -        expires 30d;
    -    }
    -
         location / {
             include uwsgi_params;
             # Needed for long running operations in admin interface
    
  • weblate/examples/weblate.nginx.granian.conf+0 6 modified
    @@ -28,12 +28,6 @@ server {
             expires 30d;
         }
     
    -    location /media/ {
    -        # DATA_DIR/media/
    -        alias /home/weblate/data/media/;
    -        expires 30d;
    -    }
    -
         location / {
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
             proxy_set_header X-Forwarded-Proto $scheme;
    
  • weblate/examples/weblate.nginx.gunicorn.conf+0 6 modified
    @@ -28,12 +28,6 @@ server {
             expires 30d;
         }
     
    -    location /media/ {
    -        # DATA_DIR/media/
    -        alias /home/weblate/data/media/;
    -        expires 30d;
    -    }
    -
         location / {
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
             proxy_set_header X-Forwarded-Proto $scheme;
    
  • weblate/middleware.py+0 6 modified
    @@ -322,7 +322,6 @@ def __init__(
             self.build_csp_sentry()
             self.build_csp_piwik()
             self.build_csp_google_analytics()
    -        self.build_csp_media_url()
             self.build_csp_static_url()
             self.build_csp_cdn()
             self.build_csp_auth()
    @@ -401,11 +400,6 @@ def build_csp_google_analytics(self) -> None:
                 self.directives["script-src"].add("www.google-analytics.com")
                 self.directives["img-src"].add("www.google-analytics.com")
     
    -    def build_csp_media_url(self) -> None:
    -        # External media URL
    -        if "://" in settings.MEDIA_URL:
    -            self.add_csp_host(settings.MEDIA_URL, "img-src")
    -
         def build_csp_static_url(self) -> None:
             # External static URL
             if "://" in settings.STATIC_URL:
    
  • weblate/screenshots/models.py+3 0 modified
    @@ -96,6 +96,9 @@ def __init__(self, *args, **kwargs) -> None:
         def get_absolute_url(self) -> str:
             return reverse("screenshot", kwargs={"pk": self.pk})
     
    +    def get_view_url(self) -> str:
    +        return reverse("screenshot-view", kwargs={"pk": self.pk})
    +
         @property
         def filter_name(self) -> str:
             return f"screenshot:{Flags.format_value(self.name)}"
    
  • weblate/screenshots/tests.py+41 1 modified
    @@ -18,11 +18,12 @@
     from django.utils import timezone
     from rest_framework.test import APITestCase
     
    +from weblate.auth.models import Group
     from weblate.lang.models import Language
     from weblate.screenshots.models import Screenshot
     from weblate.screenshots.views import get_tesseract, ocr_get_strings
     from weblate.trans.actions import ActionEvents
    -from weblate.trans.models import Change
    +from weblate.trans.models import Change, Project
     from weblate.trans.tests.test_models import RepoTestCase
     from weblate.trans.tests.test_views import FixtureTestCase
     from weblate.trans.tests.utils import create_test_user, get_test_file
    @@ -111,6 +112,45 @@ def test_edit(self) -> None:
             self.assertContains(response, "Picture")
             self.assertEqual(Screenshot.objects.all()[0].name, "Picture")
     
    +    def test_view(self) -> None:
    +        self.make_manager()
    +        self.do_upload()
    +        screenshot = Screenshot.objects.all()[0]
    +        response = self.client.get(screenshot.get_view_url())
    +        # Admin can access this
    +        self.assertEqual(response.status_code, 200)
    +        self.assertEqual(response["content-type"], "image/png")
    +
    +        # Private admin access
    +        self.project.access_control = Project.ACCESS_PRIVATE
    +        self.project.save()
    +        response = self.client.get(screenshot.get_view_url())
    +        self.assertEqual(response.status_code, 200)
    +        self.assertEqual(response["content-type"], "image/png")
    +
    +        # User access
    +        self.user.groups.remove(Group.objects.get(name="Managers"))
    +        response = self.client.get(screenshot.get_view_url())
    +        self.assertEqual(response.status_code, 200)
    +        self.assertEqual(response["content-type"], "image/png")
    +
    +        # Project privileges removed
    +        self.project.remove_user(self.user)
    +        response = self.client.get(screenshot.get_view_url())
    +        self.assertEqual(response.status_code, 404)
    +
    +        # Anonymous access
    +        self.client.logout()
    +        response = self.client.get(screenshot.get_view_url())
    +        self.assertEqual(response.status_code, 404)
    +
    +        # Anonymous access to public
    +        self.project.access_control = Project.ACCESS_PUBLIC
    +        self.project.save()
    +        response = self.client.get(screenshot.get_view_url())
    +        self.assertEqual(response.status_code, 200)
    +        self.assertEqual(response["content-type"], "image/png")
    +
         def test_delete(self) -> None:
             self.make_manager()
             self.do_upload()
    
  • weblate/screenshots/views.py+21 6 modified
    @@ -11,7 +11,7 @@
     import sentry_sdk
     from django.contrib.auth.decorators import login_required
     from django.core.exceptions import PermissionDenied
    -from django.http import JsonResponse
    +from django.http import FileResponse, JsonResponse
     from django.shortcuts import get_object_or_404, redirect, render
     from django.template.loader import render_to_string
     from django.utils.translation import gettext
    @@ -33,6 +33,7 @@
     if TYPE_CHECKING:
         from collections.abc import Generator
     
    +    from django.http import HttpResponse
         from tesserocr import PyTessBaseAPI
     
         from weblate.auth.models import AuthenticatedHttpRequest
    @@ -199,7 +200,7 @@ def try_add_source(request: AuthenticatedHttpRequest, obj) -> bool:
         return True
     
     
    -class ScreenshotList(PathViewMixin, ListView):
    +class ScreenshotList(PathViewMixin, ListView):  # type: ignore[misc]
         paginate_by = 25
         model = Screenshot
         supported_path_types = (Component,)
    @@ -253,16 +254,30 @@ def post(self, request: AuthenticatedHttpRequest, **kwargs):
             return self.get(request, **kwargs)
     
     
    -class ScreenshotDetail(DetailView):
    +class ScreenshotBaseView(DetailView):
         model = Screenshot
    -    _edit_form = None
         request: AuthenticatedHttpRequest
     
         def get_object(self, *args, **kwargs):
             obj = super().get_object(*args, **kwargs)
             self.request.user.check_access_component(obj.translation.component)
             return obj
     
    +
    +class ScreenshotView(ScreenshotBaseView):
    +    def get(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> FileResponse:  # type: ignore[override]
    +        obj = self.get_object()
    +        # Django will automatically set Content-Type based on the filename
    +        return FileResponse(
    +            obj.image.open(),
    +            as_attachment=False,
    +            filename=os.path.basename(obj.image.name),
    +        )
    +
    +
    +class ScreenshotDetail(ScreenshotBaseView):
    +    _edit_form = None
    +
         def get_context_data(self, **kwargs):
             result = super().get_context_data(**kwargs)
             component = result["object"].translation.component
    @@ -276,7 +291,7 @@ def get_context_data(self, **kwargs):
             result["search_query"] = ""
             return result
     
    -    def post(self, request: AuthenticatedHttpRequest, **kwargs):
    +    def post(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> HttpResponse:
             obj = self.get_object()
             if request.user.has_perm("screenshot.edit", obj.translation):
                 self._edit_form = ScreenshotEditForm(
    @@ -293,7 +308,7 @@ def post(self, request: AuthenticatedHttpRequest, **kwargs):
                         )
                     self._edit_form.save()
                 else:
    -                return self.get(request, **kwargs)
    +                return self.get(request, *args, **kwargs)
             return redirect(obj)
     
     
    
  • weblate/settings_docker.py+0 4 modified
    @@ -177,10 +177,6 @@
     # Absolute filesystem path to the directory that will hold user-uploaded files.
     MEDIA_ROOT = os.path.join(DATA_DIR, "media")
     
    -# URL that handles the media served from MEDIA_ROOT. Make sure to use a
    -# trailing slash.
    -MEDIA_URL = get_env_str("WEBLATE_MEDIA_URL", f"{URL_PREFIX}/media/")
    -
     # Absolute path to the directory static files should be collected to.
     # Don't put anything in this directory yourself; store your static files
     # in apps' "static/" subdirectories and in STATICFILES_DIRS.
    
  • weblate/settings_example.py+0 4 modified
    @@ -161,10 +161,6 @@
     # Absolute filesystem path to the directory that will hold user-uploaded files.
     MEDIA_ROOT = os.path.join(DATA_DIR, "media")
     
    -# URL that handles the media served from MEDIA_ROOT. Make sure to use a
    -# trailing slash.
    -MEDIA_URL = f"{URL_PREFIX}/media/"
    -
     # Absolute path to the directory static files should be collected to.
     # Don't put anything in this directory yourself; store your static files
     # in apps' "static/" subdirectories and in STATICFILES_DIRS.
    
  • weblate/templates/screenshots/screenshot_show.html+4 2 modified
    @@ -1,7 +1,9 @@
    -<a href="{{ screenshot.image.url }}"
    +<a href="{{ screenshot.get_view_url }}"
        class="thumbnail"
        title="{{ screenshot.name }}"
        data-details-url="{% url 'screenshot' pk=screenshot.pk %}"
        {% if user_can_edit_screenshot %}data-can-edit{% endif %}>
    -  <img class="img-fluid" src="{{ screenshot.image.url }}" alt="{{ screenshot.name }}" />
    +  <img class="img-fluid"
    +       src="{{ screenshot.get_view_url }}"
    +       alt="{{ screenshot.name }}" />
     </a>
    
  • weblate/urls.py+5 0 modified
    @@ -453,6 +453,11 @@
             weblate.screenshots.views.ScreenshotDetail.as_view(),
             name="screenshot",
         ),
    +    path(
    +        "screenshot/<int:pk>/view/",
    +        weblate.screenshots.views.ScreenshotView.as_view(),
    +        name="screenshot-view",
    +    ),
         path(
             "screenshot/<int:pk>/delete/",
             weblate.screenshots.views.delete_screenshot,
    

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.