CVE-2026-21889

Exposed in Translation: Weblate Static Asset Bypass

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 14, 2026·6 min read

Executive Summary (TL;DR)

Weblate, a popular localization tool, relied on the web server (Nginx/Apache) to serve media files directly for performance. This created a bypass where private screenshots were served as public static assets, completely ignoring application-level permissions. An attacker who can guess or discover the filename of a screenshot can view it without authentication. The fix involves moving media serving back into the application layer.

A classic architectural disconnect between the web server and the application logic allows unauthenticated users to access private project screenshots in Weblate by bypassing Django's access controls entirely.

The Hook: Lost in Translation (and Authorization)

Localization platforms like Weblate are the unsung heroes of global software. They bridge the gap between developer intent and user understanding. To do this effectively, translators often need context—usually in the form of screenshots showing where a specific string appears in the UI.

Here’s the rub: Screenshots are sensitive. They often contain unreleased features, internal dashboards, PII, or even API keys accidentally caught in the frame. You’d expect these images to be guarded by the same login screen that protects the rest of your private project.

But in CVE-2026-21889, Weblate fell victim to the oldest performance optimization trick in the book: letting the web server do the heavy lifting. By prioritizing speed over strict access control, Weblate inadvertently turned its private media directory into a public gallery, provided you knew which painting to ask for.

The Flaw: The Nginx Bypass

Modern web frameworks like Django are powerful but relatively slow at serving large binary files. The industry standard solution? Let Nginx or Apache handle the static assets (CSS, JS, images) directly from the disk, bypassing the Python application entirely. It saves CPU cycles and reduces latency.

In a vulnerable Weblate deployment, the Nginx configuration typically looked like this:

location /media/ {
    alias /home/weblate/data/media/;
    expires 30d;
}

See the problem? This location block is an unauthenticated tunnel. When a request hits /media/screenshots/secret_project.png, Nginx looks at the disk, finds the file, and serves it. It doesn't ask Django if the user is logged in. It doesn't check if the user is a member of the project. It just serves the bytes.

This is a classic Insecure Direct Object Reference (IDOR), but architectural rather than logical. The application layer had permission logic, but the network layer effectively short-circuited it. It’s the digital equivalent of hiring a bouncer for the club (Django) but leaving the back fire exit propped open (Nginx) for the smokers.

The Code: Moving the Gatekeeper

The fix required a philosophical shift: performance had to take a backseat to security. The patch, authored by Michal Čihař in commit a6eb5fd0299780eca286be8ff187dc2d10feec47, rips out the concept of static serving for screenshots and forces them through a standard Django View.

First, a new view was introduced to handle the serving logic. Notice the inheritance from ScreenshotBaseView which likely includes the LoginRequiredMixin or similar checks implicitly, plus the explicit check_access_component call:

# weblate/screenshots/views.py
 
class ScreenshotBaseView(DetailView):
    model = Screenshot
    request: AuthenticatedHttpRequest
 
    def get_object(self, *args, **kwargs):
        obj = super().get_object(*args, **kwargs)
        # THE FIX: Explicitly checking if the user can see the component
        self.request.user.check_access_component(obj.translation.component)
        return obj
 
class ScreenshotView(ScreenshotBaseView):
    def get(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> FileResponse:
        obj = self.get_object()
        # Serve the file through Python, not Nginx
        return FileResponse(
            obj.image.open(),
            as_attachment=False,
            filename=os.path.basename(obj.image.name),
        )

This code ensures that every single request for a screenshot incurs the "cost" of a database lookup and a permission check. It is slower, yes, but it ensures that private actually means private.

The Exploit: Enumeration and Persistence

The barrier to entry for this exploit is knowledge of the filename. The CVSS score (2.3) is surprisingly low because the vulnerability assumes random or hashed filenames that are hard to guess (High Attack Complexity). However, a motivated attacker has several avenues to lower that complexity.

1. Sequential IDs: If an older version of Weblate or a specific import tool generated screenshots with sequential filenames (e.g., shot_001.png), the attack becomes trivial. An attacker can simply iterate through integers until they hit a valid image.

2. Metadata Leakage: Filenames often leak in other places. API responses, git commits, or even browser history screenshots posted on forums might reveal the naming convention. Once you have the pattern, you have the data.

3. The "Lazy Admin" Re-exploitation: This is the most dangerous aspect. The patch in Weblate modifies the Python code, but it does not automatically update the production web server configuration.

[!WARNING] If an administrator updates Weblate via pip or apt but forgets to remove the /media/ alias from their Nginx config, the vulnerability remains active.

The application has the secure view, but Nginx will still catch the request first and serve the file statically because the file still exists on the disk in the same path. This creates a false sense of security where the software version says "Safe" but the infrastructure says "Vulnerable".

The Impact: What's in the Picture?

Why should you care about screenshots? In a vacuum, they seem harmless. But in the context of a private localization project, they are often high-fidelity snapshots of intellectual property before it hits the market.

Consider a fintech startup translating their app. The screenshots uploaded for context might include:

  • Unreleased Features: Competitors can see your roadmap.
  • Hardcoded Secrets: Developers often use "dummy" data that isn't actually dummy data. API keys or internal URLs visible in the address bar of a browser screenshot.
  • PII: Screenshots taken of a live production account to demonstrate a bug or a specific UI state.

While this isn't Remote Code Execution (RCE), it is a significant Confidentiality breach. It allows an unauthenticated external actor to peer inside the development lifecycle of private projects.

The Fix: A Two-Step Tango

Fixing CVE-2026-21889 requires coordination between the application and the infrastructure.

Step 1: The Upgrade Update Weblate to version 5.15.2 or later. This installs the new ScreenshotView logic.

Step 2: The Config Purge (Crucial) You must modify your web server configuration to stop serving the media directory.

For Nginx, look for the block handling /media/ and delete it:

- location /media/ {
-     alias /var/lib/weblate/media/;
- }

By removing this, requests to /media/... will no longer match a static file rule and will instead fall through to the main @weblate or / location block, which proxies the request to uWSGI/Gunicorn. Django will then pick up the request, route it to the new secure view, and enforce the permission checks.

Don't forget to reload your web server (systemctl reload nginx) or the backdoor stays open.

Fix Analysis (1)

Technical Appendix

CVSS Score
2.3/ 10
CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N

Affected Systems

Weblate < 5.15.2

Affected Versions Detail

Product
Affected Versions
Fixed Version
Weblate
WeblateOrg
< 5.15.25.15.2
AttributeDetail
CWECWE-284 (Improper Access Control)
CVSS v4.02.3 (Low)
Attack VectorNetwork
Attack ComplexityHigh (Requires guessing filenames)
PrivilegesNone / Low
StatusPatched
CWE-284
Improper Access Control

The product does not properly restrict access to a resource from an unauthorized actor.

Vulnerability Timeline

Fix committed to main branch
2026-01-06
CVE Published
2026-01-14

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.