Skip to content

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%0a yields \r\n. Assigning this to Location terminates the header and starts a new one. The injected Set-Cookie header 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 filename allows an attacker to inject a Content-Type: text/html header, enabling XSS by changing how the browser renders the response.
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 theme value of light\r\nSet-Cookie: session=hijacked injects 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 the Location header via Werkzeug, which encodes the URL rather than embedding it raw.
  • Any CRLF in url would 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_PATTERN matches 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.
@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 the http.cookies module, 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's redirect() then constructs the Location header 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, manual Set-Cookie assignment

Apply the Fix

  • PRIORITY 1: Replace response.headers['Location'] = user_input with redirect(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 of response.headers['Set-Cookie'] = ...

Verify the Fix

  • Submit %0d%0aX-Injected:%20evil in 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:%20evil and raw CRLF bytes; confirm the response contains only the intended headers.

Additional Resources