From 5c9ba189bc530b557044370f1d4548f3bf94f3ad Mon Sep 17 00:00:00 2001 From: andreymal Date: Tue, 16 Apr 2019 16:30:28 +0300 Subject: [PATCH] Add options to control the behavior of Request.remote_addr (#1539) * Add options to control the behavior of Request.remote_addr * Update tests for Request.remote_addr * Update documentation for Request.remote_addr --- docs/sanic/config.md | 37 +++++++++++----- docs/sanic/deploying.md | 20 +++++++++ sanic/config.py | 3 ++ sanic/request.py | 37 ++++++++++++---- tests/test_requests.py | 96 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 172 insertions(+), 21 deletions(-) diff --git a/docs/sanic/config.md b/docs/sanic/config.md index 41405597..b2552704 100644 --- a/docs/sanic/config.md +++ b/docs/sanic/config.md @@ -85,16 +85,19 @@ DB_USER = 'appuser' Out of the box there are just a few predefined values which can be overwritten when creating the application. - | Variable | Default | Description | - | ------------------------- | --------- | --------------------------------------------------------- | - | REQUEST_MAX_SIZE | 100000000 | How big a request may be (bytes) | - | REQUEST_BUFFER_QUEUE_SIZE | 100 | Request streaming buffer queue size | - | REQUEST_TIMEOUT | 60 | How long a request can take to arrive (sec) | - | RESPONSE_TIMEOUT | 60 | How long a response can take to process (sec) | - | KEEP_ALIVE | True | Disables keep-alive when False | - | KEEP_ALIVE_TIMEOUT | 5 | How long to hold a TCP connection open (sec) | - | GRACEFUL_SHUTDOWN_TIMEOUT | 15.0 | How long to wait to force close non-idle connection (sec) | - | ACCESS_LOG | True | Disable or enable access log | + | Variable | Default | Description | + | ------------------------- | ----------------- | --------------------------------------------------------------------------- | + | REQUEST_MAX_SIZE | 100000000 | How big a request may be (bytes) | + | REQUEST_BUFFER_QUEUE_SIZE | 100 | Request streaming buffer queue size | + | REQUEST_TIMEOUT | 60 | How long a request can take to arrive (sec) | + | RESPONSE_TIMEOUT | 60 | How long a response can take to process (sec) | + | KEEP_ALIVE | True | Disables keep-alive when False | + | KEEP_ALIVE_TIMEOUT | 5 | How long to hold a TCP connection open (sec) | + | GRACEFUL_SHUTDOWN_TIMEOUT | 15.0 | How long to wait to force close non-idle connection (sec) | + | ACCESS_LOG | True | Disable or enable access log | + | PROXIES_COUNT | -1 | The number of proxy servers in front of the app (e.g. nginx; see below) | + | FORWARDED_FOR_HEADER | "X-Forwarded-For" | The name of "X-Forwarded-For" HTTP header that contains client and proxy ip | + | REAL_IP_HEADER | "X-Real-IP" | The name of "X-Real-IP" HTTP header that contains real client ip | ### The different Timeout variables: @@ -143,3 +146,17 @@ Firefox client hard keepalive limit = 115 seconds Opera 11 client hard keepalive limit = 120 seconds Chrome 13+ client keepalive limit > 300+ seconds ``` + +### About proxy servers and client ip + +When you use a reverse proxy server (e.g. nginx), the value of `request.ip` will contain ip of a proxy, typically `127.0.0.1`. To determine the real client ip, `X-Forwarded-For` and `X-Real-IP` HTTP headers are used. But client can fake these headers if they have not been overridden by a proxy. Sanic has a set of options to determine the level of confidence in these headers. + +* If you have a single proxy, set `PROXIES_COUNT` to `1`. Then Sanic will use `X-Real-IP` if available or the last ip from `X-Forwarded-For`. + +* If you have multiple proxies, set `PROXIES_COUNT` equal to their number to allow Sanic to select the correct ip from `X-Forwarded-For`. + +* If you don't use a proxy, set `PROXIES_COUNT` to `0` to ignore these headers and prevent ip falsification. + +* If you don't use `X-Real-IP` (e.g. your proxy sends only `X-Forwarded-For`), set `REAL_IP_HEADER` to an empty string. + +The real ip will be available in `request.remote_addr`. If HTTP headers are unavailable or untrusted, `request.remote_addr` will be an empty string; in this case use `request.ip` instead. diff --git a/docs/sanic/deploying.md b/docs/sanic/deploying.md index 15642f6d..048def78 100644 --- a/docs/sanic/deploying.md +++ b/docs/sanic/deploying.md @@ -64,6 +64,26 @@ of the memory leak. See the [Gunicorn Docs](http://docs.gunicorn.org/en/latest/settings.html#max-requests) for more information. +## Running behind a reverse proxy + +Sanic can be used with a reverse proxy (e.g. nginx). There's a simple example of nginx configuration: + +``` +server { + listen 80; + server_name example.org; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +If you want to get real client ip, you should configure `X-Real-IP` and `X-Forwarded-For` HTTP headers and set `app.config.PROXIES_COUNT` to `1`; see the configuration page for more information. + ## Disable debug logging To improve the performance add `debug=False` and `access_log=False` in the `run` arguments. diff --git a/sanic/config.py b/sanic/config.py index e6503ac5..97581677 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -25,6 +25,9 @@ DEFAULT_CONFIG = { "WEBSOCKET_WRITE_LIMIT": 2 ** 16, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "ACCESS_LOG": True, + "PROXIES_COUNT": -1, + "FORWARDED_FOR_HEADER": "X-Forwarded-For", + "REAL_IP_HEADER": "X-Real-IP", } diff --git a/sanic/request.py b/sanic/request.py index 5553d196..dfb3d1ff 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -355,19 +355,38 @@ class Request(dict): @property def remote_addr(self): - """Attempt to return the original client ip based on X-Forwarded-For. + """Attempt to return the original client ip based on X-Forwarded-For + or X-Real-IP. If HTTP headers are unavailable or untrusted, returns + an empty string. :return: original client ip. """ if not hasattr(self, "_remote_addr"): - forwarded_for = self.headers.get("X-Forwarded-For", "").split(",") - remote_addrs = [ - addr - for addr in [addr.strip() for addr in forwarded_for] - if addr - ] - if len(remote_addrs) > 0: - self._remote_addr = remote_addrs[0] + if self.app.config.PROXIES_COUNT == 0: + self._remote_addr = "" + elif self.app.config.REAL_IP_HEADER and self.headers.get( + self.app.config.REAL_IP_HEADER + ): + self._remote_addr = self.headers[ + self.app.config.REAL_IP_HEADER + ] + elif self.app.config.FORWARDED_FOR_HEADER: + forwarded_for = self.headers.get( + self.app.config.FORWARDED_FOR_HEADER, "" + ).split(",") + remote_addrs = [ + addr + for addr in [addr.strip() for addr in forwarded_for] + if addr + ] + if self.app.config.PROXIES_COUNT == -1: + self._remote_addr = remote_addrs[0] + elif len(remote_addrs) >= self.app.config.PROXIES_COUNT: + self._remote_addr = remote_addrs[ + -self.app.config.PROXIES_COUNT + ] + else: + self._remote_addr = "" else: self._remote_addr = "" return self._remote_addr diff --git a/tests/test_requests.py b/tests/test_requests.py index 2f587361..9e634fd8 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -29,7 +29,7 @@ def test_sync(app): assert response.text == "Hello" -def test_remote_address(app): +def test_ip(app): @app.route("/") def handler(request): return text("{}".format(request.ip)) @@ -203,11 +203,23 @@ def test_content_type(app): assert response.text == "application/json" -def test_remote_addr(app): +def test_remote_addr_with_two_proxies(app): + app.config.PROXIES_COUNT = 2 + @app.route("/") async def handler(request): return text(request.remote_addr) + headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.0.2" + assert response.text == "127.0.0.2" + + headers = {"X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "" + assert response.text == "" + headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} request, response = app.test_client.get("/", headers=headers) assert request.remote_addr == "127.0.0.1" @@ -222,6 +234,86 @@ def test_remote_addr(app): assert request.remote_addr == "127.0.0.1" assert response.text == "127.0.0.1" + headers = { + "X-Forwarded-For": ", 127.0.2.2, , ,127.0.0.1, , ,,127.0.1.2" + } + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.0.1" + assert response.text == "127.0.0.1" + + +def test_remote_addr_with_infinite_number_of_proxies(app): + app.config.PROXIES_COUNT = -1 + + @app.route("/") + async def handler(request): + return text(request.remote_addr) + + headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.0.2" + assert response.text == "127.0.0.2" + + headers = {"X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.1.1" + assert response.text == "127.0.1.1" + + headers = { + "X-Forwarded-For": "127.0.0.5, 127.0.0.4, 127.0.0.3, 127.0.0.2, 127.0.0.1" + } + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.0.5" + assert response.text == "127.0.0.5" + + +def test_remote_addr_without_proxy(app): + app.config.PROXIES_COUNT = 0 + + @app.route("/") + async def handler(request): + return text(request.remote_addr) + + headers = {"X-Real-IP": "127.0.0.2", "X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "" + assert response.text == "" + + headers = {"X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "" + assert response.text == "" + + headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "" + assert response.text == "" + + +def test_remote_addr_custom_headers(app): + app.config.PROXIES_COUNT = 1 + app.config.REAL_IP_HEADER = "Client-IP" + app.config.FORWARDED_FOR_HEADER = "Forwarded" + + @app.route("/") + async def handler(request): + return text(request.remote_addr) + + headers = {"X-Real-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.1.1" + assert response.text == "127.0.1.1" + + headers = {"X-Forwarded-For": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "" + assert response.text == "" + + headers = {"Client-IP": "127.0.0.2", "Forwarded": "127.0.1.1"} + request, response = app.test_client.get("/", headers=headers) + assert request.remote_addr == "127.0.0.2" + assert response.text == "127.0.0.2" + def test_match_info(app): @app.route("/api/v1/user//")