Merge pull request #3260 from benoitc/fix-te

don't tolerate wrong te headers
This commit is contained in:
Benoit Chesneau 2024-08-07 00:15:56 +02:00 committed by GitHub
commit ff2109e759
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 54 additions and 176 deletions

View File

@ -2369,30 +2369,3 @@ class HeaderMap(Setting):
.. versionadded:: 22.0.0 .. versionadded:: 22.0.0
""" """
class TolerateDangerousFraming(Setting):
name = "tolerate_dangerous_framing"
section = "Server Mechanics"
cli = ["--tolerate-dangerous-framing"]
validator = validate_bool
action = "store_true"
default = False
desc = """\
Process requests with both Transfer-Encoding and Content-Length
This is known to induce vulnerabilities, but not strictly forbidden by RFC9112.
In any case, the connection is closed after the malformed request,
as it is unclear if and at which boundary additional requests start.
Use with care and only if necessary.
Temporary; will be changed or removed in a future version.
.. versionadded:: 22.0.0
.. versionchanged: 22.1.0
The newly added rejection of invalid and dangerous characters CR, LF and NUL in
header field values is also controlled with this setting. rfc9110 permits both
rejecting and SP-replacing. With this option set, Gunicorn passes the field value
unchanged. With this option unset, Gunicorn rejects the request.
"""

View File

@ -123,10 +123,7 @@ class Message(object):
value = " ".join(value) value = " ".join(value)
if RFC9110_5_5_INVALID_AND_DANGEROUS.search(value): if RFC9110_5_5_INVALID_AND_DANGEROUS.search(value):
if not self.cfg.tolerate_dangerous_framing:
raise InvalidHeader(name) raise InvalidHeader(name)
# value = RFC9110_5_5_INVALID_AND_DANGEROUS.sub(" ", value)
self.force_close()
if header_length > self.limit_request_field_size > 0: if header_length > self.limit_request_field_size > 0:
raise LimitRequestHeaders("limit request headers fields size") raise LimitRequestHeaders("limit request headers fields size")
@ -172,30 +169,26 @@ class Message(object):
raise InvalidHeader("CONTENT-LENGTH", req=self) raise InvalidHeader("CONTENT-LENGTH", req=self)
content_length = value content_length = value
elif name == "TRANSFER-ENCODING": elif name == "TRANSFER-ENCODING":
if value.lower() == "chunked": # T-E can be a list
# https://datatracker.ietf.org/doc/html/rfc9112#name-transfer-encoding
vals = [v.strip() for v in value.split(',')]
for val in vals:
if val.lower() == "chunked":
# DANGER: transfer codings stack, and stacked chunking is never intended # DANGER: transfer codings stack, and stacked chunking is never intended
if chunked: if chunked:
raise InvalidHeader("TRANSFER-ENCODING", req=self) raise InvalidHeader("TRANSFER-ENCODING", req=self)
chunked = True chunked = True
elif value.lower() == "identity": elif val.lower() == "identity":
# does not do much, could still plausibly desync from what the proxy does # does not do much, could still plausibly desync from what the proxy does
# safe option: nuke it, its never needed # safe option: nuke it, its never needed
if chunked: if chunked:
raise InvalidHeader("TRANSFER-ENCODING", req=self) raise InvalidHeader("TRANSFER-ENCODING", req=self)
elif value.lower() == "": elif val.lower() in ('compress', 'deflate', 'gzip'):
# lacking security review on this case # chunked should be the last one
# offer the option to restore previous behaviour, but refuse by default, for now if chunked:
raise InvalidHeader("TRANSFER-ENCODING", req=self)
self.force_close() self.force_close()
if not self.cfg.tolerate_dangerous_framing:
raise UnsupportedTransferCoding(value)
# DANGER: do not change lightly; ref: request smuggling
# T-E is a list and we *could* support correctly parsing its elements
# .. but that is only safe after getting all the edge cases right
# .. for which no real-world need exists, so best to NOT open that can of worms
else: else:
self.force_close()
# even if parser is extended, retain this branch:
# the "chunked not last" case remains to be rejected!
raise UnsupportedTransferCoding(value) raise UnsupportedTransferCoding(value)
if chunked: if chunked:
@ -204,15 +197,10 @@ class Message(object):
# b) chunked HTTP/1.0 (always faulty) # b) chunked HTTP/1.0 (always faulty)
if self.version < (1, 1): if self.version < (1, 1):
# framing wonky, see RFC 9112 Section 6.1 # framing wonky, see RFC 9112 Section 6.1
self.force_close()
if not self.cfg.tolerate_dangerous_framing:
raise InvalidHeader("TRANSFER-ENCODING", req=self) raise InvalidHeader("TRANSFER-ENCODING", req=self)
if content_length is not None: if content_length is not None:
# we cannot be certain the message framing we understood matches proxy intent # we cannot be certain the message framing we understood matches proxy intent
# -> whatever happens next, remaining input must not be trusted # -> whatever happens next, remaining input must not be trusted
self.force_close()
# either processing or rejecting is permitted in RFC 9112 Section 6.1
if not self.cfg.tolerate_dangerous_framing:
raise InvalidHeader("CONTENT-LENGTH", req=self) raise InvalidHeader("CONTENT-LENGTH", req=self)
self.body = Body(ChunkedReader(self, self.unreader)) self.body = Body(ChunkedReader(self, self.unreader))
elif content_length is not None: elif content_length is not None:

