Skip to content

Commit 72b8970

Browse files
committed
silently drop or refuse header names w/ underscore
Ambiguous mappings open a bottomless pit of "what is user input and what is proxy input" confusion. Default to what everyone else has been doing for years now, silently drop. see also https://nginx.org/r/underscores_in_headers
1 parent b284678 commit 72b8970

File tree

11 files changed

+130
-0
lines changed

11 files changed

+130
-0
lines changed

gunicorn/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2300,3 +2300,47 @@ class CasefoldHTTPMethod(Setting):
23002300
23012301
.. versionadded:: 22.0.0
23022302
"""
2303+
2304+
2305+
def validate_header_map_behaviour(val):
2306+
# FIXME: refactor all of this subclassing stdlib argparse
2307+
2308+
if val is None:
2309+
return
2310+
2311+
if not isinstance(val, str):
2312+
raise TypeError("Invalid type for casting: %s" % val)
2313+
if val.lower().strip() == "drop":
2314+
return "drop"
2315+
elif val.lower().strip() == "refuse":
2316+
return "refuse"
2317+
elif val.lower().strip() == "dangerous":
2318+
return "dangerous"
2319+
else:
2320+
raise ValueError("Invalid header map behaviour: %s" % val)
2321+
2322+
2323+
class HeaderMap(Setting):
2324+
name = "header_map"
2325+
section = "Server Mechanics"
2326+
cli = ["--header-map"]
2327+
validator = validate_header_map_behaviour
2328+
default = "drop"
2329+
desc = """\
2330+
Configure how header field names are mapped into environ
2331+
2332+
Headers containing underscores are permitted by RFC9110,
2333+
but gunicorn joining headers of different names into
2334+
the same environment variable will dangerously confuse applications as to which is which.
2335+
2336+
The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped.
2337+
The value ``refuse`` will return an error if a request contains *any* such header.
2338+
The value ``dangerous`` matches the previous, not advisabble, behaviour of mapping different
2339+
header field names into the same environ name.
2340+
2341+
Use with care and only if necessary and after considering if your problem could
2342+
instead be solved by specifically renaming or rewriting only the intended headers
2343+
on a proxy in front of Gunicorn.
2344+
2345+
.. versionadded:: 22.0.0
2346+
"""

gunicorn/http/message.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ def parse_headers(self, data):
120120
scheme_header = True
121121
self.scheme = scheme
122122

123+
# ambiguous mapping allows fooling downstream, e.g. merging non-identical headers:
124+
# X-Forwarded-For: 2001:db8::ha:cc:ed
125+
# X_Forwarded_For: 127.0.0.1,::1
126+
# HTTP_X_FORWARDED_FOR = 2001:db8::ha:cc:ed,127.0.0.1,::1
127+
# Only modify after fixing *ALL* header transformations; network to wsgi env
128+
if "_" in name:
129+
if self.cfg.header_map == "dangerous":
130+
# as if we did not know we cannot safely map this
131+
pass
132+
elif self.cfg.header_map == "drop":
133+
# almost as if it never had been there
134+
# but still counts against resource limits
135+
continue
136+
else:
137+
# fail-safe fallthrough: refuse
138+
raise InvalidHeaderName(name)
139+
123140
headers.append((name, value))
124141

125142
return headers

gunicorn/http/wsgi.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ def create(req, sock, client, server, cfg):
135135
environ['CONTENT_LENGTH'] = hdr_value
136136
continue
137137

138+
# do not change lightly, this is a common source of security problems
139+
# RFC9110 Section 17.10 discourages ambiguous or incomplete mappings
138140
key = 'HTTP_' + hdr_name.replace('-', '_')
139141
if key in environ:
140142
hdr_value = "%s,%s" % (environ[key], hdr_value)

tests/requests/invalid/040.http

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
GET /keep/same/as?invalid/040 HTTP/1.0\r\n
2+
Transfer_Encoding: tricked\r\n
3+
Content-Length: 7\r\n
4+
Content_Length: -1E23\r\n
5+
\r\n
6+
tricked\r\n

tests/requests/invalid/040.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from gunicorn.http.errors import InvalidHeaderName
2+
from gunicorn.config import Config
3+
4+
cfg = Config()
5+
cfg.set("header_map", "refuse")
6+
7+
request = InvalidHeaderName
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
POST /chunked_ambiguous_header_mapping HTTP/1.1\r\n
2+
Transfer_Encoding: gzip\r\n
3+
Transfer-Encoding: chunked\r\n
4+
\r\n
5+
5\r\n
6+
hello\r\n
7+
6\r\n
8+
world\r\n
9+
0\r\n
10+
\r\n

tests/requests/invalid/chunked_07.py

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

tests/requests/valid/040.http

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
GET /keep/same/as?invalid/040 HTTP/1.0\r\n
2+
Transfer_Encoding: tricked\r\n
3+
Content-Length: 7\r\n
4+
Content_Length: -1E23\r\n
5+
\r\n
6+
tricked\r\n

tests/requests/valid/040.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
request = {
2+
"method": "GET",
3+
"uri": uri("/keep/same/as?invalid/040"),
4+
"version": (1, 0),
5+
"headers": [
6+
("CONTENT-LENGTH", "7")
7+
],
8+
"body": b'tricked'
9+
}

tests/requests/valid/040_compat.http

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
GET /keep/same/as?invalid/040 HTTP/1.0\r\n
2+
Transfer_Encoding: tricked\r\n
3+
Content-Length: 7\r\n
4+
Content_Length: -1E23\r\n
5+
\r\n
6+
tricked\r\n

tests/requests/valid/040_compat.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from gunicorn.config import Config
2+
3+
cfg = Config()
4+
cfg.set("header_map", "dangerous")
5+
6+
request = {
7+
"method": "GET",
8+
"uri": uri("/keep/same/as?invalid/040"),
9+
"version": (1, 0),
10+
"headers": [
11+
("TRANSFER_ENCODING", "tricked"),
12+
("CONTENT-LENGTH", "7"),
13+
("CONTENT_LENGTH", "-1E23"),
14+
],
15+
"body": b'tricked'
16+
}

0 commit comments

Comments
 (0)