Open Redirect in Django with Mutual Tls
Open Redirect in Django with Mutual TLS — how this specific combination creates or exposes the vulnerability
An open redirect occurs when an application redirects a user to a URL without validating that the destination is trusted. In Django, this commonly arises when a redirect target is derived from request data such as a query parameter. When mutual TLS (mTLS) is enforced, the server validates client certificates, which can create a false sense of security. Developers may assume that because the client is authenticated via mTLS, the request is inherently safe and skip proper validation of redirect targets. This combination is risky: mTLS secures the channel and verifies identity, but it does not constrain application logic. An authenticated client can still supply a malicious next_url parameter, and if the view uses Django’s redirect() without verifying the host, the authenticated session can be abused to steer users to phishing sites.
Consider a Django view that supports single-logout (SLO) or post-login redirects and uses a query parameter like next. With mTLS, the request will present a valid client certificate, and request.user may be populated via mTLS mapping (e.g., using SSL_CLIENT_S_DN or a middleware that maps certificates to users). If the view does not validate the next URL’s host against a whitelist, an attacker who possesses a valid client certificate (e.g., from a compromised device or a misissued cert) can craft a URL such as https://api.example.com/sso/logout?next=https://evil.com and trigger a redirect to the malicious site while the browser still carries the session context implied by mTLS authentication. The vulnerability is not in mTLS itself but in the missing validation layer; mTLS ensures who is talking, not whether the requested action is safe.
Django’s built-in redirect behavior can inadvertently allow open redirects when using django.shortcuts.redirect with a relative or absolute URL derived from user input. Even when using HttpResponseRedirect, if the URL is constructed from request.GET or request.POST without host normalization and validation, the open redirect persists. In an mTLS-enabled deployment, hosts may incorrectly believe that channel-level authentication substitutes for application-level checks, leading to missing host whitelisting and allowlist logic. Attack patterns such as leveraging compromised certificates or stolen tokens pair with open redirect flaws to create convincing phishing paths that retain the appearance of a trusted origin due to the presence of a valid client certificate.
Mutual TLS-Specific Remediation in Django — concrete code fixes
To remediate open redirects in Django when mTLS is in use, always validate redirect targets independently of authentication mechanisms. Never rely on mTLS to enforce safe destinations. Use Django’s is_safe_url (Django < 4.0) or django.utils.http.is_safe_url (Django 4.0+) to check that the URL’s host matches an allowed set, and prefer using absolute path redirects or explicit destination views instead of raw user-supplied URLs.
Example 1: Safe redirect with explicit destination and mTLS identity mapping
import os
from django.http import HttpResponseRedirect, HttpRequest
from django.conf import settings
def get_mtls_user_identity(request: HttpRequest):
"""Extract user identity from mTLS client certificate headers."""
cert_dn = request.META.get('SSL_CLIENT_S_DN', '')
# Map certificate DN to a local user, e.g., via a mapping table
# This is illustrative; implement your own mapping logic.
return cert_dn
def sso_logout(request):
next_url = request.GET.get('next')
allowed_hosts = {settings.ALLOWED_REDIRECT_HOST}
# Validate the host against an explicit allowlist
from django.utils.http import is_safe_url
if not is_safe_url(url=next_url, allowed_hosts=allowed_hosts):
next_url = '/'
# Use redirect with a safe, validated next_url
return HttpResponseRedirect(next_url)
Example 2: Middleware to normalize hosts and enforce strict redirect allowlist
from django.utils.http import is_safe_url
REDIRECT_ALLOWED_HOSTS = ['api.example.com', 'app.example.com']
class SafeRedirectMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response
def process_template_response(self, request, response):
# If response is a redirect, ensure the Location header is safe
if hasattr(response, 'url') and response.status_code in (301, 302, 303, 307, 308):
if not is_safe_url(url=response.url, allowed_hosts=REDIRECT_ALLOWED_HOSTS):
response.url = '/'
return response
Example 3: View using explicit destination mapping instead of raw next
from django.shortcuts import redirect
def home(request):
# Map known, safe destinations by name rather than raw URLs
destination = request.GET.get('dest', 'dashboard')
safe_map = {
'dashboard': '/dashboard/',
'profile': '/profile/',
'logout': '/accounts/logout/',
}
path = safe_map.get(destination, '/')
return redirect(path)
Example 4: Configure host validation for is_safe_url in Django settings
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Ensure is_safe_url considers your public host
ALLOWED_REDIRECT_HOSTS = ['api.example.com', 'static.example.com']
Example 5: Unit test for redirect safety
from django.test import TestCase, RequestFactory
from django.utils.http import is_safe_url
class RedirectSafetyTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
def test_is_safe_url_with_allowed_host(self):
url = 'https://api.example.com/settings'
self.assertTrue(is_safe_url(url, allowed_hosts={'api.example.com'}))
def test_is_safe_url_with_disallowed_host(self):
url = 'https://evil.com/steal'
self.assertFalse(is_safe_url(url, allowed_hosts={'api.example.com'}))