From 522cceae73c6bb32539ee0744daa95c835a9035d Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 15 Mar 2022 13:48:18 +0100 Subject: [PATCH 1/4] base_requirements - django_better_admin_arrayfield --- base_requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/base_requirements.txt b/base_requirements.txt index 0b8365e0e..4df5f3d0e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -113,3 +113,7 @@ svgwrite # Tabular dataset library (for table-based exports) # https://github.com/jazzband/tablib tablib + +# It changes comma separated widget to list based in admin panel +# https://github.com/gradam/django-better-admin-arrayfield +django_better_admin_arrayfield From fa15c879ff3f773f4025fb8bdcd7094237084d47 Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 15 Mar 2022 14:24:55 +0100 Subject: [PATCH 2/4] #8233 Restrict API key usage by source IP --- docs/release-notes/version-3.1.md | 4 +++ netbox/netbox/api/authentication.py | 19 ++++++++++++ netbox/netbox/settings.py | 1 + netbox/templates/users/api_tokens.html | 14 +++++++-- netbox/users/admin/__init__.py | 5 ++-- netbox/users/admin/forms.py | 2 +- netbox/users/api/serializers.py | 2 +- netbox/users/forms.py | 2 +- .../migrations/0002_token_allowed_ips.py | 20 +++++++++++++ netbox/users/models.py | 29 +++++++++++++++++++ requirements.txt | 1 + 11 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 netbox/users/migrations/0002_token_allowed_ips.py diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ed713102d..6e117d06e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.10 (FUTURE) +### Enhancements + +* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP + ### Bug Fixes * [#8820](https://github.com/netbox-community/netbox/issues/8820) - Fix navbar background color in dark mode diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..5545488f0 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -10,6 +10,11 @@ class TokenAuthentication(authentication.TokenAuthentication): A custom authentication scheme which enforces Token expiration times. """ model = Token + __request = False + + def authenticate(self, request): + self.request = request + return super().authenticate(request) def authenticate_credentials(self, key): model = self.get_model() @@ -18,6 +23,20 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") + # Verify source IP is allowed + request = self.request + if token.allowed_ips and request: + # 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() + elif 'REMOTE_ADDR' in request.META: + clientip = request.META['REMOTE_ADDR'] + else: + raise exceptions.AuthenticationFailed(f"The request HTTP headers (HTTP_X_REAL_IP, REMOTE_ADDR) are missing or do not contain a valid source IP.") + + if not token.validate_client_ip(clientip): + raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.") + # Enforce the Token's expiration time, if one has been set. if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d16e00337..497738012 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -321,6 +321,7 @@ INSTALLED_APPS = [ 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', + 'django_better_admin_arrayfield', ] # Middleware diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..53839b38e 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,6 +42,14 @@ Disabled {% endif %}
+
+ Allowed Source IPs
+ {% if token.allowed_ips %} + {{ token.allowed_ips }} + {% else %} + Any + {% endif %} +
{% if token.description %}
{{ token.description }} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..3fb5d93bb 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User +from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin from users.models import ObjectPermission, Token from . import filters, forms, inlines @@ -55,10 +56,10 @@ class UserAdmin(UserAdmin_): # @admin.register(Token) -class TokenAdmin(admin.ModelAdmin): +class TokenAdmin(admin.ModelAdmin, DynamicArrayMixin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'allowed_ips' ] 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 8bd54cb66..4d2de2661 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -22,7 +22,7 @@ class TokenForm(BootstrapMixin, forms.ModelForm): 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/0002_token_allowed_ips.py b/netbox/users/migrations/0002_token_allowed_ips.py new file mode 100644 index 000000000..aa3fe2be3 --- /dev/null +++ b/netbox/users/migrations/0002_token_allowed_ips.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-03-15 13:08 + +from django.db import migrations +import django_better_admin_arrayfield.models.fields +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_squashed_0011'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django_better_admin_arrayfield.models.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 64b6432a7..62a081335 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,17 +4,22 @@ 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 django_better_admin_arrayfield.models.fields import ArrayField as betterArrayField from netbox.models import BigIDModel +from ipam.fields import IPNetworkField from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * +import ipaddress + __all__ = ( 'ObjectPermission', @@ -203,6 +208,12 @@ class Token(BigIDModel): max_length=200, blank=True ) + allowed_ips = betterArrayField( + 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 @@ -227,6 +238,24 @@ class Token(BigIDModel): 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: + raise ValidationError(f"{raw_ip_address} is an invalid IP address") + + for ipnet in self.allowed_ips: + if ip_address in ipaddress.ip_network(ipnet): + return True + + return False + # # Permissions diff --git a/requirements.txt b/requirements.txt index 04d053180..8532db73f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ social-auth-core==4.2.0 svgwrite==1.4.1 tablib==3.2.0 tzdata==2021.5 +django_better_admin_arrayfield==1.4.2 # Workaround for #7401 jsonschema==3.2.0 From de417a0296d52718c9a861fa9cbe5f3f28cb676d Mon Sep 17 00:00:00 2001 From: Pieter Lambrecht Date: Tue, 15 Mar 2022 16:15:44 +0100 Subject: [PATCH 3/4] Add 'Any' display to admin form --- netbox/templates/users/api_tokens.html | 2 +- netbox/users/admin/__init__.py | 7 ++++++- netbox/users/forms.py | 7 +++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 53839b38e..36b0143e7 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -45,7 +45,7 @@
Allowed Source IPs
{% if token.allowed_ips %} - {{ token.allowed_ips }} + {{ token.allowed_ips|join:', ' }} {% else %} Any {% endif %} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 3fb5d93bb..b9e9ca898 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -59,9 +59,14 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin, DynamicArrayMixin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'allowed_ips' + 'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips + list_allowed_ips.empty_value_display = 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 4d2de2661..ebfecafff 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,8 +1,10 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from utilities.forms import BootstrapMixin, DateTimePicker from .models import Token +from ipam.formfields import IPNetworkFormField class LoginForm(BootstrapMixin, AuthenticationForm): @@ -18,6 +20,11 @@ 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"', + ) class Meta: model = Token From a4cd082c39073e1a4b25dd61d9b47e649582b8c0 Mon Sep 17 00:00:00 2001 From: PieterL75 <74899468+PieterL75@users.noreply.github.com> Date: Fri, 18 Mar 2022 08:58:52 +0100 Subject: [PATCH 4/4] rename ipnet var to ip_networks Co-authored-by: Jeremy Stretch --- netbox/users/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 62a081335..3486d0793 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -250,8 +250,8 @@ class Token(BigIDModel): except ValueError: raise ValidationError(f"{raw_ip_address} is an invalid IP address") - for ipnet in self.allowed_ips: - if ip_address in ipaddress.ip_network(ipnet): + for ip_network in self.allowed_ips: + if ip_address in ipaddress.ip_network(ip_network): return True return False