Skip to content

Commit 2587720

Browse files
author
Pieter Lambrecht
committed
Fix 8878: Restrict API key usage by Source IP
1 parent 098ef91 commit 2587720

File tree

9 files changed

+101
-8
lines changed

9 files changed

+101
-8
lines changed

docs/release-notes/version-3.3.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
88
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
9+
* [#8878](https://github.com/netbox-community/netbox/issues/8878) - Restrict API key usage by source IP
910

1011
### REST API Changes
1112

netbox/netbox/api/authentication.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.conf import settings
2+
from django.core.exceptions import ValidationError
23
from rest_framework import authentication, exceptions
34
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
45

@@ -11,6 +12,31 @@ class TokenAuthentication(authentication.TokenAuthentication):
1112
"""
1213
model = Token
1314

15+
def authenticate(self, request):
16+
authenticationresult = super().authenticate(request)
17+
if authenticationresult:
18+
token_user, token = authenticationresult
19+
20+
# Verify source IP is allowed
21+
if token.allowed_ips:
22+
# Replace 'HTTP_X_REAL_IP' with the settings variable choosen in #8867
23+
if 'HTTP_X_REAL_IP' in request.META:
24+
clientip = request.META['HTTP_X_REAL_IP'].split(",")[0].strip()
25+
http_header = 'HTTP_X_REAL_IP'
26+
elif 'REMOTE_ADDR' in request.META:
27+
clientip = request.META['REMOTE_ADDR']
28+
http_header = 'REMOTE_ADDR'
29+
else:
30+
raise exceptions.AuthenticationFailed(f"A HTTP header containing the SourceIP (HTTP_X_REAL_IP, REMOTE_ADDR) is missing from the request.")
31+
32+
try:
33+
if not token.validate_client_ip(clientip):
34+
raise exceptions.AuthenticationFailed(f"Source IP {clientip} is not allowed to use this token.")
35+
except ValidationError as ValidationErrorInfo:
36+
raise exceptions.ValidationError(f"The value in the HTTP Header {http_header} has a ValidationError: {ValidationErrorInfo.message}")
37+
38+
return authenticationresult
39+
1440
def authenticate_credentials(self, key):
1541
model = self.get_model()
1642
try:

netbox/templates/users/api_tokens.html

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,34 @@
2222
</div>
2323
<div class="card-body">
2424
<div class="row">
25-
<div class="col col-md-4">
25+
<div class="col col-md-3">
2626
<small class="text-muted">Created</small><br />
2727
{{ token.created|annotated_date }}
2828
</div>
29-
<div class="col col-md-4">
29+
<div class="col col-md-3">
3030
<small class="text-muted">Expires</small><br />
3131
{% if token.expires %}
3232
{{ token.expires|annotated_date }}
3333
{% else %}
3434
<span>Never</span>
3535
{% endif %}
3636
</div>
37-
<div class="col col-md-4">
37+
<div class="col col-md-3">
3838
<small class="text-muted">Create/Edit/Delete Operations</small><br />
3939
{% if token.write_enabled %}
4040
<span class="badge bg-success">Enabled</span>
4141
{% else %}
4242
<span class="badge bg-danger">Disabled</span>
4343
{% endif %}
4444
</div>
45-
</div>
45+
<div class="col col-md-3">
46+
<small class="text-muted">Allowed Source IPs</small><br />
47+
{% if token.allowed_ips %}
48+
{{ token.allowed_ips|join:', ' }}
49+
{% else %}
50+
<span>Any</span>
51+
{% endif %}
52+
</div> </div>
4653
{% if token.description %}
4754
<br /><span>{{ token.description }}</span>
4855
{% endif %}

netbox/users/admin/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ def get_inlines(self, request, obj):
5858
class TokenAdmin(admin.ModelAdmin):
5959
form = forms.TokenAdminForm
6060
list_display = [
61-
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
61+
'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'list_allowed_ips'
6262
]
6363

64+
def list_allowed_ips(self, obj):
65+
return obj.allowed_ips or 'Any'
66+
list_allowed_ips.short_description = "Allowed IPs"
67+
6468

6569
#
6670
# Permissions

netbox/users/admin/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
5151

5252
class Meta:
5353
fields = [
54-
'user', 'key', 'write_enabled', 'expires', 'description'
54+
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
5555
]
5656
model = Token
5757

netbox/users/api/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class TokenSerializer(ValidatedModelSerializer):
6262

6363
class Meta:
6464
model = Token
65-
fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description')
65+
fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', 'allowed_ips')
6666

6767
def to_internal_value(self, data):
6868
if 'key' not in data:

netbox/users/forms.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from django import forms
22
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
3+
from django.contrib.postgres.forms import SimpleArrayField
34
from django.utils.html import mark_safe
45

6+
from ipam.formfields import IPNetworkFormField
57
from netbox.preferences import PREFERENCES
68
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
79
from utilities.utils import flatten_dict
@@ -100,10 +102,16 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
100102
help_text="If no key is provided, one will be generated automatically."
101103
)
102104

105+
allowed_ips = SimpleArrayField(
106+
base_field=IPNetworkFormField(),
107+
required=False,
108+
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"',
109+
)
110+
103111
class Meta:
104112
model = Token
105113
fields = [
106-
'key', 'write_enabled', 'expires', 'description',
114+
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
107115
]
108116
widgets = {
109117
'expires': DateTimePicker(),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 3.2.12 on 2022-04-19 12:37
2+
3+
import django.contrib.postgres.fields
4+
from django.db import migrations
5+
import ipam.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('users', '0002_standardize_id_fields'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='token',
17+
name='allowed_ips',
18+
field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None),
19+
),
20+
]

netbox/users/models.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
from django.contrib.auth.models import Group, User
55
from django.contrib.contenttypes.models import ContentType
66
from django.contrib.postgres.fields import ArrayField
7+
from django.core.exceptions import ValidationError
78
from django.core.validators import MinLengthValidator
89
from django.db import models
910
from django.db.models.signals import post_save
1011
from django.dispatch import receiver
1112
from django.utils import timezone
1213

14+
from ipam.fields import IPNetworkField
1315
from netbox.config import get_config
1416
from utilities.querysets import RestrictedQuerySet
1517
from utilities.utils import flatten_dict
1618
from .constants import *
1719

20+
import ipaddress
1821

1922
__all__ = (
2023
'ObjectPermission',
@@ -216,6 +219,12 @@ class Token(models.Model):
216219
max_length=200,
217220
blank=True
218221
)
222+
allowed_ips = ArrayField(
223+
base_field=IPNetworkField(),
224+
blank=True,
225+
null=True,
226+
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"',
227+
)
219228

220229
class Meta:
221230
pass
@@ -240,6 +249,24 @@ def is_expired(self):
240249
return False
241250
return True
242251

252+
def validate_client_ip(self, raw_ip_address):
253+
"""
254+
Checks that an IP address falls within the allowed IPs.
255+
"""
256+
if not self.allowed_ips:
257+
return True
258+
259+
try:
260+
ip_address = ipaddress.ip_address(raw_ip_address)
261+
except ValueError as e:
262+
raise ValidationError(str(e))
263+
264+
for ip_network in self.allowed_ips:
265+
if ip_address in ipaddress.ip_network(ip_network):
266+
return True
267+
268+
return False
269+
243270

244271
#
245272
# Permissions

0 commit comments

Comments
 (0)