Added support for HTTP Forwarded header and combined parsing of other proxy headers.

- Accessible via request.forwarded that tries parse_forwarded and then parse_xforwarded
- parse_forwarded uses the Forwarded header, if config.FORWARDED_SECRET is provided and a matching header field is found
- parse_xforwarded uses X-Real-IP and X-Forwarded-* much alike the existing implementation
- This commit does not change existing request properties that still use the old code and won't make use of Forwarded headers.
This commit is contained in:
L. Kärkkäinen 2019-07-20 15:46:32 +03:00
parent 84b41123f2
commit f69383db04
3 changed files with 63 additions and 0 deletions

View File

@ -26,6 +26,7 @@ DEFAULT_CONFIG = {
"WEBSOCKET_WRITE_LIMIT": 2 ** 16, "WEBSOCKET_WRITE_LIMIT": 2 ** 16,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"ACCESS_LOG": True, "ACCESS_LOG": True,
"FORWARDED_SECRET": None,
"PROXIES_COUNT": -1, "PROXIES_COUNT": -1,
"FORWARDED_FOR_HEADER": "X-Forwarded-For", "FORWARDED_FOR_HEADER": "X-Forwarded-For",
"REAL_IP_HEADER": "X-Real-IP", "REAL_IP_HEADER": "X-Real-IP",

50
sanic/forwarded.py Normal file
View File

@ -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'}

View File

@ -12,6 +12,7 @@ from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
from httptools import parse_url from httptools import parse_url
from sanic.exceptions import InvalidUsage from sanic.exceptions import InvalidUsage
from sanic.forwarded import parse_forwarded, parse_xforwarded
from sanic.log import error_logger, logger from sanic.log import error_logger, logger
@ -87,6 +88,7 @@ class Request(dict):
"parsed_files", "parsed_files",
"parsed_form", "parsed_form",
"parsed_json", "parsed_json",
"parsed_forwarded",
"raw_url", "raw_url",
"stream", "stream",
"transport", "transport",
@ -107,6 +109,7 @@ class Request(dict):
# Init but do not inhale # Init but do not inhale
self.body_init() self.body_init()
self.parsed_forwarded = None
self.parsed_json = None self.parsed_json = None
self.parsed_form = None self.parsed_form = None
self.parsed_files = None self.parsed_files = None
@ -373,6 +376,15 @@ class Request(dict):
or self.host.split(":")[0] 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 @property
def server_port(self): def server_port(self):
""" """