SSRF

SSRF in Django: remediation guide [May 2026] [CVE-2026-41654]

[Updated May 2026] Updated CVE-2026-41654

Overview

The CVE-2026-41654 vulnerability in Weblate shows how SSRF can arise when user-controlled URLs are stored and later used server-side, taking advantage of Django patterns. In Weblate, an authenticated user with project.add could import a crafted backup ZIP whose components/*.json contain an attacker-chosen repo URL. Because Weblate persisted components via Component.objects.bulk_create([component])[0], Django's full_clean() and model-level validators did not run, allowing the URL to be written directly into .git/config during configure_repo(pull=False). The URL could point at a private address like http://127.0.0.1:9999/ or use disallowed schemes (file://, git://), enabling the server to initiate requests into internal services. The issue was patched in Weblate version 5.17.1, illustrating the risk when user-provided URLs are persisted without proper validation and then used in network operations. This pattern-unvalidated, persisted URLs used in server-side calls-maps to SSRF risks in Django apps that store and reuse user input for remote actions.

Affected Versions

Weblate <= 5.17.0 (CVE-2026-41654); patched in 5.17.1

Code Fix Example

Django API Security Remediation
Vulnerable pattern (SSRF risk due to unvalidated, bulk-created URLs used in server-side config):

from django.db import models
import json, zipfile, subprocess

class Component(models.Model):
    name = models.CharField(max_length=100)
    repo_url = models.URLField()

def import_components_from_backup(backup_zip_bytes):
    components = []
    with zipfile.ZipFile(backup_zip_bytes) as zf:
        for fname in zf.namelist():
            if fname.startswith('components/') and fname.endswith('.json'):
                data = json.loads(zf.read(fname))
                components.append(Component(name=data['name'], repo_url=data['repo_url']))
    # Vulnerable: bulk_create bypasses model validation (full_clean) and validators
    Component.objects.bulk_create(components)
    # Later, the server uses the URL directly (e.g., in git config)
    for c in components:
        subprocess.run(['git', 'config', 'url.{}.insteadof'.format(c.repo_url), c.repo_url], check=True)

# This pattern allows attacker-controlled repo_url to be stored and used without validation.

# Fixed pattern (validate URLs, enforce allowlists, and avoid bulk bypass):
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from urllib.parse import urlparse
import ipaddress

def validate_repo_url(value):
    # Allow only http/https
    parsed = urlparse(value)
    if parsed.scheme not in ('http', 'https'):
        raise ValidationError('Unsupported URL scheme')
    # Disallow private IPs and loopbacks
    host = parsed.hostname
    if host:
        try:
            ip = ipaddress.ip_address(host)
            if ip.is_private or ip.is_loopback:
                raise ValidationError('Private IPs are not allowed')
        except ValueError:
            # Not an IP address; DNS-based checks could be added here
            pass
    return value

class Component(models.Model):
    name = models.CharField(max_length=100)
    repo_url = models.URLField(validators=[validate_repo_url])

def import_components_from_backup_fixed(backup_zip_bytes):
    components = []
    with zipfile.ZipFile(backup_zip_bytes) as zf:
        for fname in zf.namelist():
            if fname.startswith('components/') and fname.endswith('.json'):
                data = json.loads(zf.read(fname))
                comp = Component(name=data['name'], repo_url=data['repo_url'])
                # Run validators before persistence
                comp.full_clean()
                components.append(comp)
    # Prefer per-instance create/save to ensure validators run,
    # instead of bulk_create bypassing full_clean
    for c in components:
        c.save()
        subprocess.run(['git', 'config', 'url.{}.insteadof'.format(c.repo_url), c.repo_url], check=True)

CVE References

Choose which optional cookies to allow. You can change this any time.