Skip to content

Commit ac29c9b

Browse files
committed
fail-safe on unsupported request framing
If we promise wsgi.input_terminated, we better get it right - or not at all. * chunked encoding on HTTP <= 1.1 * chunked not last transfer coding * multiple chinked codings * any unknown codings (yes, this too! because we do not detect unusual syntax that is still chunked) * empty coding (plausibly harmless, but not see in real life anyway - refused, for the moment)
1 parent 0b10cba commit ac29c9b

40 files changed

+281
-6
lines changed

gunicorn/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2344,3 +2344,21 @@ class HeaderMap(Setting):
23442344
23452345
.. versionadded:: 22.0.0
23462346
"""
2347+
2348+
2349+
class TolerateDangerousFraming(Setting):
2350+
name = "tolerate_dangerous_framing"
2351+
section = "Server Mechanics"
2352+
cli = ["--tolerate-dangerous-framing"]
2353+
validator = validate_bool
2354+
action = "store_true"
2355+
default = False
2356+
desc = """\
2357+
Process requests with both Transfer-Encoding and Content-Length
2358+
2359+
This is known to induce vulnerabilities, but not strictly forbidden by RFC9112.
2360+
2361+
Use with care and only if necessary. May be removed in a future version.
2362+
2363+
.. versionadded:: 22.0.0
2364+
"""

gunicorn/http/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ def __str__(self):
7373
return "Invalid HTTP header name: %r" % self.hdr
7474

7575

76+
class UnsupportedTransferCoding(ParseException):
77+
def __init__(self, hdr):
78+
self.hdr = hdr
79+
self.code = 501
80+
81+
def __str__(self):
82+
return "Unsupported transfer coding: %r" % self.hdr
83+
84+
7685
class InvalidChunkSize(IOError):
7786
def __init__(self, data):
7887
self.data = data

gunicorn/http/message.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
InvalidHeader, InvalidHeaderName, NoMoreData,
1313
InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion,
1414
LimitRequestLine, LimitRequestHeaders,
15+
UnsupportedTransferCoding,
1516
)
1617
from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest
1718
from gunicorn.http.errors import InvalidSchemeHeaders
@@ -39,6 +40,7 @@ def __init__(self, cfg, unreader, peer_addr):
3940
self.trailers = []
4041
self.body = None
4142
self.scheme = "https" if cfg.is_ssl else "http"
43+
self.must_close = False
4244

4345
# set headers limits
4446
self.limit_request_fields = cfg.limit_request_fields
@@ -58,6 +60,9 @@ def __init__(self, cfg, unreader, peer_addr):
5860
self.unreader.unread(unused)
5961
self.set_body_reader()
6062

63+
def force_close(self):
64+
self.must_close = True
65+
6166
def parse(self, unreader):
6267
raise NotImplementedError()
6368

@@ -152,9 +157,47 @@ def set_body_reader(self):
152157
content_length = value
153158
elif name == "TRANSFER-ENCODING":
154159
if value.lower() == "chunked":
160+
# DANGER: transer codings stack, and stacked chunking is never intended
161+
if chunked:
162+
raise InvalidHeader("TRANSFER-ENCODING", req=self)
155163
chunked = True
164+
elif value.lower() == "identity":
165+
# does not do much, could still plausibly desync from what the proxy does
166+
# safe option: nuke it, its never needed
167+
if chunked:
168+
raise InvalidHeader("TRANSFER-ENCODING", req=self)
169+
elif value.lower() == "":
170+
# lacking security review on this case
171+
# offer the option to restore previous behaviour, but refuse by default, for now
172+
self.force_close()
173+
if not self.cfg.tolerate_dangerous_framing:
174+
raise UnsupportedTransferCoding(value)
175+
# DANGER: do not change lightly; ref: request smuggling
176+
# T-E is a list and we *could* support correctly parsing its elements
177+
# .. but that is only safe after getting all the edge cases right
178+
# .. for which no real-world need exists, so best to NOT open that can of worms
179+
else:
180+
self.force_close()
181+
# even if parser is extended, retain this branch:
182+
# the "chunked not last" case remains to be rejected!
183+
raise UnsupportedTransferCoding(value)
156184

157185
if chunked:
186+
# two potentially dangerous cases:
187+
# a) CL + TE (TE overrides CL.. only safe if the recipient sees it that way too)
188+
# b) chunked HTTP/1.0 (always faulty)
189+
if self.version < (1, 1):
190+
# framing wonky, see RFC 9112 Section 6.1
191+
self.force_close()
192+
if not self.cfg.tolerate_dangerous_framing:
193+
raise InvalidHeader("TRANSFER-ENCODING", req=self)
194+
if content_length is not None:
195+
# we cannot be certain the message framing we understood matches proxy intent
196+
# -> whatever happens next, remaining input must not be trusted
197+
self.force_close()
198+
# either processing or rejecting is permitted in RFC 9112 Section 6.1
199+
if not self.cfg.tolerate_dangerous_framing:
200+
raise InvalidHeader("CONTENT-LENGTH", req=self)
158201
self.body = Body(ChunkedReader(self, self.unreader))
159202
elif content_length is not None:
160203
try:
@@ -173,6 +216,8 @@ def set_body_reader(self):
173216
self.body = Body(EOFReader(self.unreader))
174217

175218
def should_close(self):
219+
if self.must_close:
220+
return True
176221
for (h, v) in self.headers:
177222
if h == "CONNECTION":
178223
v = v.lower().strip(" \t")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
POST /chunked_w_underscore_chunk_size HTTP/1.1\r\n
2+
Transfer-Encoding: chunked\r\n
3+
\r\n
4+
5\r\n
5+
hello\r\n
6+
6_0\r\n
7+
world\r\n
8+
0\r\n
9+
\r\n
10+
POST /after HTTP/1.1\r\n
11+
Transfer-Encoding: identity\r\n
12+
\r\n

tests/requests/invalid/chunked_01.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidChunkSize
2+
request = InvalidChunkSize
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
POST /chunked_with_prefixed_value HTTP/1.1\r\n
2+
Content-Length: 12\r\n
3+
Transfer-Encoding: \tchunked\r\n
4+
\r\n
5+
5\r\n
6+
hello\r\n
7+
6\r\n
8+
world\r\n
9+
\r\n

tests/requests/invalid/chunked_02.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidHeader
2+
request = InvalidHeader
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
POST /double_chunked HTTP/1.1\r\n
2+
Transfer-Encoding: identity, chunked, identity, chunked\r\n
3+
\r\n
4+
5\r\n
5+
hello\r\n
6+
6\r\n
7+
world\r\n
8+
\r\n

tests/requests/invalid/chunked_03.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import UnsupportedTransferCoding
2+
request = UnsupportedTransferCoding
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
POST /chunked_twice HTTP/1.1\r\n
2+
Transfer-Encoding: identity\r\n
3+
Transfer-Encoding: chunked\r\n
4+
Transfer-Encoding: identity\r\n
5+
Transfer-Encoding: chunked\r\n
6+
\r\n
7+
5\r\n
8+
hello\r\n
9+
6\r\n
10+
world\r\n
11+
\r\n

tests/requests/invalid/chunked_04.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidHeader
2+
request = InvalidHeader
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
POST /chunked_HTTP_1.0 HTTP/1.0\r\n
2+
Transfer-Encoding: chunked\r\n
3+
\r\n
4+
5\r\n
5+
hello\r\n
6+
6\r\n
7+
world\r\n
8+
0\r\n
9+
Vary: *\r\n
10+
Content-Type: text/plain\r\n
11+
\r\n

tests/requests/invalid/chunked_05.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidHeader
2+
request = InvalidHeader
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
POST /chunked_not_last HTTP/1.1\r\n
2+
Transfer-Encoding: chunked\r\n
3+
Transfer-Encoding: gzip\r\n
4+
\r\n
5+
5\r\n
6+
hello\r\n
7+
6\r\n
8+
world\r\n
9+
\r\n

tests/requests/invalid/chunked_06.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import UnsupportedTransferCoding
2+
request = UnsupportedTransferCoding
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
POST /chunked_not_last HTTP/1.1\r\n
2+
Transfer-Encoding: chunked\r\n
3+
Transfer-Encoding: identity\r\n
4+
\r\n
5+
5\r\n
6+
hello\r\n
7+
6\r\n
8+
world\r\n
9+
\r\n

tests/requests/invalid/chunked_08.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidHeader
2+
request = InvalidHeader
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GETß /germans.. HTTP/1.1\r\n
2+
Content-Length: 3\r\n
3+
\r\n
4+
ÄÄÄ

tests/requests/invalid/nonascii_01.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidRequestMethod
3+
4+
cfg = Config()
5+
request = InvalidRequestMethod
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GETÿ /french.. HTTP/1.1\r\n
2+
Content-Length: 3\r\n
3+
\r\n
4+
ÄÄÄ

tests/requests/invalid/nonascii_02.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidRequestMethod
3+
4+
cfg = Config()
5+
request = InvalidRequestMethod
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
GET /french.. HTTP/1.1\r\n
2+
Content-Lengthÿ: 3\r\n
3+
Content-Length: 3\r\n
4+
\r\n
5+
ÄÄÄ

tests/requests/invalid/nonascii_04.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidHeaderName
3+
4+
cfg = Config()
5+
request = InvalidHeaderName

tests/requests/invalid/prefix_01.http

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
GET\0PROXY /foo HTTP/1.1\r\n
2+
\r\n

tests/requests/invalid/prefix_01.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidRequestMethod
2+
request = InvalidRequestMethod

tests/requests/invalid/prefix_02.http

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
GET\0 /foo HTTP/1.1\r\n
2+
\r\n

tests/requests/invalid/prefix_02.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from gunicorn.http.errors import InvalidRequestMethod
2+
request = InvalidRequestMethod

tests/requests/invalid/prefix_03.http

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GET /stuff/here?foo=bar HTTP/1.1\r\n
2+
Content-Length: 0 1\r\n
3+
\r\n
4+
x

tests/requests/invalid/prefix_03.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidHeader
3+
4+
cfg = Config()
5+
request = InvalidHeader

tests/requests/invalid/prefix_04.http

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
GET /stuff/here?foo=bar HTTP/1.1\r\n
2+
Content-Length: 3 1\r\n
3+
\r\n
4+
xyz
5+
abc123

tests/requests/invalid/prefix_04.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidHeader
3+
4+
cfg = Config()
5+
request = InvalidHeader

tests/requests/invalid/prefix_05.http

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GET: /stuff/here?foo=bar HTTP/1.1\r\n
2+
Content-Length: 3\r\n
3+
\r\n
4+
xyz

tests/requests/invalid/prefix_05.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from gunicorn.config import Config
2+
from gunicorn.http.errors import InvalidRequestMethod
3+
4+
cfg = Config()
5+
request = InvalidRequestMethod

tests/requests/valid/025.http

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
POST /chunked_cont_h_at_first HTTP/1.1\r\n
2-
Content-Length: -1\r\n
32
Transfer-Encoding: chunked\r\n
43
\r\n
54
5; some; parameters=stuff\r\n
@@ -16,4 +15,10 @@ Content-Length: -1\r\n
1615
hello\r\n
1716
6; blahblah; blah\r\n
1817
world\r\n
19-
0\r\n
18+
0\r\n
19+
\r\n
20+
PUT /ignored_after_dangerous_framing HTTP/1.1\r\n
21+
Content-Length: 3\r\n
22+
\r\n
23+
foo\r\n
24+
\r\n

tests/requests/valid/025.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
from gunicorn.config import Config
2+
3+
cfg = Config()
4+
cfg.set("tolerate_dangerous_framing", True)
5+
16
req1 = {
27
"method": "POST",
38
"uri": uri("/chunked_cont_h_at_first"),
49
"version": (1, 1),
510
"headers": [
6-
("CONTENT-LENGTH", "-1"),
711
("TRANSFER-ENCODING", "chunked")
812
],
913
"body": b"hello world"

tests/requests/valid/025compat.http

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
POST /chunked_cont_h_at_first HTTP/1.1\r\n
2+
Transfer-Encoding: chunked\r\n
3+
\r\n
4+
5; some; parameters=stuff\r\n
5+
hello\r\n
6+
6; blahblah; blah\r\n
7+
world\r\n
8+
0\r\n
9+
\r\n
10+
PUT /chunked_cont_h_at_last HTTP/1.1\r\n
11+
Transfer-Encoding: chunked\r\n
12+
Content-Length: -1\r\n
13+
\r\n
14+
5; some; parameters=stuff\r\n
15+
hello\r\n
16+
6; blahblah; blah\r\n
17+
world\r\n
18+
0\r\n

tests/requests/valid/025compat.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from gunicorn.config import Config
2+
3+
cfg = Config()
4+
cfg.set("tolerate_dangerous_framing", True)
5+
6+
req1 = {
7+
"method": "POST",
8+
"uri": uri("/chunked_cont_h_at_first"),
9+
"version": (1, 1),
10+
"headers": [
11+
("TRANSFER-ENCODING", "chunked")
12+
],
13+
"body": b"hello world"
14+
}
15+
16+
req2 = {
17+
"method": "PUT",
18+
"uri": uri("/chunked_cont_h_at_last"),
19+
"version": (1, 1),
20+
"headers": [
21+
("TRANSFER-ENCODING", "chunked"),
22+
("CONTENT-LENGTH", "-1"),
23+
],
24+
"body": b"hello world"
25+
}
26+
27+
request = [req1, req2]

tests/requests/valid/029.http

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
GET /stuff/here?foo=bar HTTP/1.1\r\n
2-
Transfer-Encoding: chunked\r\n
32
Transfer-Encoding: identity\r\n
3+
Transfer-Encoding: chunked\r\n
44
\r\n
55
5\r\n
66
hello\r\n

tests/requests/valid/029.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"uri": uri("/stuff/here?foo=bar"),
88
"version": (1, 1),
99
"headers": [
10+
('TRANSFER-ENCODING', 'identity'),
1011
('TRANSFER-ENCODING', 'chunked'),
11-
('TRANSFER-ENCODING', 'identity')
1212
],
1313
"body": b"hello"
1414
}

0 commit comments

Comments
 (0)