diff --git a/tornado/httputil.py b/tornado/httputil.py index c0c57e6e95..9a02a567db 100644 --- a/tornado/httputil.py +++ b/tornado/httputil.py @@ -27,6 +27,7 @@ from functools import lru_cache from http.client import responses import http.cookies +import ipaddress import re from ssl import SSLError import time @@ -35,6 +36,7 @@ from tornado.escape import native_str, parse_qs_bytes, utf8 from tornado.log import gen_log +from tornado.netutil import is_valid_ip from tornado.util import ObjectDict, unicode_type @@ -383,6 +385,22 @@ def __init__( self.query_arguments = copy.deepcopy(self.arguments) self.body_arguments = {} # type: Dict[str, List[bytes]] + @property + def unsafe_remote_ip(self) -> str: + """The IP a client claims to be using. + + This is the first public IP in the X-Forwarded-For header. + + Unlike `remote_ip` this IP is untrustworthy but potentially more + representative of the real IP a client is using. Useful for situations + like geolocation. + """ + ip = self.headers.get("X-Forwarded-For", self.remote_ip) + for ip in (cand.strip() for cand in ip.split(",")): + if is_valid_ip(ip) and ipaddress.ip_address(ip).is_global: + break + return ip + @property def cookies(self) -> Dict[str, http.cookies.Morsel]: """A dictionary of ``http.cookies.Morsel`` objects.""" diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py index 614dec7b8f..d52345681f 100644 --- a/tornado/test/httpserver_test.py +++ b/tornado/test/httpserver_test.py @@ -575,6 +575,40 @@ def test_invalid_content_length(self): yield self.stream.read_until_close() +class UnsafeRemoteIPTest(HandlerBaseTestCase): + class Handler(RequestHandler): + def get(self): + self.set_header("request-version", self.request.version) + self.write( + dict( + remote_ip=self.request.remote_ip, + unsafe_remote_ip=self.request.unsafe_remote_ip, + ) + ) + + def get_httpserver_options(self): + return dict(xheaders=True, trusted_downstream=["5.5.5.5"]) + + def test_unsafe_ip(self): + self.assertEqual(self.fetch_json("/")["remote_ip"], "127.0.0.1") + self.assertEqual(self.fetch_json("/")["unsafe_remote_ip"], "127.0.0.1") + + valid_ip = {"X-Forwarded-for": "4.4.4.4"} + self.assertEqual( + self.fetch_json("/", headers=valid_ip)["unsafe_remote_ip"], "4.4.4.4" + ) + + valid_ip_list = {"X-Forwarded-for": "3.3.3.3, 4.4.4.4"} + self.assertEqual( + self.fetch_json("/", headers=valid_ip_list)["unsafe_remote_ip"], "3.3.3.3" + ) + + skip_private_ip = {"X-Forwarded-for": "10.0.0.1, 3.3.3.3, 4.4.4.4"} + self.assertEqual( + self.fetch_json("/", headers=skip_private_ip)["unsafe_remote_ip"], "3.3.3.3" + ) + + class XHeaderTest(HandlerBaseTestCase): class Handler(RequestHandler): def get(self):