View File

@ -1,2 +1,2 @@
from gunicorn.http.errors import UnsupportedTransferCoding from gunicorn.http.errors import InvalidHeader
request = UnsupportedTransferCoding request = InvalidHeader

View File

@ -1,2 +1,2 @@
from gunicorn.http.errors import UnsupportedTransferCoding from gunicorn.http.errors import InvalidHeader
request = UnsupportedTransferCoding request = InvalidHeader

View File

@ -1,24 +1,10 @@
POST /chunked_cont_h_at_first HTTP/1.1\r\n POST /chunked HTTP/1.1\r\n
Transfer-Encoding: gzip\r\n
Transfer-Encoding: chunked\r\n Transfer-Encoding: chunked\r\n
\r\n \r\n
5; some; parameters=stuff\r\n 5\r\n
hello\r\n hello\r\n
6 \t;\tblahblah; blah\r\n 6\r\n
world\r\n world\r\n
0\r\n 0\r\n
\r\n \r\n
PUT /chunked_cont_h_at_last HTTP/1.1\r\n
Transfer-Encoding: chunked\r\n
Content-Length: -1\r\n
\r\n
5; some; parameters=stuff\r\n
hello\r\n
6; blahblah; blah\r\n
world\r\n
0\r\n
\r\n
PUT /ignored_after_dangerous_framing HTTP/1.1\r\n
Content-Length: 3\r\n
\r\n
foo\r\n
\r\n

View File

@ -1,27 +1,10 @@
from gunicorn.config import Config request = {
cfg = Config()
cfg.set("tolerate_dangerous_framing", True)
req1 = {
"method": "POST", "method": "POST",
"uri": uri("/chunked_cont_h_at_first"), "uri": uri("/chunked"),
"version": (1, 1), "version": (1, 1),
"headers": [ "headers": [
("TRANSFER-ENCODING", "chunked") ('TRANSFER-ENCODING', 'gzip'),
('TRANSFER-ENCODING', 'chunked')
], ],
"body": b"hello world" "body": b"hello world"
} }
req2 = {
"method": "PUT",
"uri": uri("/chunked_cont_h_at_last"),
"version": (1, 1),
"headers": [
("TRANSFER-ENCODING", "chunked"),
("CONTENT-LENGTH", "-1"),
],
"body": b"hello world"
}
request = [req1, req2]

View File

@ -0,0 +1,9 @@
POST /chunked HTTP/1.1\r\n
Transfer-Encoding: gzip,chunked\r\n
\r\n
5\r\n
hello\r\n
6\r\n
world\r\n
0\r\n
\r\n

View File

@ -0,0 +1,10 @@
request = {
"method": "POST",
"uri": uri("/chunked"),
"version": (1, 1),
"headers": [
('TRANSFER-ENCODING', 'gzip,chunked')
],
"body": b"hello world"
}

View File

@ -1,18 +0,0 @@
POST /chunked_cont_h_at_first HTTP/1.1\r\n
Transfer-Encoding: chunked\r\n
\r\n
5; some; parameters=stuff\r\n
hello\r\n
6; blahblah; blah\r\n
world\r\n
0\r\n
\r\n
PUT /chunked_cont_h_at_last HTTP/1.1\r\n
Transfer-Encoding: chunked\r\n
Content-Length: -1\r\n
\r\n
5; some; parameters=stuff\r\n
hello\r\n
6; blahblah; blah\r\n
world\r\n
0\r\n

View File

@ -1,27 +0,0 @@
from gunicorn.config import Config
cfg = Config()
cfg.set("tolerate_dangerous_framing", True)
req1 = {
"method": "POST",
"uri": uri("/chunked_cont_h_at_first"),
"version": (1, 1),
"headers": [
("TRANSFER-ENCODING", "chunked")
],
"body": b"hello world"
}
req2 = {
"method": "PUT",
"uri": uri("/chunked_cont_h_at_last"),
"version": (1, 1),
"headers": [
("TRANSFER-ENCODING", "chunked"),
("CONTENT-LENGTH", "-1"),
],
"body": b"hello world"
}
request = [req1, req2]

View File

@ -1,8 +0,0 @@
GET / HTTP/1.1\r\n
Host: x\r\n
Newline: a\n
Content-Length: 26\r\n
X-Forwarded-By: broken-proxy\r\n\r\n
GET / HTTP/1.1\n
Host: x\r\n
\r\n

View File

@ -1,18 +0,0 @@
from gunicorn.config import Config
cfg = Config()
cfg.set("tolerate_dangerous_framing", True)
req1 = {
"method": "GET",
"uri": uri("/"),
"version": (1, 1),
"headers": [
("HOST", "x"),
("NEWLINE", "a\nContent-Length: 26"),
("X-FORWARDED-BY", "broken-proxy"),
],
"body": b""
}
request = [req1]