Skip to content

Commit 6d53fd6

Browse files
committed
gh-142533: Prevent CRLF injection in HTTP headers
Reject CR/LF in header names/values in `http.server` and `wsgiref.headers` to prevent CRLF injection attacks.
1 parent dac4589 commit 6d53fd6

File tree

5 files changed

+85
-14
lines changed

5 files changed

+85
-14
lines changed

Lib/http/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@
112112

113113
DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8"
114114

115+
def _validate_header_string(value):
116+
"""Validate header values preventing CRLF injection."""
117+
if '\r' in value or '\n' in value:
118+
raise ValueError('Invalid header name/value: contains CR or LF')
119+
115120
class HTTPServer(socketserver.TCPServer):
116121

117122
allow_reuse_address = True # Seems to make sense in testing environment
@@ -553,6 +558,8 @@ def send_response_only(self, code, message=None):
553558

554559
def send_header(self, keyword, value):
555560
"""Send a MIME header to the headers buffer."""
561+
_validate_header_string(keyword)
562+
_validate_header_string(value)
556563
if self.request_version != 'HTTP/0.9':
557564
if not hasattr(self, '_headers_buffer'):
558565
self._headers_buffer = []

Lib/test/test_httpservers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,32 @@ def test_header_buffering_of_send_header(self):
10681068
self.assertEqual(output.getData(), b'Foo: foo\r\nbar: bar\r\n\r\n')
10691069
self.assertEqual(output.numWrites, 1)
10701070

1071+
def test_crlf_injection_in_header_value(self):
1072+
input = BytesIO(b'GET / HTTP/1.1\r\n\r\n')
1073+
output = AuditableBytesIO()
1074+
handler = SocketlessRequestHandler()
1075+
handler.rfile = input
1076+
handler.wfile = output
1077+
handler.request_version = 'HTTP/1.1'
1078+
1079+
with self.assertRaises(ValueError) as ctx:
1080+
handler.send_header('X-Custom', 'value\r\nSet-Cookie: custom=true')
1081+
self.assertIn('Invalid header name/value: contains CR or LF',
1082+
str(ctx.exception))
1083+
1084+
def test_crlf_injection_in_header_name(self):
1085+
input = BytesIO(b'GET / HTTP/1.1\r\n\r\n')
1086+
output = AuditableBytesIO()
1087+
handler = SocketlessRequestHandler()
1088+
handler.rfile = input
1089+
handler.wfile = output
1090+
handler.request_version = 'HTTP/1.1'
1091+
1092+
with self.assertRaises(ValueError) as ctx:
1093+
handler.send_header('X-Inj\r\nSet-Cookie', 'value')
1094+
self.assertIn('Invalid header name/value: contains CR or LF',
1095+
str(ctx.exception))
1096+
10711097
def test_header_unbuffered_when_continue(self):
10721098

10731099
def _readAndReseek(f):

Lib/test/test_wsgiref.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,39 @@ def testExtras(self):
503503
'\r\n'
504504
)
505505

506+
def test_crlf_rejection_in_setitem(self):
507+
h = Headers()
508+
for crlf in ('\r\n', '\r', '\n'):
509+
with self.subTest(crlf_repr=repr(crlf)):
510+
with self.assertRaises(ValueError) as ctx:
511+
h['X-Custom'] = f'value{crlf}Set-Cookie: test'
512+
self.assertIn('CR or LF', str(ctx.exception))
513+
514+
def test_crlf_rejection_in_setdefault(self):
515+
for crlf in ('\r\n', '\r', '\n'):
516+
with self.subTest(crlf_repr=repr(crlf)):
517+
h = Headers()
518+
with self.assertRaises(ValueError) as ctx:
519+
h.setdefault('X-Custom', f'value{crlf}Set-Cookie: test')
520+
self.assertIn('CR or LF', str(ctx.exception))
521+
522+
def test_crlf_rejection_in_add_header(self):
523+
for crlf in ('\r\n', '\r', '\n'):
524+
with self.subTest(location='value', crlf_repr=repr(crlf)):
525+
h = Headers()
526+
with self.assertRaises(ValueError) as ctx:
527+
h.add_header('X-Custom', f'value{crlf}Set-Cookie: test')
528+
self.assertIn('CR or LF', str(ctx.exception))
529+
530+
with self.subTest(location='param', crlf_repr=repr(crlf)):
531+
h = Headers()
532+
with self.assertRaises(ValueError) as ctx:
533+
h.add_header('Content-Disposition',
534+
'attachment',
535+
filename=f'test{crlf}.txt')
536+
self.assertIn('CR or LF', str(ctx.exception))
537+
538+
506539
class ErrorHandler(BaseCGIHandler):
507540
"""Simple handler subclass for testing BaseHandler"""
508541

