Skip to content

CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') - Python

Overview

HTTP Response Splitting in Python web applications occurs when user-supplied strings reach HTTP response headers without the application deciding what they may contain. 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.

Where the Framework Already Closed This, and Where It Did Not

On current Flask/Werkzeug and Django, a CRLF in a header value is not an injection - it is a 500. That matters for triage, because a reader given the classic write-up will go looking for a bug that cannot fire on their stack, and will not go looking for the ones that can.

Measured on Werkzeug 3.1.8 / Flask 3.1.3 and Django 6.1:

What the code does Werkzeug / Flask Django
response.headers['Location'] = "http://x/\r\nSet-Cookie: admin=true" ValueError: Header values must not contain newline characters. - unhandled, so the request answers 500 BadHeaderError: Header values can't contain newlines
Bare \n or bare \r in a value same ValueError same BadHeaderError
redirect(url) with a CRLF in url same ValueError, 500 Location is percent-encoded to http://x/%0D%0ASet-Cookie:%20admin=true
set_cookie(name, value) with a CRLF in value escaped to theme="light\015\012Set-Cookie: ...", no injection http.cookies.CookieError: Control characters are not allowed in cookies
A CRLF in the header name not checked - see below BadHeaderError

Werkzeug's Headers datastructure has refused newlines in values since 0.8 (September 2011), so this is not a recent hardening a deployment might be behind on.

Four things are still live, and a finding on a Python codebase is most likely to be one of them:

  • Header names, on Werkzeug. The check covers values only. response.headers['X-A\r\nX-Injected: evil'] = 'v' raises nothing, and through Werkzeug's own development server it reaches the wire as X-A\r\nX-Injected: evil: v - a real injected header. Whether it survives depends on the WSGI server rather than the framework: waitress 3.0.2 refuses it (carriage return/line feed character present in header name, answering 500) and so does CPython's wsgiref (ValueError: Control characters not allowed in headers). Django checks names as well as values and is not affected. Any code that derives a header name from user input - a dynamic X-prefixed field, a passthrough of a client-chosen key - is the one to look at.
  • Anything that writes the response without going through the framework's header object: a raw WSGI app assembling its own start_response header list, an ASGI app appending to (name, value) byte tuples, a socket.send() in a health-check or proxy shim, or a middleware that rebuilds headers from environ. The check lives in werkzeug.datastructures.Headers and django.http.HttpResponse, not in the language.
  • A different framework, or an older one. The table above is Flask/Werkzeug and Django. Confirm rather than assume for anything else, and note the check is in the application framework - a bare ASGI or WSGI app has whichever behaviour its server happens to implement. Other ecosystems do not agree with these two either: a servlet container silently replaces each CR and LF with a space, PHP discards the header entirely, and only ASP.NET Core and Node raise the way Flask and Django do. The main CWE-113 page has the runtime-by-runtime table.
  • The value reaching a header somewhere else entirely - a reverse proxy configured with a header from a request field, a log line, an email header, an S3 object's metadata. That is CWE-93 territory once it leaves HTTP, and the same string is usually flowing to both.

The practical consequence for the sinks that are checked: an attacker cannot inject, but they can turn any request into a 500 at will, and a 500 is a real availability and log-noise problem rather than a fix. Validate the value so the request is rejected deliberately with a 400, rather than letting the header layer raise.

Primary Defence: Use flask.redirect() or django.shortcuts.redirect() with a validated URL instead of setting Location manually. Validate - do not silently strip - any string that must appear in a response header, and validate header names against a fixed set rather than deriving them from input at all.

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

# GET /redirect?next=%0d%0aSet-Cookie:%20admin=true
# On Werkzeug 3.1.8 this is a 500, not an injection - see below

