1
0
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:
Pieter Lambrecht
2022-04-19 14:44:35 +02:00
parent 098ef91583
commit 2587720298
9 changed files with 101 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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),
),
]

View File

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