diff --git a/sanic/config.py b/sanic/config.py index 42ea762e..423279c4 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -26,6 +26,7 @@ DEFAULT_CONFIG = { "WEBSOCKET_WRITE_LIMIT": 2 ** 16, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "ACCESS_LOG": True, + "FORWARDED_SECRET": None, "PROXIES_COUNT": -1, "FORWARDED_FOR_HEADER": "X-Forwarded-For", "REAL_IP_HEADER": "X-Real-IP", diff --git a/sanic/forwarded.py b/sanic/forwarded.py new file mode 100644 index 00000000..05957795 --- /dev/null +++ b/sanic/forwarded.py @@ -0,0 +1,50 @@ +import re + +# https://tools.ietf.org/html/rfc7230#section-3.2.6 and https://tools.ietf.org/html/rfc7239#section-4 +_token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"' +# _forwarded_pair = re.compile(f'(^|[;,])\\s*{_token}=(?:{_token}|{_quoted})', re.ASCII) +# Same as forwarded_pair but with reversed string because that allows fast matching from end +_forwarded_reverse = re.compile(f'(?:{_token}|{_quoted})={_token}\\s*($|[;,])', re.ASCII) + +def parse_forwarded(header, secret=None): + """Parse HTTP Forwarded header. + Last proxy is returned, or if a secret is provided, a proxy with secret="yoursecret". + :return: dict with fields from matched element + """ + if header is None or secret is not None and secret not in header: + return None + ret = {} + for m in _forwarded_reverse.finditer(header[::-1]): + val_quoted, val_token, key, sep = m.groups() + key, val = key.lower()[::-1], (val_token or val_quoted)[::-1] + if key != 'secret': ret[key] = val + elif val == secret: secret = None + if sep == ";": continue + if secret is None: break + ret = {} # Advancing to previous comma-separated element + return ret if secret is None and ret else None + +def parse_xforwarded(headers, config): + """Parse X-Real-IP and X-Forwarded-* headers.""" + addr = config.REAL_IP_HEADER and headers.get(config.REAL_IP_HEADER) + forwarded_for = config.FORWARDED_FOR_HEADER and headers.get(config.FORWARDED_FOR_HEADER) + if not addr and forwarded_for: + proxies_count = config.PROXIES_COUNT + assert proxies_count == -1 or proxies_count > 0, config.PROXIES_COUNT + try: + proxies = forwarded_for.split(",") + addr = proxies[-proxies_count] if proxies_count > 0 else proxies[0] + except (AttributeError, IndexError): + return None + addr = addr.strip() + if not addr: + return None + other = ((h, headers.get(f'x-forwarded-{h}')) for h in ('proto', 'host', 'port', 'path')) + return {'for': addr, **{h: v for h, v in other if v}} + +def test_parse_forwarded(): + example_header = 'secret=first;for=1.2.3.4, for=injected,host=", for=23.45.67.89;proto=https;host=site.tld;path="/app";secret=egah2CGj55fSJFs, for="[::1]";port=8000' + assert parse_forwarded(example_header) == {'for': '[::1]', 'port': '8000'} + assert parse_forwarded(example_header, secret='non-exist') == None + assert parse_forwarded(example_header, secret='first') == {'for': '1.2.3.4'} + assert parse_forwarded(example_header, secret='egah2CGj55fSJFs') == {'for': '23.45.67.89', 'proto': 'https', 'host': 'site.tld', 'path': '/app'} diff --git a/sanic/request.py b/sanic/request.py index 9c87efd6..1929c3e6 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -12,6 +12,7 @@ from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse from httptools import parse_url from sanic.exceptions import InvalidUsage +from sanic.forwarded import parse_forwarded, parse_xforwarded from sanic.log import error_logger, logger @@ -87,6 +88,7 @@ class Request(dict): "parsed_files", "parsed_form", "parsed_json", + "parsed_forwarded", "raw_url", "stream", "transport", @@ -107,6 +109,7 @@ class Request(dict): # Init but do not inhale self.body_init() + self.parsed_forwarded = None self.parsed_json = None self.parsed_form = None self.parsed_files = None @@ -373,6 +376,15 @@ class Request(dict): or self.host.split(":")[0] ) + @property + def forwarded(self): + if self.parsed_forwarded is None: + self.parsed_forwarded = ( + parse_forwarded(self.headers.get('forwarded'), self.app.config.FORWARDED_SECRET) or + parse_xforwarded(self.headers, self.app.config) + ) + return self.parsed_forwarded + @property def server_port(self): """