Feb 19, 2026·5 min read·3 visits
Weblate versions < 5.15.2 served screenshot uploads directly via the web server (Nginx/Apache), bypassing Django's permission checks. Unauthenticated users could view private project screenshots if they could guess the URL. Fixed by routing media requests through a protected Django view.
Weblate, the popular open-source localization tool, suffered from a classic architectural disconnect between application logic and web server configuration. By defaulting to serving the `/media/` directory directly via Nginx or Apache, Weblate inadvertently bypassed its own authentication middleware for uploaded screenshots. This allowed unauthenticated attackers to access sensitive visual context—potentially revealing internal dashboards, PII, or unreleased features—simply by knowing or guessing the file path.
Localization is hard. To make it easier, Weblate allows developers to upload screenshots of their application so translators can see exactly where a string like "Submit" appears—is it a button? A modal? A command to launch nuclear missiles?
This context is vital. However, in the world of software development, context is also sensitive. These screenshots often depict unreleased features, internal admin panels, or user interfaces populated with real (and hopefully test) data. You would assume that if a project is marked "Private" in Weblate, the screenshots attached to it are also private.
Well, prior to version 5.15.2, that assumption was dead wrong. It turns out that while Weblate locked the front door (the project dashboard), it left the side window (the media directory) wide open. This vulnerability isn't a complex buffer overflow; it's a fundamental misunderstanding of who is responsible for guarding the assets.
The root cause here is a classic "DevOps vs. Dev" conflict. In the Django world (which Weblate is built on), serving static files and user uploads through the Python application is considered slow and inefficient. The standard advice is to let a high-performance web server like Nginx or Apache handle the /media/ directory directly.
Weblate's official documentation and example configurations followed this advice to a fault. They provided Nginx configs that looked like this:
location /media/ {
alias /home/weblate/data/media/;
expires 30d;
}Do you see the problem? When a request hits https://weblate.company.com/media/screenshots/secret_admin_panel.png, Nginx looks at the location block, sees it matches /media/, and happily serves the file from the disk. Nginx does not know what a "Django Session" is. It doesn't care about your Access Control Lists (ACLs) or whether User ID 5 has permission to view Project X. It just serves bytes.
By bypassing the application layer entirely, Weblate inadvertently created a massive IDOR (Insecure Direct Object Reference) scenario, or more accurately, a lack of access control on static assets. The application code checked permissions if you asked the application, but nobody was asking the application.
The fix required a two-pronged approach: changing the code to handle file serving securely, and screaming at administrators to update their web server configs. The code change was introduced in commit a6eb5fd0299780eca286be8ff187dc2d10feec47.
The developers introduced a new view specifically for serving these screenshots. Instead of a direct link to a file, the application now routes the request through a ScreenshotView class.
Here is the logic that was missing:
# Inside weblate/trans/views/files.py
class ScreenshotView(DetailView):
# ... setup code ...
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
# The Critical Fix:
# Verify the user has access to the component this screenshot belongs to.
self.request.user.check_access_component(obj.translation.component)
return objPreviously, this logic didn't exist because the request never reached Python. Now, the view explicitly checks check_access_component. If the user doesn't have rights to the component, they get a 403 Forbidden, not a PNG of your CEO's password hint.
Exploiting this is trivially easy if you know the file paths, and moderately annoying if you don't. Since directory listing is usually disabled in Nginx/Apache by default (hopefully), an attacker can't just browse the directory tree like an FTP server.
However, filenames in Weblate might be predictable or leaked via other channels. For example:
/screenshots/1001.png, /screenshots/1002.png), it's game over. A simple curl loop will scrape the entire database.Once the attacker has the URL, they simply paste it into their browser. No cookies, no login, no exploit payloads needed. It's the digital equivalent of walking into a bank vault because the door was propped open with a chair.
Fixing CVE-2026-21889 is unique because pip install --upgrade weblate is not enough. If you upgrade the application code but leave your Nginx config pointing /media/ to the filesystem, you are still vulnerable.
Step 1: The Upgrade
Update Weblate to version 5.15.2 or later. This installs the new ScreenshotView logic capable of verifying permissions.
Step 2: The Config Purge
You must edit your web server configuration (Nginx, Apache, etc.). You need to remove any alias or root directive that serves the /media/ directory directly.
For Nginx, delete this block:
# DELETE THIS BLOCK
location /media/ {
alias /var/lib/weblate/media/;
}Instead, requests to /media/ should fall through to the default @uwsgi or @proxy block that hands traffic off to the Django application. This ensures the new Python code actually runs for every image request.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Weblate WeblateOrg | < 5.15.2 | 5.15.2 |
| Attribute | Detail |
|---|---|
| CWE | CWE-284 (Improper Access Control) |
| CVSS v3.1 | 7.5 (High) |
| Attack Vector | Network |
| Privileges Required | None |
| Fix Version | 5.15.2 |
| Likelihood | Low (Requires URL prediction) |