diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6e2f28730..f9a229aef 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -21,10 +21,10 @@ * [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit * [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API token access by source IP * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results -* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields * [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 2f86a1da2..ea66dc5a6 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,41 +1,37 @@ from django.conf import settings -from django.core.exceptions import ValidationError from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS from users.models import Token +from utilities.request import get_client_ip class TokenAuthentication(authentication.TokenAuthentication): """ - A custom authentication scheme which enforces Token expiration times. + A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token def authenticate(self, request): - authenticationresult = super().authenticate(request) - if authenticationresult: - token_user, token = authenticationresult + result = super().authenticate(request) - # Verify source IP is allowed + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token if token.allowed_ips: - # Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867 - if 'HTTP_X_REAL_IP' in request.META: - clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip() - http_header = 'HTTP_X_REAL_IP' - elif 'REMOTE_ADDR' in request.META: - clientip = request.META['REMOTE_ADDR'] - http_header = 'REMOTE_ADDR' - else: - raise exceptions.AuthenticationFailed(f"A HTTP header containing the SourceIP (HTTP_X_REAL_IP, REMOTE_ADDR) is missing from the request.") + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) - try: - if not token.validate_client_ip(clientip): - raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.") - except ValidationError as ValidationErrorInfo: - raise exceptions.ValidationError(f"The value in the HTTP Header {http_header} has a ValidationError: {ValidationErrorInfo.message}") - - return authenticationresult + return result def authenticate_credentials(self, key): model = self.get_model() diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index b48a14d5c..2a40e45ac 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -67,7 +67,10 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips') + fields = ( + 'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', + 'allowed_ips', + ) def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 9720f92b7..8692eb050 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -101,11 +101,12 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, help_text="If no key is provided, one will be generated automatically." ) - allowed_ips = SimpleArrayField( base_field=IPNetworkFormField(), required=False, - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + label='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) class Meta: diff --git a/netbox/users/models.py b/netbox/users/models.py index 5372353c0..222b088d6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -223,7 +223,9 @@ class Token(models.Model): base_field=IPNetworkField(), blank=True, null=True, - help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + verbose_name='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', ) class Meta: @@ -249,20 +251,15 @@ class Token(models.Model): return False return True - def validate_client_ip(self, raw_ip_address): + def validate_client_ip(self, client_ip): """ - Checks that an IP address falls within the allowed IPs. + Validate the API client IP address against the source IP restrictions (if any) set on the token. """ if not self.allowed_ips: return True - try: - ip_address = ipaddress.ip_address(raw_ip_address) - except ValueError as e: - raise ValidationError(str(e)) - for ip_network in self.allowed_ips: - if ip_address in ipaddress.ip_network(ip_network): + if client_ip in ipaddress.ip_network(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py new file mode 100644 index 000000000..0fac59d38 --- /dev/null +++ b/netbox/utilities/request.py @@ -0,0 +1,27 @@ +import ipaddress + +__all__ = ( + 'get_client_ip', +) + + +def get_client_ip(request, additional_headers=()): + """ + Return the client (source) IP address of the given request. + """ + HTTP_HEADERS = ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', + *additional_headers + ) + for header in HTTP_HEADERS: + if header in request.META: + client_ip = request.META[header].split(',')[0] + try: + return ipaddress.ip_address(client_ip) + except ValueError: + raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + + # Could not determine the client IP address from request headers + return None