CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection') - Python
Overview
CRLF Injection in Python applications occurs when untrusted user input containing carriage return (\r, %0D) and line feed (\n, %0A) characters is used in HTTP headers or other protocol fields without validation. What that buys an attacker depends on the sink: HTTP response splitting and header injection, an injected email header, or a forged log or CSV record.
Primary Defence: Reject - do not strip - user input containing newline characters (\r, \n) before it reaches an HTTP header, a log record, an email header or a CSV row. Validate against a strict allowlist rather than filtering, let the framework build Location and Set-Cookie headers rather than assembling them by hand, and use a JSON log formatter so a newline that does get through is escaped inside a field instead of starting a new record.
What the Runtime Already Blocks
Python's web and mail libraries mostly fail closed on a literal CR or LF, and knowing which one you are on decides whether a finding is exploitable or merely unhandled. Verified against Python 3.13 with Flask 3.1 / Werkzeug 3.1, Django 6.1, FastAPI 0.141 / Starlette 1.6 behind uvicorn, and requests 2.34 / urllib3 2.7:
| Sink | Behaviour on a raw CR/LF |
|---|---|
response.headers[...] (Werkzeug) |
ValueError: Header values must not contain newline characters. |
flask.redirect() |
Same ValueError - the target becomes a Location header |
response[...] = value (Django) |
django.http.response.BadHeaderError |
HttpResponseRedirect(...) (Django) |
Percent-encoded: Location: http://example.com%0D%0ASet-Cookie:%20admin=true |
response.headers[...] (Starlette/FastAPI) |
Accepted, then the ASGI server refuses to serialize it - uvicorn raises LocalProtocolError on h11 and RuntimeError: Invalid HTTP header value. on httptools, and the client gets a dropped connection with no response |
EmailMessage[...] (email.message) |
ValueError: Header values may not contain linefeed or carriage return characters |
requests.get(..., headers=...) |
urllib3.exceptions.InvalidHeader |
Three sinks have no such guard and are where the real exploitation lives: a header block assembled as a string and handed to smtplib.sendmail(), a log record written through logging with a text formatter, and any CSV or line-delimited file written with write().
Even on the guarded sinks the pattern still needs fixing, for reasons the table does not show. The checks look for a literal CR or LF byte at the moment the value is set, so a percent-encoded payload passes and becomes a real newline wherever something decodes it again. The Unicode line terminators are handled inconsistently. EmailMessage is the strict one, because its check is Python's own definition of a line break rather than the HTTP one: it splits the value with splitlines() and refuses anything that comes out as more than one line, so U+0085, U+2028 and U+2029 are rejected exactly as CR and LF are when text follows them - A U+2028 B raises ValueError - while a value that is only a separator or ends with one passes and is emitted as an RFC 2047 encoded word rather than as a break. That rejection is not universal - it is the fix for CVE-2024-6923 and only ships in 3.8.20, 3.9.20, 3.10.15, 3.11.10, 3.12.5, and 3.13 onward, so EmailMessage on an earlier 3.8-3.12 patch level, or on any version before 3.8, accepts a raw CR/LF in a header value and email header injection is exploitable. Everywhere else, the header collection accepts all three and the serializer is what draws the line, at Latin-1 rather than at anything HTTP-specific. PEP 3333 requires WSGI header values to be Latin-1-encodable, so U+0085 (byte 85) reaches the wire while U+2028 and U+2029 raise UnicodeEncodeError in the server's write path - measured on Werkzeug 3.1.8's dev server, the handler returns 200 and the client gets a dropped connection with no response at all, the same shape as the Starlette row above. urllib3 2.7.0 behaves the same way outbound: A U+0085 B goes out as 41 85 42, and U+2028 or U+2029 raises UnicodeEncodeError before anything is written. Django accepts U+0085 in a header value and RFC 2047-encodes U+2028 and U+2029. And an unhandled ValueError or BadHeaderError on a request path is itself a defect - the endpoint is now remotely faultable by anyone who sends a newline.
Common Vulnerable Patterns
Flask Redirect with User Input
# VULNERABLE - Direct user input in redirect location
from flask import Flask, request, redirect
app = Flask(__name__)
@app.route('/redirect')
def vulnerable_redirect():
url = request.args.get('url', '')
# VULNERABLE - User input directly in redirect
return redirect(url)
# Attack: /redirect?url=http://example.com%0d%0aSet-Cookie:%20admin=true
# Werkzeug raises ValueError building the Location header: 500, no Set-Cookie.
# The open redirect does land - any host the attacker names
Why this is vulnerable: The response split is the part that does not happen. On Flask 3.1 the payload above raises ValueError: Header values must not contain newline characters. inside redirect(), so the caller gets a 500 and no Set-Cookie reaches the client.
What survives is the open redirect. url is attacker-chosen and never checked, so the handler will send a user to any host a phishing page cares to name, and no amount of CRLF filtering touches that half. See CWE-601 for the target check.
Custom Response Headers
# VULNERABLE - User input in custom headers
from flask import Flask, request, Response
app = Flask(__name__)
@app.route('/api/data')
def vulnerable_headers():
username = request.args.get('username', '')
response = Response("User data")
# VULNERABLE - User input in custom header
response.headers['X-User-Name'] = username
response.headers['X-Requested-By'] = request.headers.get('User-Agent', '')
return response
# Attack: ?username=admin%0d%0aContent-Length:%200%0d%0a%0d%0a<script>alert('XSS')</script>
# Werkzeug raises ValueError on assignment: 500, no injected header. The
# unvalidated value is still the defect - see the table above
Why this is vulnerable: Werkzeug validates on assignment, so response.headers['X-User-Name'] = username raises ValueError rather than emitting an injected header. The documented attack turns an unvalidated query parameter into a remote way to fault the endpoint, not into a split response.
The pattern is still the defect. Both values are attacker-controlled where they become part of the response, the percent-encoded form is outside what the check covers - as is U+0085, which passes both the check and the serializer - and User-Agent is a request header the client sets freely, so nothing about it is more trustworthy than the query string.
Django HttpResponse Headers
# VULNERABLE - Django with user-controlled headers
from django.http import HttpResponse
from django.views.decorators.http import require_GET
@require_GET
def vulnerable_view(request):
callback = request.GET.get('callback', '')
data = '{"status": "success"}'
response = HttpResponse(data, content_type='application/json')
# VULNERABLE - User input in JSONP callback header
response['X-Callback'] = callback
return response
# Attack: ?callback=test%0d%0aSet-Cookie:%20sessionid=stolen
# Django raises BadHeaderError: no Set-Cookie. The callback is still
# unvalidated, which matters wherever else it is used
Why this is vulnerable: Django does check this one: response['X-Callback'] = callback raises BadHeaderError when the value contains a newline, and the exception name is worth recognising in a traceback because it is what tells you the scanner's split payload did not land.
The callback is still unvalidated, and that matters beyond the header. A JSONP callback name is normally echoed into a script body, so it needs an identifier-shaped allowlist whatever the header layer does - and a percent-encoded newline reaches the header untouched by BadHeaderError.
Email Header Injection
# VULNERABLE - header block assembled as text, then handed to smtplib
import smtplib
def send_feedback(name, email, subject, message):
# VULNERABLE - user input concatenated straight into the header block
data = (
f"From: {email}\r\n"
f"To: admin@example.com\r\n"
f"Subject: {subject}\r\n"
f"X-Sender-Name: {name}\r\n"
"\r\n"
f"{message}"
)
smtp = smtplib.SMTP('localhost')
smtp.sendmail(email, ['admin@example.com'], data)
smtp.quit()
# Attack: subject = "Feedback\r\nBcc: victim@example.com"
# Attack: subject = "Feedback\r\n\r\nphishing body replaces the real one"
Why this is vulnerable: smtplib does not parse or validate what you hand it. sendmail() normalizes line endings, applies dot-stuffing and writes the string to the wire, so the injected Bcc: above arrives at the MTA as a real header line and the second payload closes the header block early and replaces the body - a message that carries your application's From and says whatever the attacker chose.
Note what the envelope does and does not do. Delivery is driven by the to_addrs argument, so an injected Bcc: does not by itself add an SMTP recipient; what it does is spoof visible headers, control the body, and add recipients on any relay or forwarding rule that acts on header fields. That is enough on its own, and it is a good example of why the fix is to stop building the header block by hand rather than to reason about which injected header is delivered.
The high-level API is what closes this. email.message.EmailMessage refuses a header value containing CR or LF outright - msg['Subject'] = "Feedback\r\nBcc: ..." raises ValueError: Header values may not contain linefeed or carriage return characters - so the same attack against an EmailMessage-built message fails at assignment. The legacy email.mime classes use the compat32 policy and accept the assignment, but still raise when the message is serialized. If you find this pattern, the remediation is to construct an EmailMessage and pass it to send_message(), not to add a strip in front of the f-string.
Log Injection
# VULNERABLE - Logging user input without sanitization
import logging
logger = logging.getLogger(__name__)
def process_login(username, password):
# VULNERABLE - User input in log message
logger.info(f"Login attempt for user: {username}")
if authenticate(username, password):
logger.info(f"Successful login: {username}")
return True
else:
logger.warning(f"Failed login for: {username}")
return False
# Attack: username = "admin\nINFO:root:Successful login: attacker\nINFO:root:Admin access granted"
# Creates fake log entries
Why this is vulnerable: logging with a text formatter is one of the sinks with no newline guard, so username reaches the record unchanged. A \n in it ends the line, and everything after it is written in the same format the real records use - the payload above forges a successful login and an admin grant for attacker. Whatever reads the file afterwards has no way to tell those lines from the ones the application wrote.
FastAPI Response Headers
# VULNERABLE - FastAPI with custom headers
from fastapi import FastAPI, Query, Response
app = FastAPI()
@app.get("/download")
async def download_file(filename: str = Query(...)):
content = "File content"
# VULNERABLE - User input in Content-Disposition header
response = Response(content=content, media_type="application/octet-stream")
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
return response
# Attack: ?filename=file.txt%0d%0aX-Injected:%20malicious
# Starlette accepts the value; uvicorn then refuses to write it and the
# connection is dropped with no response. Server-dependent, not a control
Why this is vulnerable: This is the one Python web sink that does not reject the value. Starlette stores the raw bytes, so response.headers["Content-Disposition"] accepts attachment; filename=file.txt\r\nX-Injected: evil without complaint, and the refusal comes later from the ASGI server: uvicorn raises LocalProtocolError under h11 and RuntimeError: Invalid HTTP header value. under httptools, and the client's connection is dropped with no response at all.
Treat that as a server-dependent backstop rather than a control. The rejection lives in whatever writes the response - swap the server, put the value on a path that is serialized differently, or hand the same string to a log line or an outbound request, and nothing checks it. The header is built by concatenation from a query parameter, which is the defect; a quote breaks out of the filename= parameter and a path component turns the download into a traversal even where the newline is caught.
CSV Export with User Data
# VULNERABLE - CSV rows assembled as text, so nothing quotes the value
from flask import Flask, Response, request
app = Flask(__name__)
@app.route('/export')
def export_csv():
users = [
{'name': request.args.get('name', 'User'), 'email': 'user@example.com'},
]
# VULNERABLE - the row is built by interpolation, so a newline in a value ends
# the record. The csv module would quote it; this hand-built export does not.
lines = ['name,email']
for user in users:
lines.append(f"{user['name']},{user['email']}")
return Response(
'\r\n'.join(lines) + '\r\n',
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=users.csv'}
)
# Attack: ?name=admin%0aadmin2,admin2@evil.com
# Injects additional CSV rows
Why this is vulnerable: A newline is a record separator in CSV, and nothing between the query parameter and the exported file checks for one. The attack above ends the row early and appends a second one, so the file the user downloads carries an admin2 account that no user record backs - measured, the export parses back as three rows rather than two. The flaw is the hand-built row rather than CSV export as such: csv.writer quotes a field containing a newline, which is why this example does not use it, and is also the fix.
HTTP Proxy Headers
# VULNERABLE - Proxy forwarding with user headers
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/proxy')
def proxy_request():
target_url = request.args.get('url', '')
# VULNERABLE - Forwarding user-controlled headers
headers = {
'X-Forwarded-For': request.headers.get('X-Forwarded-For', ''),
'X-Real-IP': request.headers.get('X-Real-IP', ''),
'X-Custom': request.headers.get('X-Custom', '')
}
response = requests.get(target_url, headers=headers)
return response.text
# Attack: X-Forwarded-For: 1.2.3.4%0d%0aX-Admin:%20true
# urllib3 raises InvalidHeader before the request is sent. Forwarding a
# client-set X-Forwarded-For at all is the weakness, and needs no newline
Why this is vulnerable: urllib3 validates outbound header values, so a raw CRLF in X-Forwarded-For raises InvalidHeader before the request is sent rather than injecting X-Admin: true into the backend request.
The forwarding itself is the weakness, and it survives the check. Every one of these values is set freely by the client, so the handler is copying attacker-controlled X-Forwarded-For and X-Real-IP into a request the backend is likely to trust for rate limiting, allowlisting or audit - IP spoofing that needs no newline at all. target_url is also unvalidated, which makes this an SSRF sink as well; see CWE-918. Set forwarding headers from the connection Flask actually observed, not from what the client sent.
Secure Patterns
Flask Redirect with Validation
# SECURE - Flask redirect: reject CRLF, then allowlist the destination host
from flask import Flask, request, redirect, abort
import re
from urllib.parse import urljoin, urlparse
app = Flask(__name__)
# The origin this application is served from, used to resolve relative targets
SELF_ORIGIN = 'https://example.com/'
ALLOWED_HOSTS = {'example.com', 'app.example.com'}
CRLF = re.compile(r'[\r\n]|%0[da]', re.IGNORECASE)
def redirect_target(url):
"""Return a validated absolute URL, or None to reject."""
# Reject, do not repair - a stripped value is a different URL
if not url or CRLF.search(url):
return None
# Resolving against our own origin puts relative and absolute targets
# through the same host check
absolute = urljoin(SELF_ORIGIN, url)
parsed = urlparse(absolute)
if parsed.scheme not in ('http', 'https'):
return None
if parsed.hostname not in ALLOWED_HOSTS:
return None
return absolute
@app.route('/redirect')
def secure_redirect():
# SECURE - validated target or 400, with no third outcome
target = redirect_target(request.args.get('url', ''))
if not target:
abort(400, "Invalid redirect URL")
return redirect(target)
if __name__ == '__main__':
app.run()
Why this works:
The host allowlist is what closes the open redirect, and it has to be in the code rather than in a comment. A scheme check alone accepts https://evil.example/login, and urlparse('//evil.example/path') returns an empty scheme, so a check that treats the empty scheme as "relative, therefore local" accepts a protocol-relative URL to any host. Both are the phishing link the redirect parameter exists to prevent, and neither contains a newline.
Resolving against SELF_ORIGIN is what makes the host check meaningful. urljoin() turns a relative /dashboard into https://example.com/dashboard, so relative and absolute targets pass through one check rather than two code paths, and it turns //evil.example/path into https://evil.example/path, where parsed.hostname names the host the browser would actually visit. Returning the resolved absolute rather than the raw input means the value redirected to is the value that was checked - which is also what neutralises the backslash forms, since /\evil.example resolves to a path on our own origin instead of to an authority.
Rejecting on CR/LF rather than stripping removes an ordering hazard as well as recording the attempt. A strip changes the string the later checks run against, so the sanitizer can manufacture a value that would have failed - see the Java page for the worked example, where removing a newline turns a passing local path into //evil.example. abort(400) has no such failure mode. The regex covers the single percent-encoded forms in either case, because %0D%0A is as valid as %0d%0a and a lowercase-only strip is the usual way this is written wrong; a double-encoded %250D%250A is deliberately left alone, since it is inert until two decodes and a filter that runs a fixed number of times can always be out-nested. Fix the double decode instead.
Custom Headers with Validation
# SECURE - Custom headers with CRLF removal
from flask import Flask, request, Response
import re
app = Flask(__name__)
def sanitize_header_value(value):
"""Remove CRLF and other control characters"""
if not value:
return ''
# Remove CRLF characters (including encoded versions)
clean = value.replace('\r', '').replace('\n', '')
clean = clean.replace('%0d', '').replace('%0a', '')
clean = clean.replace('%0D', '').replace('%0A', '')
# Remove other control characters
clean = re.sub(r'[\x00-\x1f\x7f]', '', clean)
# Limit length
return clean[:200]
def validate_username(username):
"""Validate username format"""
if not username:
return False
# fullmatch, not match: Python's `$` also matches before a trailing newline
return bool(re.fullmatch(r'[a-zA-Z0-9._-]{3,50}', username))
@app.route('/api/data')
def secure_headers():
username = request.args.get('username', '')
# SECURE - Validate input
if not validate_username(username):
return Response("Invalid username", status=400)
response = Response("User data")
# SECURE - Sanitize header value
clean_username = sanitize_header_value(username)
response.headers['X-User-Name'] = clean_username
return response
Why this works:
validate_username() is the control. It accepts letters, digits, dot, underscore and hyphen, three to fifty characters, and nothing else, so a value carrying CR, LF or a percent-encoded newline never reaches the header - the request gets a 400 instead. re.fullmatch() rather than re.match() against [a-zA-Z0-9._-]{3,50}$ is what makes that hold: Python's $ also matches immediately before a trailing newline, so admin\n passes the anchored match and fails fullmatch.
sanitize_header_value() runs after that and is defence in depth rather than the fix. It removes literal \r and \n, the percent-encoded %0d and %0a in either case, and the rest of the ASCII control range via [\x00-\x1f\x7f], then caps the result at 200 characters so a single header cannot grow without bound. Stripping is tolerable here only because the value already passed validation - on its own it hands the caller a value nobody checked, which is why the redirect and FastAPI examples on this page reject instead. The helper still earns its place for code paths that reuse it with no validator in front.
Django JSONP Callback with Validation
# SECURE - Django with proper header handling
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.http import require_GET
import re
def validate_callback(callback):
"""Validate JSONP callback name"""
# fullmatch, not match: Python's `$` also matches before a trailing newline
return bool(re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', callback))
@require_GET
def secure_view(request):
callback = request.GET.get('callback', '')
# SECURE - Validate callback format
if not validate_callback(callback):
return HttpResponseBadRequest("Invalid callback name")
data = '{"status": "success"}'
response = HttpResponse(data, content_type='application/json')
# SECURE - Use validated callback (no sanitization needed)
response['X-Callback'] = callback
return response
Why this works:
validate_callback() is the control. The regex accepts a JavaScript identifier - a letter or underscore, then letters, digits and underscores - which leaves no room for CR, LF or anything else that could start a second header line. re.fullmatch() rather than an anchored re.match() is deliberate for the same reason as elsewhere on this page: Python's $ matches before a trailing newline, so a $-anchored match would accept myCallback\n.
Rejecting with HttpResponseBadRequest rather than repairing the value is the other half. A sanitizer that silently returns a different callback name serves the request under a name the caller never asked for; a 400 says which of the two happened and leaves the attempt in the access log.
Because the callback passed that check, response['X-Callback'] = callback needs nothing further, which is what the "no sanitization needed" comment records. Where a header value has no equivalent validator in front of it, the sanitize_header_value() helper in the Custom Headers example above is the fallback. Django's BadHeaderError sits behind both as a backstop for literal newlines, not as a substitute for validating the input.
Email with Header Validation
# SECURE - Email with header sanitization
import smtplib
from email.message import EmailMessage
from email.utils import parseaddr
import re
def sanitize_email_header(value):
"""Remove CRLF from email headers"""
if not value:
return ''
# Remove CRLF and control characters
return re.sub(r'[\r\n\x00-\x1f\x7f]', '', value)
def validate_email(email):
"""Validate email format"""
if not email or len(email) > 254:
return False
name, addr = parseaddr(email)
if not addr:
return False
# Additional validation
# fullmatch, not match: Python's `$` also matches before a trailing newline
return bool(re.fullmatch(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', addr))
def send_feedback_secure(name, email, subject, message):
"""Send email with sanitized headers"""
# SECURE - Validate inputs
if not validate_email(email):
raise ValueError("Invalid email address")
if len(subject) > 200 or len(message) > 5000:
raise ValueError("Content too long")
# SECURE - Sanitize all header values
clean_email = sanitize_email_header(email)
clean_subject = sanitize_email_header(subject)
clean_name = sanitize_email_header(name)
msg = EmailMessage()
msg['From'] = clean_email
msg['To'] = 'admin@example.com'
msg['Subject'] = clean_subject
msg.set_content(message)
msg['X-Sender-Name'] = clean_name
smtp = smtplib.SMTP('localhost')
smtp.send_message(msg)
smtp.quit()
Why this works:
validate_email() does the structural work. parseaddr() splits the value the way the email RFCs define, the 254-character limit is RFC 5321's maximum address length, and the regex then requires what came out to be an ordinary local@domain. fullmatch rather than a $-anchored match matters for the same reason as elsewhere on this page - Python's $ also matches before a trailing newline.
sanitize_email_header() removes [\r\n\x00-\x1f\x7f] from every value that becomes a header: From, Subject, and the custom X-Sender-Name. CRLF is what SMTP uses to separate one header from the next and the header block from the body, so a newline reaching a subject line is what lets an attacker add a visible Bcc: or end the block early and replace the message body.
EmailMessage is doing most of the work here, and it is worth being clear about which part. Under the default policy it refuses a header value containing CR or LF - the assignment raises ValueError - so the class is fail-closed and the explicit sanitize_email_header() calls are defence in depth, not the control that closes the finding. They earn their place by turning a 500 into a rejected input with a message, and by covering the case where the same helper is reused on a path that does not end at an EmailMessage. What the class does not do is validate that the address is sane or bound the field lengths, which is why validate_email() and the 200/5000-character limits are still needed.
Secure Logging
# SECURE - Logging with sanitization
import logging
import re
logger = logging.getLogger(__name__)
def sanitize_log_input(value):
"""Remove CRLF and control characters for logging"""
if not value:
return ''
# Remove newlines and control characters
clean = re.sub(r'[\r\n\x00-\x1f\x7f]', ' ', value)
# Limit length
return clean[:200]
def validate_username(username):
"""Validate username format"""
# fullmatch, not match: Python's `$` also matches before a trailing newline
return bool(re.fullmatch(r'[a-zA-Z0-9._-]{3,50}', username))
def process_login_secure(username, password):
"""Process login with secure logging"""
# SECURE - Validate username
if not validate_username(username):
logger.warning("Invalid username format in login attempt")
return False
# SECURE - Sanitize for logging
clean_username = sanitize_log_input(username)
logger.info(f"Login attempt for user: {clean_username}")
if authenticate(username, password):
logger.info(f"Successful login: {clean_username}")
return True
else:
logger.warning(f"Failed login for: {clean_username}")
return False
def authenticate(username, password):
# Authentication logic
return True
Why this works:
sanitize_log_input() replaces every newline and ASCII control character in the value with a space, so a username like admin\nINFO: User hacker performed GRANT ADMIN stays on one line instead of becoming a second record that reads as genuine. The regex [\r\n\x00-\x1f\x7f] is deliberately wider than CRLF: a terminal escape sequence in a log file is read back by tools that act on it, and a tab can break a parser expecting fixed fields. Replacing rather than deleting keeps the entry legible - whoever reviews it can still see what was submitted, it just cannot break the record structure. The 200-character cap bounds how much of it lands in the file.
validate_username() runs first and rejects anything outside [a-zA-Z0-9._-]{3,50}, so on this path the sanitizer usually has nothing left to do; it is there for the values that reach a log with no validator in front of them. re.fullmatch() is again why a trailing newline cannot slip through an otherwise anchored pattern. The rejection is logged as a fixed string with no user data in it, so a malformed username cannot forge a record on its way to being refused.
FastAPI with Pydantic Validation
# SECURE - FastAPI with a constrained query parameter
from typing import Annotated
from fastapi import FastAPI, Query, Response
app = FastAPI()
# Anchored allowlist: no CR/LF, no control characters, no path separators
FILENAME = r'^[a-zA-Z0-9._-]{1,100}\.[a-zA-Z0-9]{1,10}$'
@app.get("/download")
async def download_file(filename: Annotated[str, Query(pattern=FILENAME)]):
content = "File content"
# SECURE - filename matched the pattern or the request never got here
response = Response(content=content, media_type="application/octet-stream")
response.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
Why this works:
Query(pattern=...) puts the check in the route signature, so FastAPI runs it before the handler body executes and returns 422 for anything that does not match. Nothing reaches the Content-Disposition header unless it is a name made of letters, digits, dot, underscore and hyphen with a short extension - and because the pattern is anchored at both ends, a value that merely starts legitimately does not pass. That is the property doing the work: an unanchored re.search-style check is the standard way this validation is written wrong, and it accepts report.pdf followed by a newline and an injected header.
The same anchored pattern closes several weaknesses at once. / and \ are outside the character class, so the value cannot address a parent directory (see CWE-22) - note that it is the separators doing that rather than the dot, since ..txt and foo..txt both match and are harmless without one. CR, LF and every other control character are outside the class too, so the value cannot become a second header line, and the {1,100} bound keeps the header a sane length without a separate check. The value is also quoted in the header, so a space or a semicolon admitted by a future revision of the pattern cannot break the parameter apart.
Validate rather than clean. This example has no re.sub() step, and that is the point: stripping produces a different filename and then uses it, which quietly serves the wrong file when the input was legitimate and hides the attempt when it was not. Rejecting says which of the two happened. If the download set is known in advance, go one better and map an opaque id to a server-side filename so the query parameter never reaches the header at all.
Common Pitfalls
- Using
re.sub(r'[\r\n]', '', value)on a value read from a raw source that bypasses the framework's normal decoding (for example,request.environ['QUERY_STRING']instead ofrequest.args) - percent-encoded CRLF (%0d,%0a) passes through untouched and gets decoded later by something else. - Calling
urllib.parse.quote()- which percent-encodes a value for use inside a URL - and treating that as sufficient for a value placed in a raw, hand-built header string; some non-compliant clients or intermediate proxies still normalize%0d%0aback to CRLF before the header reaches them. - Validating a redirect target with
urlparse()but checking onlyscheme- a scheme-only check (http/https) doesn't stop a value that has a valid scheme paired with a raw CRLF embedded in the path or fragment.