Why this is vulnerable: target is whatever the client sent, and the handler makes no decision about it before it becomes a header. On a current Werkzeug the assignment raises ValueError: Header values must not contain newline characters., so the outcome is an unhandled 500 rather than the injected Set-Cookie that this pattern is usually written up as producing - the payload is a denial-of-service and a stack trace in the log, and any attacker can fire it on every request. The pattern is still the one to fix, for two reasons that outlive the framework check: it is an open redirect (CWE-601) at the same time, since next=https://evil.example is a perfectly valid header value that Werkzeug will happily emit; and the check is a property of werkzeug.datastructures.Headers, so the same line in a raw WSGI handler, behind an older Werkzeug, or with the value going to a header name instead, does split the response. Fix it by deciding what next is allowed to be, not by relying on the header layer to raise.

Building a Header Name from User Input (Flask)

@app.route('/echo')
def echo():
    # VULNERABLE - the header NAME comes from the client; Werkzeug validates
    # header values and does not validate names
    response = make_response('OK')
    response.headers[request.args.get('field', 'X-Default')] = 'ok'
    return response

# GET /echo?field=X-A%0d%0aX-Injected:%20evil
# Emitted verbatim by werkzeug.serving as: X-A\r\nX-Injected: evil: ok

Why this is vulnerable: The newline check in Headers.set() runs over the value and not the key, so a CRLF in the name passes through the framework untouched. Whether it reaches the client is then decided by the WSGI server rather than by the application: Werkzeug's own development server writes it out as-is and the injected X-Injected: evil appears on the response, while waitress and wsgiref both raise and turn it into a 500. That split is what makes this dangerous to reason about - it can pass every test in development on one server and behave differently in production, in either direction. Header names should come from a fixed set in the source, never from a request; there is no legitimate reason for a client to choose one.

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

# filename=report.pdf%0d%0aContent-Type:%20text/html
# On Django 6.1 this raises BadHeaderError, not an injected header

Why this is vulnerable: filename reaches a header with no decision made about it. Django is the stricter of the two frameworks here - response['...'] = value runs HttpResponse.__setitem__, which raises BadHeaderError: Header values can't contain newlines for \r, \n or \r\n, in the name as well as the value - so the CRLF payload produces a 500, not the Content-Type override. What is left is still a real bug: an unauthenticated 500 on demand, and everything a filename can do inside a quoted Content-Disposition value without any newline at all. A " closes the quoted string, and a ; starts a new parameter, so report".pdf and a.pdf"; filename*=UTF-8''evil.html both change what the browser saves the file as. Build the header with a helper that quotes for you, or restrict the filename to a character set that needs no quoting.

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: Assembling the Set-Cookie value by hand throws away everything the cookie API does: HttpOnly, Secure, SameSite and an expiry are all absent, the value is not quoted or escaped, and a ; in theme starts a new cookie attribute. On Werkzeug 3.1.8 the CRLF form (light\r\nSet-Cookie: session=hijacked) hits the same header-value check as every other assignment and answers 500; a ; needs no newline and works. response.set_cookie('theme', value) is the fix and it handles the CRLF case differently again - Werkzeug's dump_cookie escapes the control characters into a quoted value (theme="light\015\012Set-Cookie: session=hijacked"), so the cookie is stored with a strange value and nothing is injected, while Django's set_cookie raises http.cookies.CookieError for the same input. Neither silently splits the response, and both are better than the string above.

Secure Patterns

Safe Redirect with URL Validation (Flask)

import re
from flask import Flask, request, redirect, abort

app = Flask(__name__)

# SECURE - fullmatch, not match with ^...$ - in Python, $ also matches
# immediately before a trailing newline, so "/home\n" satisfies ^/[a-z]+$
ALLOWED_REDIRECT_PATTERN = re.compile(r'/(?!/)[a-zA-Z0-9/_\-]*')
# The (?!/) is what rejects a protocol-relative target. Without it the
# character class alone stops //evil.example on the dot and lets //intranet
# through, which the browser resolves as a host, not as a path.

@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.fullmatch(url):
        abort(400)
    return redirect(url)  # Flask sets the Location header safely

