Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/configuration/optional-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,15 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff

---

## PROXY_HEADER_REALIP

Default: HTTP_X_REAL_IP

This parameters sets the HTTP Header that contains the REAL IP of a client that connects through a PROXY. The Real IP is required to validate an API token's Allowed IPRanges.
Other common values are HTTP_X_FORWARDED_FOR, HTTP_X_CLIENT_IP

---

## RELEASE_CHECK_URL

Default: None (disabled)
Expand Down
10 changes: 10 additions & 0 deletions docs/release-notes/version-3.1.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# NetBox v3.1

## v3.1.10 (FUTURE)

### Enhancements

* [#8233](https://github.com/netbox-community/netbox/issues/8233) - Restrict API key usage by source IP

### Bug Fixes

---

## v3.1.9 (2022-03-07)

### Enhancements
Expand Down
19 changes: 19 additions & 0 deletions netbox/netbox/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -18,6 +23,20 @@ def authenticate_credentials(self, key):
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")

# Verify source IP is allowed
request = self.request
if len(token.allowed_ipranges) > 0 and request:

if settings.PROXY_HEADER_REALIP in request.META:
clientip = request.META[settings.PROXY_HEADER_REALIP].split(",")[0].strip()
elif 'REMOTE_ADDR' in request.META:
clientip = request.META['REMOTE_ADDR']
else:
raise exceptions.AuthenticationFailed(f"The request headers ({settings.PROXY_HEADER_REALIP}, REMOTE_ADDR) are missing or do not contain a valid source IP.")

if not token.validateclientip(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")
Expand Down
1 change: 1 addition & 0 deletions netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
PROXY_HEADER_REALIP = getattr(configuration, 'PROXY_HEADER_REALIP', 'HTTP_X_REAL_IP')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
Expand Down
8 changes: 8 additions & 0 deletions netbox/templates/users/api_tokens.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
<span>Never</span>
{% endif %}
</div>
<div class="col col-md-4">
<small class="text-muted">Allowed IP Sources</small><br />
{% if token.allowed_ipranges %}
{{ token.allowed_ipranges }}
{% else %}
<span>Everywhere</span>
{% endif %}
</div>
<div class="col col-md-4">
<small class="text-muted">Create/Edit/Delete Operations</small><br />
{% if token.write_enabled %}
Expand Down
2 changes: 1 addition & 1 deletion netbox/users/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def get_inlines(self, request, obj):
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
'key', 'user', 'created', 'expires', 'write_enabled', 'description', 'allowed_ipranges'
]


Expand Down
2 changes: 1 addition & 1 deletion netbox/users/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):

class Meta:
fields = [
'user', 'key', 'write_enabled', 'expires', 'description'
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ipranges'
]
model = Token

Expand Down
2 changes: 1 addition & 1 deletion netbox/users/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_ipranges')

def to_internal_value(self, data):
if 'key' not in data:
Expand Down
2 changes: 1 addition & 1 deletion netbox/users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_ipranges',
]
widgets = {
'expires': DateTimePicker(),
Expand Down
18 changes: 18 additions & 0 deletions netbox/users/migrations/0002_token_allowed_ipranges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-03-11 13:39

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0001_squashed_0011'),
]

operations = [
migrations.AddField(
model_name='token',
name='allowed_ipranges',
field=models.CharField(blank=True, max_length=250),
),
]
64 changes: 64 additions & 0 deletions netbox/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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
Expand All @@ -14,6 +15,7 @@
from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict
from .constants import *
import ipaddress


__all__ = (
Expand Down Expand Up @@ -203,6 +205,11 @@ class Token(BigIDModel):
max_length=200,
blank=True
)
allowed_ipranges = models.CharField(
max_length=250,
blank=True,
help_text='Allowed ip addresses/ranges from where the token can be used (comma separated). Leave blank for no restrictions. Ex: "10.1.1.0/24, 192.168.10.16, 2001:DB8:1::/64"',
)

class Meta:
pass
Expand All @@ -214,6 +221,9 @@ def __str__(self):
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
if not self.validateipranges(self.allowed_ipranges):
raise TypeError(f"{self.allowed_ipranges} contains an invalid ip address, range or prefix")

return super().save(*args, **kwargs)

@staticmethod
Expand All @@ -227,6 +237,60 @@ def is_expired(self):
return False
return True

@staticmethod
def validateipranges(ip_addresses):
"""
Checks that the value is a comma separated list of IPv4 and/or IPv6 addresses, ranges or subnets.
"""
if len(ip_addresses) == 0:
return True

for ip in ip_addresses.split(','):
try:
if '/' in ip:
iptest = ipaddress.ip_network(ip)
elif '-' in ip:
ips = ip.split('-')
ip1 = ipaddress.ip_address(ips[0])
ip2 = ipaddress.ip_address(ips[1])
if ip1 > ip2:
raise ValidationError()
else:
iptest = ipaddress.ip_address(ip)
except ValueError:
raise ValidationError(f"{ip} is an invalid value in the Allowed IP Ranges ({ip_addresses})")

return True

def validateclientip(self, raw_ip_address):
"""
Checks that an ip address falls within the allowed ip ranges.
"""
if len(self.allowed_ipranges) == 0:
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 ip in self.allowed_ipranges.split(','):
if '/' in ip:
ipnet = ipaddress.ip_network(ip)
if ip_address in ipnet:
return True
elif '-' in ip:
ips = ip.split('-')
ip1 = ipaddress.ip_address(ips[0])
ip2 = ipaddress.ip_address(ips[1])
if ip_address >= ip1 and ip_address <= ip2:
return True
else:
ipaddr = ipaddress.ip_address(ip)
if ip_address == ipaddr:
return True
return False


#
# Permissions
Expand Down