Zip Slip in Django with Mutual Tls
Zip Slip in Django with Mutual Tls — how this specific combination creates or exposes the vulnerability
Zip Slip is a path traversal vulnerability that occurs when an archive extraction uses user-supplied paths without proper sanitization, allowing files to be written outside the intended directory. In Django, this often manifests when an application extracts a client-provided ZIP file to a destination derived from archive member names. The presence of Mutual TLS (mTLS) does not prevent Zip Slip; it changes the trust boundary but does not alter how the archive is processed server-side. mTLS ensures that only authenticated clients with valid certificates can reach the endpoint, which may increase the perceived safety of accepting file uploads. However, once the request is authenticated via client certificate, the application must still validate and sanitize paths within the uploaded archive. Without explicit path checks, an attacker with a valid certificate can craft an archive containing entries like ../../../etc/passwd or absolute paths that escape the extraction directory. The combination of mTLS and Zip Slip is notable because mTLS narrows the attack surface to certificate-holding actors, but if authorization and input validation are weak, a compromised or malicious certificate holder can still trigger insecure extraction behavior. This scenario is especially risky when the Django view trusts the client identity provided by mTLS and skips additional checks, assuming the request is safe because it came from an authenticated source. The vulnerability is not introduced by mTLS but is exposed when path normalization is omitted after authentication. Django does not automatically sanitize archive member paths, so developers must explicitly reject paths that traverse parent directories or resolve outside the target folder. In a typical flow, the view receives a file via a POST request, passes it to a library such as Python’s zipfile, and writes entries to disk. If the view uses the member’s filename directly—e.g., extracted_path = os.path.join(UPLOAD_DIR, member.name)—and does not call functions like os.path.normpath combined with prefix checks, absolute or relative paths can escape. mTLS may also influence logging and audit trails, making it easier to trace which certificate triggered the extraction, but it does not mitigate the traversal itself. To safely handle uploads in this setup, Django must enforce strict path validation regardless of transport-layer authentication, ensuring every entry is confined to the intended directory tree.
Mutual Tls-Specific Remediation in Django — concrete code fixes
Remediation for Zip Slip in Django should focus on secure archive extraction and strict path validation, independent of mTLS. However, when mTLS is in use, ensure that client identity is mapped to a least-privilege scope and that extraction logic does not implicitly trust certificate-derived metadata. Below are concrete code examples for secure extraction in a Django view using Mutual TLS for client authentication.
Secure ZIP extraction with path validation
Use a helper that checks each archive entry against a normalized target directory, rejecting paths that escape the intended base. This approach works alongside mTLS authentication.
import os
import zipfile
from django.conf import settings
from django.http import HttpResponseBadRequest
def safe_extract_zip(zip_path, extract_dir):
"""Extract a ZIP file ensuring no path traversal."""
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
for member in zf.infolist():
# Normalize the member path
member_path = os.path.normpath(member.filename)
# Build the full destination path
dest_path = os.path.normpath(os.path.join(extract_dir, member_path))
# Ensure the destination is still inside extract_dir
if not dest_path.startswith(os.path.abspath(extract_dir) + os.sep):
raise ValueError(f"Invalid path outside target directory: {member.filename}")
# Optionally, reject absolute paths or Windows drive letters
if os.path.isabs(member_path) or (os.sep == '\\' and len(member_path) > 1 and member_path[1] == ':'):
raise ValueError(f"Absolute path detected: {member.filename}")
zf.extract(member, dest_path)
return dest_path
class UploadView(View):
def post(self, request):
uploaded_file = request.FILES.get('archive')
if not uploaded_file:
return HttpResponseBadRequest('No file uploaded')
# Save temporarily
temp_path = f'/tmp/{uploaded_file.name}'
with open(temp_path, 'wb+') as dst:
for chunk in uploaded_file.chunks():
dst.write(chunk)
try:
# Extract safely
safe_extract_zip(temp_path, settings.MEDIA_ROOT)
except (zipfile.BadZipFile, ValueError) as e:
return HttpResponseBadRequest(f'Invalid archive: {e}')
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return HttpResponse('Extraction successful')
Django settings for client certificate verification
When using Mutual TLS, configure Django to require client certificates and map them to application-level permissions. Below is an example using django-sslserver-style settings and a custom middleware to bind the certificate subject to the request user.
# settings.py
SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Assume the web server terminates TLS and sets headers like SSL_CLIENT_VERIFY
# and SSL_CLIENT_S_DN_CN. The middleware reads them.
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'yourapp.middleware.ClientCertMiddleware',
# ...
]
# yourapp/middleware.py
import ssl
from django.contrib.auth.models import User
from django.http import HttpResponseForbidden
class ClientCertMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# The web server should validate the client certificate and set these headers
cert_verified = request.META.get('SSL_CLIENT_VERIFY', '')
if cert_verified != 'SUCCESS':
return HttpResponseForbidden('Client certificate required')
subject = request.META.get('SSL_CLIENT_S_DN_CN')
if subject:
# Map subject to a local user; in practice, use a lookup table or LDAP
user, _ = User.objects.get_or_create(username=subject)
request.user = user
else:
request.user = User.objects.get(username='anonymous')
return self.get_response(request)
Additional hardening tips
- Always use
os.path.normpathand prefix checks before extracting. - Consider using
pathlib.Pathwithresolve()and ensuring the result is under the base directory. - Limit file permissions on extracted files and avoid executing uploaded content.
- Log extraction attempts with client certificate identifiers for audit trails.