From 5dc48f3a8895f7c1e5a359d0075bd309e89dd248 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:26:22 -0400 Subject: [PATCH] Enforce a fixed key length for v2 tokens --- netbox/users/constants.py | 3 +++ .../users/migrations/0014_users_token_v2.py | 12 +++++++++++- netbox/users/models/tokens.py | 19 +++++++++---------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/netbox/users/constants.py b/netbox/users/constants.py index b02c482e0..647249179 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -10,4 +10,7 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( CONSTRAINT_TOKEN_USER = '$user' +# API tokens +TOKEN_KEY_LENGTH = 16 +TOKEN_DEFAULT_LENGTH = 40 TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 9e18e4a72..39f1cbf48 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -21,6 +21,7 @@ class Migration(migrations.Migration): migrations.RunSQL( sql="ALTER INDEX IF EXISTS users_token_key_key RENAME TO users_token_plaintext_key", ), + # Make plaintext (formerly key) nullable for v2 tokens migrations.AlterField( model_name='token', @@ -33,6 +34,7 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinLengthValidator(40)] ), ), + # Add version field to distinguish v1 and v2 tokens migrations.AddField( model_name='token', @@ -40,17 +42,25 @@ class Migration(migrations.Migration): field=models.PositiveSmallIntegerField(default=1), # Mark all existing Tokens as v1 preserve_default=False, ), + # Change the default version for new tokens to v2 migrations.AlterField( model_name='token', name='version', field=models.PositiveSmallIntegerField(default=2), ), + # Add new key, pepper, and hmac_digest fields for v2 tokens migrations.AddField( model_name='token', name='key', - field=models.CharField(blank=True, max_length=16, null=True, unique=True), + field=models.CharField( + blank=True, + max_length=16, + null=True, + unique=True, + validators=[django.core.validators.MinLengthValidator(16)] + ), ), migrations.AddField( model_name='token', diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index cf35c4e6a..96aa8e821 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,6 @@ -import binascii import hashlib import hmac import random -import os from django.conf import settings from django.contrib.postgres.fields import ArrayField @@ -16,7 +14,7 @@ from netaddr import IPNetwork from ipam.fields import IPNetworkField from users.choices import TokenVersionChoices -from users.constants import TOKEN_CHARSET +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet @@ -75,10 +73,11 @@ class Token(models.Model): ) key = models.CharField( verbose_name=_('key'), - max_length=16, + max_length=TOKEN_KEY_LENGTH, unique=True, blank=True, null=True, + validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], help_text=_('v2 token identification key'), ) pepper = models.PositiveSmallIntegerField( @@ -148,7 +147,7 @@ class Token(models.Model): if self.v1: self.plaintext = value elif self.v2: - self.key = self.key or self.generate(16) + self.key = self.key or self.generate_key() self.update_digest() def clean(self): @@ -162,15 +161,15 @@ class Token(models.Model): return super().save(*args, **kwargs) - @staticmethod - def generate_key(): + @classmethod + def generate_key(cls): """ - DEPRECATED: Generate and return a random 160-bit key expressed in hexadecimal. + Generate and return a random alphanumeric key for v2 tokens. """ - return binascii.hexlify(os.urandom(20)).decode() + return cls.generate(length=TOKEN_KEY_LENGTH) @staticmethod - def generate(length=40): + def generate(length=TOKEN_DEFAULT_LENGTH): """ Generate and return a random token value of the given length. """