From 25877202983182fd12a7b6a31dedca7edf5a589c Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 14:44:35 +0200 Subject: [PATCH 1/8] Fix 8878: Restrict API key usage by Source IP --- docs/release-notes/version-3.3.md | 1 + netbox/netbox/api/authentication.py | 26 ++++++++++++++++++ netbox/templates/users/api_tokens.html | 15 ++++++++--- netbox/users/admin/__init__.py | 6 ++++- netbox/users/admin/forms.py | 2 +- netbox/users/api/serializers.py | 2 +- netbox/users/forms.py | 10 ++++++- .../migrations/0003_token_allowed_ips.py | 20 ++++++++++++++ netbox/users/models.py | 27 +++++++++++++++++++ 9 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 netbox/users/migrations/0003_token_allowed_ips.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 1dd19a5c0..09dcfcf22 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,6 +6,7 @@ * [#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 +* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP ### REST API Changes diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..2f86a1da2 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,4 +1,5 @@ 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 @@ -11,6 +12,31 @@ class TokenAuthentication(authentication.TokenAuthentication): """ model = Token + def authenticate(self, request): + authenticationresult = super().authenticate(request) + if authenticationresult: + token_user, token = authenticationresult + + # Verify source IP is allowed + 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.") + + 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 + def authenticate_credentials(self, key): model = self.get_model() try: diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..360e65a67 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -22,11 +22,11 @@
-
+
Created
{{ token.created|annotated_date }}
-
+
Expires
{% if token.expires %} {{ token.expires|annotated_date }} @@ -34,7 +34,7 @@ Never {% endif %}
-
+
Create/Edit/Delete Operations
{% if token.write_enabled %} Enabled @@ -42,7 +42,14 @@ Disabled {% endif %}
-
+
+ Allowed Source IPs
+ {% if token.allowed_ips %} + {{ token.allowed_ips|join:', ' }} + {% else %} + Any + {% endif %} +
{% if token.description %}
{{ token.description }} {% endif %} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..ede26cd1b 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips or 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 7d0212441..bc3d44862 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm): class Meta: fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' + 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d490e8fe9..4b1f5bff3 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + 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 d5e6218e5..9720f92b7 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -100,10 +102,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm): 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"', + ) + class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', + 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py new file mode 100644 index 000000000..f4eaa9f96 --- /dev/null +++ b/netbox/users/migrations/0003_token_allowed_ips.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-04-19 12:37 + +import django.contrib.postgres.fields +from django.db import migrations +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 722ec5ba6..40ff78b98 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,17 +4,20 @@ import os from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * +import ipaddress __all__ = ( 'ObjectPermission', @@ -216,6 +219,12 @@ class Token(models.Model): max_length=200, blank=True ) + allowed_ips = ArrayField( + 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"', + ) class Meta: pass @@ -240,6 +249,24 @@ class Token(models.Model): return False return True + def validate_client_ip(self, raw_ip_address): + """ + Checks that an IP address falls within the allowed IPs. + """ + 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): + return True + + return False + # # Permissions From 086e34f728c4fb873b7e63561bc901e9954a5ec3 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:33:29 +0200 Subject: [PATCH 2/8] Updated docs relnotes to refer to 8233 --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 09dcfcf22..415e61963 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#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 -* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP ### REST API Changes From fa4807be8ccf93aed93c42dbcb5231e7657c8e54 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 19 Apr 2022 21:55:39 +0200 Subject: [PATCH 3/8] Update releasenotes --- docs/release-notes/version-3.3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 415e61963..294f8f4d7 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -6,7 +6,7 @@ * [#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 usage by source IP +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key access by source IP ### REST API Changes From a38a880e67d78eba52f19cc4c2613e9399939c2f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 17:01:07 -0400 Subject: [PATCH 4/8] Refactor source IP resolution logic --- docs/release-notes/version-3.3.md | 2 +- netbox/netbox/api/authentication.py | 40 +++++++++++++---------------- netbox/users/api/serializers.py | 5 +++- netbox/users/forms.py | 5 ++-- netbox/users/models.py | 15 +++++------ netbox/utilities/request.py | 27 +++++++++++++++++++ 6 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 netbox/utilities/request.py 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 From e3b7bba84ff15ce0c1f512e5348cd13db89ecb0c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:10:18 -0400 Subject: [PATCH 5/8] Add token authentication tests --- netbox/netbox/tests/test_authentication.py | 67 +++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..6597684fb 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,3 +1,5 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType @@ -8,10 +10,73 @@ from netaddr import IPNetwork from rest_framework.test import APIClient from dcim.models import Site -from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token from utilities.testing import TestCase +from utilities.testing.api import APITestCase + + +class TokenAuthenticationTestCase(APITestCase): + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_authentication(self): + url = reverse('dcim-api:site-list') + + # Request without a token should return a 403 + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Valid token should return a 200 + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_expiration(self): + url = reverse('dcim-api:site-list') + + # Request without a non-expired token should succeed + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Request with an expired token should fail + token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_write_enabled(self): + url = reverse('dcim-api:site-list') + data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + # Request with a write-disabled token should fail + token = Token.objects.create(user=self.user, write_enabled=False) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + # Request with a write-enabled token should succeed + token.write_enabled = True + token.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_allowed_ips(self): + url = reverse('dcim-api:site-list') + + # Request from a non-allowed client IP should fail + token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + self.assertEqual(response.status_code, 403) + + # Request with an expired token should fail + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + self.assertEqual(response.status_code, 200) class ExternalAuthenticationTestCase(TestCase): From 3c15419bd0e10a153713707655798d1ac22f194f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 22 Jun 2022 21:51:43 -0400 Subject: [PATCH 6/8] Introduce IPNetworkSerializer to serialize allowed token IPs --- docs/release-notes/version-3.3.md | 3 ++- netbox/netbox/api/__init__.py | 3 ++- netbox/netbox/api/fields.py | 21 +++++++++++++++++++-- netbox/users/api/serializers.py | 3 ++- netbox/users/models.py | 6 ++---- netbox/utilities/request.py | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9a229aef..81125451e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -13,6 +13,8 @@ #### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) +#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) + ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses @@ -21,7 +23,6 @@ * [#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 diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 1eaa7d1c4..231ab55e6 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from .fields import * from .routers import NetBoxRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -7,6 +7,7 @@ __all__ = ( 'BulkOperationSerializer', 'ChoiceField', 'ContentTypeField', + 'IPNetworkSerializer', 'NetBoxRouter', 'SerializedPKRelatedField', 'ValidatedModelSerializer', diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index d73cbcac2..1f3c40dc2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,12 +1,18 @@ from collections import OrderedDict -import pytz -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField +__all__ = ( + 'ChoiceField', + 'ContentTypeField', + 'IPNetworkSerializer', + 'SerializedPKRelatedField', +) + class ChoiceField(serializers.Field): """ @@ -104,6 +110,17 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" +class IPNetworkSerializer(serializers.Serializer): + """ + Representation of an IP network value (e.g. 192.0.2.0/24). + """ + def to_representation(self, instance): + return str(instance) + + def to_internal_value(self, value): + return IPNetwork(value) + + class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 2a40e45ac..e5ed1bb34 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * @@ -64,6 +64,7 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() + allowed_ips = serializers.ListField(child=IPNetworkSerializer()) class Meta: model = Token diff --git a/netbox/users/models.py b/netbox/users/models.py index 222b088d6..704516c71 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,12 +4,12 @@ import os from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netaddr import IPNetwork from ipam.fields import IPNetworkField from netbox.config import get_config @@ -17,8 +17,6 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * -import ipaddress - __all__ = ( 'ObjectPermission', 'Token', @@ -259,7 +257,7 @@ class Token(models.Model): return True for ip_network in self.allowed_ips: - if client_ip in ipaddress.ip_network(ip_network): + if client_ip in IPNetwork(ip_network): return True return False diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 0fac59d38..3b8e1edde 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -1,4 +1,4 @@ -import ipaddress +from netaddr import IPAddress __all__ = ( 'get_client_ip', @@ -19,7 +19,7 @@ def get_client_ip(request, additional_headers=()): if header in request.META: client_ip = request.META[header].split(',')[0] try: - return ipaddress.ip_address(client_ip) + return IPAddress(client_ip) except ValueError: raise ValueError(f"Invalid IP address set for {header}: {client_ip}") From d4db656940e98b597644106707b4d94a3628e895 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:09:39 -0400 Subject: [PATCH 7/8] Allowed IPs should be optional on Token --- netbox/users/api/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index e5ed1bb34..177cce39c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -64,7 +64,12 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() - allowed_ips = serializers.ListField(child=IPNetworkSerializer()) + allowed_ips = serializers.ListField( + child=IPNetworkSerializer(), + required=False, + allow_empty=True, + default=[] + ) class Meta: model = Token From 7e4b34560f47ef9f8189600ccd4377cb4fc7566c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 23 Jun 2022 08:12:36 -0400 Subject: [PATCH 8/8] Update token model docs --- docs/models/users/token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d98b51369..367444477 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail.