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)