Просмотр исходного кода

Merge pull request #9175 from PieterL75/issue_8233

Closes #8233: Restrict API key access by source IP
Jeremy Stretch 3 лет назад
Родитель
Сommit
7043c6faf9

+ 1 - 0
docs/release-notes/version-3.3.md

@@ -24,6 +24,7 @@
 * [#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
 

+ 26 - 0
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:

+ 11 - 4
netbox/templates/users/api_tokens.html

@@ -22,11 +22,11 @@
                     </div>
                     <div class="card-body">
                         <div class="row">
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
                                 <small class="text-muted">Created</small><br />
                                 {{ token.created|annotated_date }}
                             </div>
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
                                 <small class="text-muted">Expires</small><br />
                                 {% if token.expires %}
                                     {{ token.expires|annotated_date }}
@@ -34,7 +34,7 @@
                                     <span>Never</span>
                                 {% endif %}
                             </div>
-                            <div class="col col-md-4">
+                            <div class="col col-md-3">
                                 <small class="text-muted">Create/Edit/Delete Operations</small><br />
                                 {% if token.write_enabled %}
                                     <span class="badge bg-success">Enabled</span>
@@ -42,7 +42,14 @@
                                     <span class="badge bg-danger">Disabled</span>
                                 {% endif %}
                             </div>
-                        </div>
+                            <div class="col col-md-3">
+                                <small class="text-muted">Allowed Source IPs</small><br />
+                                {% if token.allowed_ips %}
+                                    {{ token.allowed_ips|join:', ' }}
+                                {% else %}
+                                    <span>Any</span>
+                                {% endif %}
+                            </div>                        </div>
                         {% if token.description %}
                             <br /><span>{{ token.description }}</span>
                         {% endif %}

+ 5 - 1
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

+ 1 - 1
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
 

+ 1 - 1
netbox/users/api/serializers.py

@@ -67,7 +67,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:

+ 9 - 1
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(),

+ 20 - 0
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),
+        ),
+    ]

+ 27 - 0
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