Refactor source IP resolution logic

This commit is contained in:
jeremystretch 2022-06-22 17:01:07 -04:00
parent 7043c6faf9
commit a38a880e67
6 changed files with 59 additions and 35 deletions

View File

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

View File

@ -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()

View File

@ -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:

View File

@ -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:

View File

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

View File

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