Lib/wsgiref/headers.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ def __init__(self, headers=None):
3535
self._headers = headers
3636
if __debug__:
3737
for k, v in headers:
38-
self._convert_string_type(k)
39-
self._convert_string_type(v)
38+
self._validate_header_string(k)
39+
self._validate_header_string(v)
4040

41-
def _convert_string_type(self, value):
42-
"""Convert/check value type."""
41+
def _validate_header_string(self, value):
42+
"""Validate header type and value."""
4343
if type(value) is str:
44+
if '\r' in value or '\n' in value:
45+
raise ValueError('Invalid header name/value: contains CR or LF')
4446
return value
4547
raise AssertionError("Header names/values must be"
4648
" of type str (got {0})".format(repr(value)))
@@ -53,14 +55,15 @@ def __setitem__(self, name, val):
5355
"""Set the value of a header."""
5456
del self[name]
5557
self._headers.append(
56-
(self._convert_string_type(name), self._convert_string_type(val)))
58+
(self._validate_header_string(name),
59+
self._validate_header_string(val)))
5760

5861
def __delitem__(self,name):
5962
"""Delete all occurrences of a header, if present.
6063
6164
Does *not* raise an exception if the header is missing.
6265
"""
63-
name = self._convert_string_type(name.lower())
66+
name = self._validate_header_string(name.lower())
6467
self._headers[:] = [kv for kv in self._headers if kv[0].lower() != name]
6568

6669
def __getitem__(self,name):
@@ -87,13 +90,13 @@ def get_all(self, name):
8790
fields deleted and re-inserted are always appended to the header list.
8891
If no fields exist with the given name, returns an empty list.
8992
"""
90-
name = self._convert_string_type(name.lower())
93+
name = self._validate_header_string(name.lower())
9194
return [kv[1] for kv in self._headers if kv[0].lower()==name]
9295

9396

9497
def get(self,name,default=None):
9598
"""Get the first header value for 'name', or return 'default'"""
96-
name = self._convert_string_type(name.lower())
99+
name = self._validate_header_string(name.lower())
97100
for k,v in self._headers:
98101
if k.lower()==name:
99102
return v
@@ -148,8 +151,8 @@ def setdefault(self,name,value):
148151
and value 'value'."""
149152
result = self.get(name)
150153
if result is None:
151-
self._headers.append((self._convert_string_type(name),
152-
self._convert_string_type(value)))
154+
self._headers.append((self._validate_header_string(name),
155+
self._validate_header_string(value)))
153156
return value
154157
else:
155158
return result
@@ -172,13 +175,13 @@ def add_header(self, _name, _value, **_params):
172175
"""
173176
parts = []
174177
if _value is not None:
175-
_value = self._convert_string_type(_value)
178+
_value = self._validate_header_string(_value)
176179
parts.append(_value)
177180
for k, v in _params.items():
178-
k = self._convert_string_type(k)
181+
k = self._validate_header_string(k)
179182
if v is None:
180183
parts.append(k.replace('_', '-'))
181184
else:
182-
v = self._convert_string_type(v)
185+
v = self._validate_header_string(v)
183186
parts.append(_formatparam(k.replace('_', '-'), v))
184-
self._headers.append((self._convert_string_type(_name), "; ".join(parts)))
187+
self._headers.append((self._validate_header_string(_name), "; ".join(parts)))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Reject CR/LF in HTTP headers in `http.server` and `wsgiref.headers` to prevent
2+
CRLF injection attacks.

0 commit comments

Comments
 (0)