diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index d8d79b6ec..6d21bee09 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -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)
diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py
index 5e177bfcb..f9c720e43 100644
--- a/netbox/netbox/api/authentication.py
+++ b/netbox/netbox/api/authentication.py
@@ -11,13 +11,49 @@ class TokenAuthentication(authentication.TokenAuthentication):
"""
model = Token
- def authenticate_credentials(self, key):
+ def authenticate(self, request):
+ auth = authentication.get_authorization_header(request).split()
+
+ if not auth or auth[0].lower() != self.keyword.lower().encode():
+ return None
+
+ if len(auth) == 1:
+ msg = 'Invalid token header. No credentials provided.'
+ raise exceptions.AuthenticationFailed(msg)
+ elif len(auth) > 2:
+ msg = 'Invalid token header. Token string should not contain spaces.'
+ raise exceptions.AuthenticationFailed(msg)
+
+ try:
+ token = auth[1].decode()
+ except UnicodeError:
+ msg = 'Invalid token header. Token string should not contain invalid characters.'
+ raise exceptions.AuthenticationFailed(msg)
+
+ return self.authenticate_credentials(request,token)
+
+
+ def authenticate_credentials(self, request, key):
model = self.get_model()
try:
token = model.objects.prefetch_related('user').get(key=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")
+ # Verify source IP is allowed
+ if len(token.allowed_ipranges) > 0:
+
+ 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")
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index d69c45fc9..1b0684d35 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -115,6 +115,7 @@ REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []
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)
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html
index 01ffec23a..51cc3fb97 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/api_tokens.html
@@ -34,6 +34,14 @@
Never
{% endif %}
+
+ Allowed IP Sources
+ {% if token.allowed_ipranges %}
+ {{ token.allowed_ipranges }}
+ {% else %}
+ Everywhere
+ {% endif %}
+
Create/Edit/Delete Operations
{% if token.write_enabled %}
diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py
index 1b163ed06..9a115275f 100644
--- a/netbox/users/admin/__init__.py
+++ b/netbox/users/admin/__init__.py
@@ -58,7 +58,7 @@ 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', 'allowed_ipranges'
]
diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py
index 7d0212441..a146dfc07 100644
--- a/netbox/users/admin/forms.py
+++ b/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_ipranges'
]
model = Token
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index d490e8fe9..8c30e4269 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -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:
diff --git a/netbox/users/forms.py b/netbox/users/forms.py
index 8bd54cb66..ff2419fe5 100644
--- a/netbox/users/forms.py
+++ b/netbox/users/forms.py
@@ -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(),
diff --git a/netbox/users/migrations/0002_token_allowed_ipranges.py b/netbox/users/migrations/0002_token_allowed_ipranges.py
new file mode 100644
index 000000000..dadf8044c
--- /dev/null
+++ b/netbox/users/migrations/0002_token_allowed_ipranges.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 64b6432a7..b2d3454c1 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -4,6 +4,7 @@ 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
@@ -14,6 +15,7 @@ from netbox.models import BigIDModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import flatten_dict
from .constants import *
+import ipaddress
__all__ = (
@@ -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
@@ -214,6 +221,9 @@ class Token(BigIDModel):
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
@@ -227,6 +237,59 @@ class Token(BigIDModel):
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:
+ 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:
+ 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