diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 9c40a41d8..ec26049f0 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -80,6 +80,26 @@ The hostname displayed in the user interface identifying the system on which Net --- +## HTTP_CLIENT_IP_HEADERS + +!!! info "This parameter was introduced in NetBox v4.6.1." + +Default: + +```python +( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', +) +``` + +An ordered list of HTTP request headers inspected to determine the source IP address of a client request. The first header in the list which is present on the request is used; if none are found, the client IP cannot be determined. This is most commonly required when NetBox is deployed behind a reverse proxy which injects a proprietary client IP header (e.g. `HTTP_CF_CONNECTING_IP` for Cloudflare). + +The client IP is used for source-address restrictions on API tokens and for logging failed login attempts. + +--- + ## HTTP_PROXIES Default: `None` diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ddfe558ea..0bbb8f36d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -132,6 +132,11 @@ GRAPHQL_DEFAULT_VERSION = getattr(configuration, 'GRAPHQL_DEFAULT_VERSION', 1) GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10) GRAPHQL_MAX_QUERY_DEPTH = getattr(configuration, 'GRAPHQL_MAX_QUERY_DEPTH', None) HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node()) +HTTP_CLIENT_IP_HEADERS = getattr(configuration, 'HTTP_CLIENT_IP_HEADERS', ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', +)) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {}) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False) diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 54a7f9ff6..6471a4dec 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -2,6 +2,7 @@ import warnings from contextlib import ExitStack, contextmanager from urllib.parse import urlparse +from django.conf import settings from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ from netaddr import AddrFormatError, IPAddress @@ -71,15 +72,14 @@ def copy_safe_request(request, include_files=True): def get_client_ip(request, additional_headers=()): """ - Return the client (source) IP address of the given request. + Return the client (source) IP address of the given request. Accepts an optional list of headers to inspect in + addition to those configured under HTTP_CLIENT_IP_HEADERS. """ - HTTP_HEADERS = ( - 'HTTP_X_REAL_IP', - 'HTTP_X_FORWARDED_FOR', - 'REMOTE_ADDR', - *additional_headers + headers = ( + *settings.HTTP_CLIENT_IP_HEADERS, + *additional_headers, ) - for header in HTTP_HEADERS: + for header in headers: if header in request.META: ip = request.META[header].split(',')[0].strip() try: diff --git a/netbox/utilities/tests/test_request.py b/netbox/utilities/tests/test_request.py index 075bf2aaa..92af102de 100644 --- a/netbox/utilities/tests/test_request.py +++ b/netbox/utilities/tests/test_request.py @@ -1,5 +1,5 @@ from django.contrib.auth.models import AnonymousUser -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from netaddr import IPAddress from utilities.request import copy_safe_request, get_client_ip @@ -61,3 +61,31 @@ class GetClientIPTestCase(TestCase): request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip') with self.assertRaises(ValueError): get_client_ip(request) + + def test_no_matching_header(self): + request = self.factory.get('/') + request.META.pop('REMOTE_ADDR', None) + self.assertIsNone(get_client_ip(request)) + + def test_additional_headers_argument(self): + """Headers passed via `additional_headers` are checked after the configured defaults.""" + request = self.factory.get('/', HTTP_CF_CONNECTING_IP='10.0.0.1') + request.META.pop('REMOTE_ADDR', None) + self.assertEqual( + get_client_ip(request, additional_headers=('HTTP_CF_CONNECTING_IP',)), + IPAddress('10.0.0.1'), + ) + + def test_default_headers_precede_additional_headers(self): + """Headers from HTTP_CLIENT_IP_HEADERS take precedence over `additional_headers`.""" + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1', HTTP_CF_CONNECTING_IP='10.0.0.1') + self.assertEqual( + get_client_ip(request, additional_headers=('HTTP_CF_CONNECTING_IP',)), + IPAddress('192.168.1.1'), + ) + + @override_settings(HTTP_CLIENT_IP_HEADERS=('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR')) + def test_custom_configured_headers(self): + """get_client_ip() should honor the HTTP_CLIENT_IP_HEADERS setting.""" + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1', HTTP_CF_CONNECTING_IP='10.0.0.1') + self.assertEqual(get_client_ip(request), IPAddress('10.0.0.1'))