CWE-113: HTTP Response Splitting - Python
Overview
HTTP Response Splitting in Python web applications occurs when user-supplied strings are placed into HTTP response headers without stripping CRLF characters (\r\n). The attack exploits the structure of HTTP: headers end with \r\n, and the header section ends with \r\n\r\n. By injecting these sequences into a header value, an attacker can terminate the current header and inject arbitrary headers - including a complete second HTTP response - enabling cache poisoning, XSS, and session hijacking.
Flask's response.headers['Location'] = user_input and make_response() with user-derived header values are typical sinks. Django's HttpResponseRedirect and redirect() helpers validate redirect URLs against a scheme allowlist, but manually setting headers via response['Header-Name'] = user_input provides no CRLF protection.
Primary Defence: Use flask.redirect() or django.shortcuts.redirect() with a validated URL instead of setting Location manually. Strip CRLF characters from any string that must appear in a response header.
Common Vulnerable Patterns
Manual Location Header Assignment (Flask)
from flask import Flask, request, make_response
app = Flask(__name__)
@app.route('/redirect')
def unsafe_redirect():
target = request.args.get('next')
response = make_response('', 302)
# VULNERABLE - user controls the Location header value
response.headers['Location'] = target
return response
# Attack: GET /redirect?next=%0d%0aSet-Cookie:%20admin=true
# Results in injected Set-Cookie header being processed by the browser
Why this is vulnerable:
- URL decoding of
%0d%0ayields\r\n. Assigning this toLocationterminates the header and starts a new one. The injectedSet-Cookieheader is processed by the browser.
Direct Header Assignment (Django)
from django.http import HttpResponse
def download_view(request):
filename = request.GET.get('filename', 'file.txt')
response = HttpResponse(content_type='application/octet-stream')
# VULNERABLE - user input placed directly in Content-Disposition header
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
# Attack: filename=report.pdf%0d%0aContent-Type:%20text/html
Why this is vulnerable:
- A CRLF sequence in
filenameallows an attacker to inject aContent-Type: text/htmlheader, enabling XSS by changing how the browser renders the response.
String Concatenation in Set-Cookie
from flask import request, make_response
@app.route('/set-pref')
def set_preference():
theme = request.args.get('theme', 'light')
response = make_response('OK')
# VULNERABLE - manual cookie header construction
response.headers['Set-Cookie'] = f'theme={theme}; Path=/'
return response
Why this is vulnerable:
- A
themevalue oflight\r\nSet-Cookie: session=hijackedinjects a second cookie that could override the session identifier.
Secure Patterns
Safe Redirect with URL Validation (Flask)
import re
from flask import Flask, request, redirect, abort
app = Flask(__name__)
ALLOWED_REDIRECT_PATTERN = re.compile(r'^/[a-zA-Z0-9/_\-]*$')
@app.route('/redirect')
def safe_redirect():
url = request.args.get('next', '/')
# SECURE: validate the URL is a relative path from an allowed set
if not ALLOWED_REDIRECT_PATTERN.match(url):
abort(400)
return redirect(url) # Flask sets the Location header safely
Why this works:
- The regex ensures only relative paths with safe characters are accepted.
flask.redirect()constructs theLocationheader via Werkzeug, which encodes the URL rather than embedding it raw. - Any CRLF in
urlwould not match the regex and the request is rejected with a 400.
CRLF Sanitization Utility
import re
CRLF_PATTERN = re.compile(r'[\r\n\u0085\u2028\u2029]|%0[aAdD]', re.IGNORECASE)
def sanitize_header_value(value: str) -> str:
"""Strip CRLF and Unicode line terminators from a header value."""
return CRLF_PATTERN.sub('', value)
# SECURE: sanitize before assigning to any header
@app.route('/download')
def safe_download():
raw = request.args.get('filename', 'report')
filename = sanitize_header_value(raw)
# Additional safety: basename to prevent path traversal
filename = os.path.basename(filename) or 'download'
response = make_response(get_file_contents())
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
Why this works:
CRLF_PATTERNmatches both raw CRLF bytes and percent-encoded variants that survive URL decoding. The pattern also covers Unicode line terminators that HTTP/1.1 treats as line endings in some implementations.
Safe Cookie with response.set_cookie() (Flask)
@app.route('/set-pref')
def set_preference():
raw = request.args.get('theme', 'light')
# Strip CRLF before passing to set_cookie
theme = CRLF_PATTERN.sub('', raw)
response = make_response('', 204)
# SECURE: use set_cookie() rather than header assignment
response.set_cookie(
'theme', theme,
httponly=True,
samesite='Strict',
secure=True,
)
return response
Why this works:
response.set_cookie()(Werkzeug) encodes the cookie value using thehttp.cookiesmodule, which handles special characters. Stripping CRLF beforehand adds defence-in-depth for percent-encoded payloads.
Django Safe Redirect
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
def login_success(request):
next_url = request.GET.get('next', '/')
# SECURE: Django's helper validates the URL scheme and host
if not url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
next_url = '/'
return redirect(next_url)
Why this works:
url_has_allowed_host_and_scheme()rejects URLs with non-HTTP schemes and off-host targets. Django'sredirect()then constructs theLocationheader safely.
Remediation Steps
Locate the Finding
- Source:
request.args,request.form,request.json,request.headers - Sink:
response.headers['...'] = user_input,make_response()with user-derived header values, manualSet-Cookieassignment
Apply the Fix
- PRIORITY 1: Replace
response.headers['Location'] = user_inputwithredirect(validated_url)(Flask/Django) - PRIORITY 2: Strip CRLF with
re.sub(r'[\r\n\u0085\u2028\u2029]|%0[aAdD]', '', value, flags=re.I)before any header assignment - PRIORITY 3: Use
response.set_cookie()instead ofresponse.headers['Set-Cookie'] = ...
Verify the Fix
- Submit
%0d%0aX-Injected:%20evilin redirect/cookie/filename parameters; confirm the injected header is absent from the response - Check that valid inputs still produce the correct headers
- Rescan with the security scanner to confirm the finding is resolved
Check for Similar Issues
Search for: response.headers[, make_response(, HttpResponse[, and any manual Location or Set-Cookie assignments
Testing
- Normal input: verify approved redirect destinations, download filenames, and cookie values still produce the expected Flask or Django response.
- Boundary input: test empty values, long names, Unicode line separators, and percent-encoded characters after framework decoding.
- Malicious input: submit
%0d%0aX-Injected:%20eviland raw CRLF bytes; confirm the response contains only the intended headers.