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.
| Package | Affected versions | Patched versions |
|---|---|---|
weblatePyPI | < 5.15.2 | 5.15.2 |
Affected products
1- Range: weblate-0.1, weblate-0.2, weblate-0.3, …
Patches
1a6eb5fd02997feat: proxy screenshot view
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- github.com/advisories/GHSA-3g2f-4rjg-9385ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-21889ghsaADVISORY
- github.com/WeblateOrg/weblate/commit/a6eb5fd0299780eca286be8ff187dc2d10feec47ghsax_refsource_MISCWEB
- github.com/WeblateOrg/weblate/pull/17516ghsax_refsource_MISCWEB
- github.com/WeblateOrg/weblate/security/advisories/GHSA-3g2f-4rjg-9385ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.