Why this works:

  • The pattern accepts only relative paths built from letters, digits, /, _ and -, and the (?!/) rejects a second slash at the front. The character class excludes \r, \n, : and ., which stops a CRLF payload and an absolute https://evil.example; the lookahead is what stops a protocol-relative one. Both halves are needed, and the second is easy to leave out: without it //evil.example is still rejected - on the dot - while //intranet and //attacker/path are accepted and the browser reads them as a host rather than a path. Measured on Python 3.13.12. With the lookahead the one check covers response splitting and open redirect (CWE-601), which are different weaknesses arriving through the same parameter.
  • Rejecting with abort(400) is the point of validating rather than leaving it to the framework. The header layer will raise on a CRLF anyway, but that produces a 500 and a stack trace; deciding here produces a 400 and a log line that says what happened.
  • fullmatch() matters more than it looks. re.match(r'^/[a-zA-Z0-9/_\-]*$', '/home\n') returns a match, because Python's $ matches before a final newline - so the ^...$ spelling would admit exactly the character the check exists to exclude. Measured on Python 3.13.12, re.fullmatch() rejects '/home\n' and '/home\r\nSet-Cookie: a=b' while still accepting /home, /reports/2026-q1 and /a/b/c_d-e.
  • Use re.fullmatch(), not \A...\z. Python inverts the convention every other engine on this site uses. \z is not a Python escape at all up to and including 3.13 - re.compile(r'\A[a-z]+\z') raises PatternError: bad escape \z, measured on 3.13.12 - and it was only added in 3.14. Python's strict end anchor is the capital \Z, which in .NET, Java and PCRE means the newline-tolerant one. Guidance that carries \z across from a .NET or PHP page therefore produces a pattern that either does not compile or, if somebody "fixes" it to \Z in the other direction, silently means the opposite. re.fullmatch() has no such trap and needs no version caveat.

Validate Rather Than Strip

import re
from flask import abort, make_response, request

# Reject, do not repair. A stripped value is a value nobody chose:
# "report\r\nX: y" becomes "reportX: y", which is stored and shown to
# somebody later as though the user had asked for it.
FILENAME_PATTERN = re.compile(r'[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}')

@app.route('/download')
def safe_download():
    filename = request.args.get('filename', 'report.pdf')
    # SECURE - an allowlist of characters valid in a quoted Content-Disposition
    # filename - no CR, LF, quote, semicolon or backslash can survive it
    if not FILENAME_PATTERN.fullmatch(filename):
        abort(400)

    response = make_response(get_file_contents())
    response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
    return response

Why this works:

  • The allowlist is defined by what is safe in this context rather than by a list of dangerous characters. " and ; are excluded because they terminate or extend the quoted parameter, \ because it escapes inside it, and CR/LF because they end the header - and nothing has to be enumerated for that to hold, which is what makes an allowlist survive a spec the author has not read.
  • A denylist written the other way round tends to acquire %0d and %0a as literal two- and three-character sequences. Those do not belong in it: request.args has already percent-decoded, so a %0d still present in the value is one the client double-encoded, and it is ordinary text at this point - stripping it silently rewrites a name such as q3%0d-report.pdf into q3-report.pdf and defends against nothing. The allowlist above rejects that name too, because % is not in the character class; the difference is that it says so with a 400 instead of storing a value the user did not ask for. If a downstream component really does decode a second time, the fix belongs at that decode, not here.
  • Length is bounded in the same expression. A header value has no length limit in the application, and servers and proxies differ on where they stop accepting one.
