mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Fix 8878: Restrict API key usage by Source IP
This commit is contained in:
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
netbox/users/migrations/0003_token_allowed_ips.py
Normal file
20
netbox/users/migrations/0003_token_allowed_ips.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
Reference in New Issue
Block a user