diff --git a/gunicorn/config.py b/gunicorn/config.py index b9191387..24d9c2cd 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -996,3 +996,38 @@ class WorkerExit(Setting): The callable needs to accept two instance variables for the Arbiter and the just-exited Worker. """ + +class ProxyProtocol(Setting): + name = "proxy_protocol" + section = "Server Mechanics" + cli = ["--proxy-protocol"] + validator = validate_bool + default = False + action = "store_true" + desc = """\ + Enable detect PROXY protocol (PROXY mode). + + Allow using Http and Proxy together. It's may be useful for work with + stunnel as https frondend and gunicorn as http server. + + PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + + Example for stunnel config:: + + [https] + protocol = proxy + accept = 443 + connect = 80 + cert = /etc/ssl/certs/stunnel.pem + key = /etc/ssl/certs/stunnel.key + """ + +class ProxyAllowFrom(Setting): + name = "proxy_allow_ips" + section = "Server Mechanics" + cli = ["--proxy-allow-from"] + validator = validate_string_to_list + default = "127.0.0.1" + desc = """\ + Front-end's IPs from which allowed accept proxy requests (comma separate). + """ diff --git a/gunicorn/http/errors.py b/gunicorn/http/errors.py index f579e9d9..7ba4949f 100644 --- a/gunicorn/http/errors.py +++ b/gunicorn/http/errors.py @@ -78,3 +78,21 @@ class LimitRequestHeaders(ParseException): def __str__(self): return self.msg + + +class InvalidProxyLine(ParseException): + def __init__(self, line): + self.line = line + self.code = 400 + + def __str__(self): + return "Invalid PROXY line: %s" % self.line + + +class ForbiddenProxyRequest(ParseException): + def __init__(self, host): + self.host = host + self.code = 403 + + def __str__(self): + return "Proxy request from %s not allowed" % self.host diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index 4bd554c7..ab3575fc 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -5,16 +5,20 @@ import re import urlparse +import socket +from errno import ENOTCONN try: from cStringIO import StringIO except ImportError: from StringIO import StringIO +from gunicorn.http.unreader import SocketUnreader from gunicorn.http.body import ChunkedReader, LengthReader, EOFReader, Body from gunicorn.http.errors import InvalidHeader, InvalidHeaderName, NoMoreData, \ InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion, \ LimitRequestLine, LimitRequestHeaders +from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest MAX_REQUEST_LINE = 8190 MAX_HEADERS = 32768 @@ -131,7 +135,7 @@ class Message(object): class Request(Message): - def __init__(self, cfg, unreader): + def __init__(self, cfg, unreader, req_number=1): self.methre = re.compile("[A-Z0-9$-_.]{3,20}") self.versre = re.compile("HTTP/(\d+).(\d+)") @@ -146,6 +150,10 @@ class Request(Message): if (self.limit_request_line < 0 or self.limit_request_line >= MAX_REQUEST_LINE): self.limit_request_line = MAX_REQUEST_LINE + + self.req_number = req_number + self.proxy_protocol_info = None + super(Request, self).__init__(cfg, unreader) @@ -161,27 +169,19 @@ class Request(Message): buf = StringIO() self.get_data(unreader, buf, stop=True) - # Request line - data = buf.getvalue() - while True: - idx = data.find("\r\n") - if idx >= 0: - # check if the request line is too large - if idx > self.limit_request_line > 0: - raise LimitRequestLine(idx, self.limit_request_line) - break + # get request line + line, rbuf = self.read_line(unreader, buf, self.limit_request_line) - # check if chunk is too large before read next chunk - if len(data) - 2 > self.limit_request_line and \ - self.limit_request_line> 0 : - raise LimitRequestLine(len(data), self.limit_request_line) + # proxy protocol + if self.proxy_protocol(line): + # get next request line + buf = StringIO() + buf.write(rbuf) + line, rbuf = self.read_line(unreader, buf, self.limit_request_line) - self.get_data(unreader, buf) - data = buf.getvalue() - - self.parse_request_line(data[:idx]) + self.parse_request_line(line) buf = StringIO() - buf.write(data[idx+2:]) # Skip \r\n + buf.write(rbuf) # Headers data = buf.getvalue() @@ -210,6 +210,103 @@ class Request(Message): buf = StringIO() return ret + def read_line(self, unreader, buf, limit=0): + data = buf.getvalue() + + while True: + idx = data.find("\r\n") + if idx >= 0: + # check if the request line is too large + if idx > limit > 0: + raise LimitRequestLine(idx, limit) + break + self.get_data(unreader, buf) + data = buf.getvalue() + + if len(data) - 2 > limit > 0: + raise LimitRequestLine(len(data), limit) + + return (data[:idx], # request line, + data[idx + 2:]) # residue in the buffer, skip \r\n + + def proxy_protocol(self, line): + """\ + Detect, check and parse proxy protocol. + + :raises: ForbiddenProxyRequest, InvalidProxyLine. + :return: True for proxy protocol line else False + """ + if not self.cfg.proxy_protocol: + return False + + if self.req_number != 1: + return False + + if not line.startswith("PROXY"): + return False + + self.proxy_protocol_access_check() + self.parse_proxy_protocol(line) + + return True + + def proxy_protocol_access_check(self): + # check in allow list + if isinstance(self.unreader, SocketUnreader): + try: + remote_host = self.unreader.sock.getpeername()[0] + except socket.error as e: + if e[0] == ENOTCONN: + raise ForbiddenProxyRequest("UNKNOW") + raise + if remote_host not in self.cfg.proxy_allow_ips: + raise ForbiddenProxyRequest(remote_host) + + def parse_proxy_protocol(self, line): + bits = line.split() + + if len(bits) != 6: + raise InvalidProxyLine(line) + + # Extract data + proto = bits[1] + s_addr = bits[2] + d_addr = bits[3] + + # Validation + if proto not in ["TCP4", "TCP6"]: + raise InvalidProxyLine("protocol '%s' not supported" % proto) + if proto == "TCP4": + try: + socket.inet_pton(socket.AF_INET, s_addr) + socket.inet_pton(socket.AF_INET, d_addr) + except socket.error: + raise InvalidProxyLine(line) + elif proto == "TCP6": + try: + socket.inet_pton(socket.AF_INET6, s_addr) + socket.inet_pton(socket.AF_INET6, d_addr) + except socket.error: + raise InvalidProxyLine(line) + + try: + s_port = int(bits[4]) + d_port = int(bits[5]) + except ValueError: + raise InvalidProxyLine("invalid port %s" % line) + + if not ((0 <= s_port <= 65535) and (0 <= d_port <= 65535)): + raise InvalidProxyLine("invalid port %s" % line) + + # Set data + self.proxy_protocol_info = { + "proxy_protocol": proto, + "client_addr": s_addr, + "client_port": s_port, + "proxy_addr": d_addr, + "proxy_port": d_port + } + def parse_request_line(self, line): bits = line.split(None, 2) if len(bits) != 3: diff --git a/gunicorn/http/parser.py b/gunicorn/http/parser.py index 38fee43b..54bbddea 100644 --- a/gunicorn/http/parser.py +++ b/gunicorn/http/parser.py @@ -16,6 +16,9 @@ class Parser(object): self.unreader = IterUnreader(source) self.mesg = None + # request counter (for keepalive connetions) + self.req_count = 0 + def __iter__(self): return self @@ -31,7 +34,8 @@ class Parser(object): data = self.mesg.body.read(8192) # Parse the next request - self.mesg = self.mesg_class(self.cfg, self.unreader) + self.req_count += 1 + self.mesg = self.mesg_class(self.cfg, self.unreader, self.req_count) if not self.mesg: raise StopIteration() return self.mesg diff --git a/gunicorn/http/wsgi.py b/gunicorn/http/wsgi.py index 325f5f50..228ed941 100644 --- a/gunicorn/http/wsgi.py +++ b/gunicorn/http/wsgi.py @@ -57,6 +57,19 @@ def default_environ(req, sock, cfg): "SERVER_PROTOCOL": "HTTP/%s" % ".".join(map(str, req.version)) } +def proxy_environ(req): + info = req.proxy_protocol_info + + if not info: + return {} + + return { + "PROXY_PROTOCOL": info["proxy_protocol"], + "REMOTE_ADDR": info["client_addr"], + "REMOTE_PORT": str(info["client_port"]), + "PROXY_ADDR": info["proxy_addr"], + "PROXY_PORT": str(info["proxy_port"]), + } def create(req, sock, client, server, cfg): resp = Response(req, sock) @@ -149,6 +162,8 @@ def create(req, sock, client, server, cfg): environ['PATH_INFO'] = unquote(path_info) environ['SCRIPT_NAME'] = script_name + environ.update(proxy_environ(req)) + return resp, environ class Response(object): diff --git a/gunicorn/workers/base.py b/gunicorn/workers/base.py index cb854be0..474cf3d3 100644 --- a/gunicorn/workers/base.py +++ b/gunicorn/workers/base.py @@ -16,6 +16,7 @@ from gunicorn.workers.workertmp import WorkerTmp from gunicorn.http.errors import InvalidHeader, InvalidHeaderName, \ InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion, \ LimitRequestLine, LimitRequestHeaders +from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest from gunicorn.http.wsgi import default_environ, Response class Worker(object): @@ -131,7 +132,8 @@ class Worker(object): addr = addr or ('', -1) # unix socket case if isinstance(exc, (InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion, InvalidHeader, InvalidHeaderName, - LimitRequestLine, LimitRequestHeaders,)): + LimitRequestLine, LimitRequestHeaders, + InvalidProxyLine, ForbiddenProxyRequest,)): status_int = 400 reason = "Bad Request" @@ -150,6 +152,12 @@ class Worker(object): mesg = "
%s
" % str(exc) elif isinstance(exc, LimitRequestHeaders): mesg = "Error parsing headers: '%s'
" % str(exc) + elif isinstance(exc, InvalidProxyLine): + mesg = "'%s'
" % str(exc) + elif isinstance(exc, ForbiddenProxyRequest): + reason = "Forbidden" + mesg = "Request forbidden
" + status_int = 403 self.log.debug("Invalid request from ip={ip}: {error}"\ "".format(ip=addr[0], diff --git a/tests/requests/invalid/pp_01.http b/tests/requests/invalid/pp_01.http new file mode 100644 index 00000000..3b9a5be1 --- /dev/null +++ b/tests/requests/invalid/pp_01.http @@ -0,0 +1 @@ +PROXY TCP4 192.168.0.1 192.16...\r\n diff --git a/tests/requests/invalid/pp_01.py b/tests/requests/invalid/pp_01.py new file mode 100644 index 00000000..c6d13193 --- /dev/null +++ b/tests/requests/invalid/pp_01.py @@ -0,0 +1,7 @@ +from gunicorn.config import Config +from gunicorn.http.errors import InvalidProxyLine + +cfg = Config() +cfg.set("proxy_protocol", True) + +request = InvalidProxyLine diff --git a/tests/requests/invalid/pp_02.http b/tests/requests/invalid/pp_02.http new file mode 100644 index 00000000..6cc1bc0c --- /dev/null +++ b/tests/requests/invalid/pp_02.http @@ -0,0 +1 @@ +PROXY TCP4 192.168.0.1 192.168.0.11 65iii 100000\r\n diff --git a/tests/requests/invalid/pp_02.py b/tests/requests/invalid/pp_02.py new file mode 100644 index 00000000..a7fa74e0 --- /dev/null +++ b/tests/requests/invalid/pp_02.py @@ -0,0 +1,7 @@ +from gunicorn.config import Config +from gunicorn.http.errors import InvalidProxyLine + +cfg = Config() +cfg.set('proxy_protocol', True) + +request = InvalidProxyLine diff --git a/tests/requests/valid/pp_01.http b/tests/requests/valid/pp_01.http new file mode 100644 index 00000000..4d930c6c --- /dev/null +++ b/tests/requests/valid/pp_01.http @@ -0,0 +1,7 @@ +PROXY TCP4 192.168.0.1 192.168.0.11 56324 443\r\n +GET /stuff/here?foo=bar HTTP/1.0\r\n +Server: http://127.0.0.1:5984\r\n +Content-Type: application/json\r\n +Content-Length: 14\r\n +\r\n +{"nom": "nom"} diff --git a/tests/requests/valid/pp_01.py b/tests/requests/valid/pp_01.py new file mode 100644 index 00000000..2e5b85a3 --- /dev/null +++ b/tests/requests/valid/pp_01.py @@ -0,0 +1,16 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set('proxy_protocol', True) + +request = { + "method": "GET", + "uri": uri("/stuff/here?foo=bar"), + "version": (1, 0), + "headers": [ + ("SERVER", "http://127.0.0.1:5984"), + ("CONTENT-TYPE", "application/json"), + ("CONTENT-LENGTH", "14") + ], + "body": '{"nom": "nom"}' +} diff --git a/tests/requests/valid/pp_02.http b/tests/requests/valid/pp_02.http new file mode 100644 index 00000000..294d2ea1 --- /dev/null +++ b/tests/requests/valid/pp_02.http @@ -0,0 +1,15 @@ +PROXY TCP4 192.168.0.1 192.168.0.11 56324 443\r\n +GET /stuff/here?foo=bar HTTP/1.1\r\n +Server: http://127.0.0.1:5984\r\n +Content-Type: application/json\r\n +Content-Length: 14\r\n +Connection: keep-alive\r\n +\r\n +{"nom": "nom"} +POST /post_chunked_all_your_base HTTP/1.1\r\n +Transfer-Encoding: chunked\r\n +\r\n +1e\r\n +all your base are belong to us\r\n +0\r\n +\r\n diff --git a/tests/requests/valid/pp_02.py b/tests/requests/valid/pp_02.py new file mode 100644 index 00000000..f756b526 --- /dev/null +++ b/tests/requests/valid/pp_02.py @@ -0,0 +1,30 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("proxy_protocol", True) + +req1 = { + "method": "GET", + "uri": uri("/stuff/here?foo=bar"), + "version": (1, 1), + "headers": [ + ("SERVER", "http://127.0.0.1:5984"), + ("CONTENT-TYPE", "application/json"), + ("CONTENT-LENGTH", "14"), + ("CONNECTION", "keep-alive") + ], + "body": '{"nom": "nom"}' +} + + +req2 = { + "method": "POST", + "uri": uri("/post_chunked_all_your_base"), + "version": (1, 1), + "headers": [ + ("TRANSFER-ENCODING", "chunked"), + ], + "body": "all your base are belong to us" +} + +request = [req1, req2]