@app.route('/set-pref')
def set_preference():
    theme = request.args.get('theme', 'light')
    # SECURE - reject an unexpected value rather than repairing it
    if theme not in ('light', 'dark', 'system'):
        abort(400)

    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:

  • set_cookie() hands the value to Werkzeug's dump_cookie(), which quotes it and escapes anything outside the RFC 6265 cookie-octet set into three-digit octal escapes - the same convention the stdlib http.cookies uses. A CRLF in the value comes out as theme="light\015\012..." inside the quotes rather than as a second header, so the response is not split. Django's set_cookie refuses the same input outright with http.cookies.CookieError; either way there is no injection, and neither is a reason to build the header by hand.
  • The allowlist is still what makes this correct rather than merely not-broken. theme is an enumerated value, so the set of legal inputs is three strings - and where a value is enumerated, checking membership beats any amount of character filtering, because nothing about the encoding has to be reasoned about at all.

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.

Testing

Re-running the detector is not enough here, for a reason specific to this weakness: on current Flask/Werkzeug and Django the header layer already raises, so a scanner that stops at "no injected header appeared" reports success against a fixed endpoint and against an unfixed one that answered 500. Assert on the status code, not only on the header list.

  • The accept: request each redirect destination, filename and cookie value the application is supposed to allow, and assert 302/200 with the expected Location, Content-Disposition and Set-Cookie. A validation pattern tight enough to exclude CRLF is also tight enough to exclude /reports/2026-q1 if the character class omitted -, and every rejection test passes either way.
  • A rejected value is a 400, not a 500: send next=/home%0d%0aSet-Cookie:%20admin=true and assert the status is 400. A 500 means the validation did not run and the value reached Headers.set() or HttpResponse.__setitem__, which is a working denial of service on the endpoint even though nothing was injected.
  • Trailing newline: send each validated parameter with a single \n appended (next=/home%0a). Assert 400. This is the $-versus-fullmatch case, and it is the one input that distinguishes a correct anchor from an incorrect one.
  • The response on the wire, not the framework's view of it: for anything that builds a header name, or writes through raw WSGI/ASGI, read the raw bytes from a socket rather than a test client. A test client shows the framework's header mapping; only the socket shows what the server serialised, and for a CRLF in a header name those differ.
  • Both header halves: if any header name is derived from input, assert on the full header list, not on response.headers['X-Expected']. An injected header does not disturb the one you look up.

Common Pitfalls

  • Anchoring a validation regex with ^...$ instead of re.fullmatch(): in Python, $ matches at the end of the string or just before a trailing newline, so re.match(r'^/[a-z0-9/_-]+$', value) accepts a value ending in \n that a stricter anchor would reject - which is precisely the character the check was written to exclude. Use re.fullmatch().
  • Reaching for \A...\z because that is the fix in .NET, Java and PHP: it is not the fix in Python, and it fails in both directions. \z is not a valid Python escape up to and including 3.13 - re.compile(r'\A[a-z]+\z') raises PatternError: bad escape \z on 3.13.12 - and it only arrived in 3.14, so a pattern copied from a .NET page does not compile on most deployed interpreters. Rewriting it as \Z does compile and is correct in Python, but the same two letters mean the newline-tolerant anchor in every other engine, so the correction does not travel back. re.fullmatch() avoids the whole exchange.
  • Reading the framework's rejection as the fix: ValueError from Werkzeug and BadHeaderError from Django stop the injection and leave an unhandled exception in its place, so the endpoint answers 500 to any request the attacker chooses. The finding is downgraded, not closed. Validate before the assignment so the answer is a deliberate 400.
  • Fixing the redirect call but leaving a second header assignment unprotected: switching response.headers['Location'] = url to redirect(url) closes that sink, but a nearby response.headers['X-Debug-Path'] = raw_value in the same view is not covered by that change and needs its own validation.
  • Checking values and not names: Werkzeug validates header values only, so a name built from user input is unguarded at the framework layer and its fate is decided by whichever WSGI server is running. Header names belong in the source as literals.
  • Stripping %0d/%0a from an already-decoded value: request.args and request.GET have decoded once already, so a percent sequence still present in the value is literal text. Removing it silently corrupts legitimate input and stops nothing; if a second decode really happens downstream, that decode is the sink to fix.

Additional Resources