diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index b47064c84ce..3c5afa94eab 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,6 +1,6 @@ import hashlib import hmac -import random +import secrets import zoneinfo from django.conf import settings @@ -260,7 +260,7 @@ def generate(length=TOKEN_DEFAULT_LENGTH): """ Generate and return a random token value of the given length. """ - return ''.join(random.choice(TOKEN_CHARSET) for _ in range(length)) + return ''.join(secrets.choice(TOKEN_CHARSET) for _ in range(length)) def update_digest(self): """ diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 7c1f5556e88..fc59fe1560c 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,10 +1,13 @@ +import secrets from datetime import timedelta +from unittest.mock import patch from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from django.utils import timezone from users.choices import TokenVersionChoices +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH from users.models import Token, User from utilities.testing import create_test_user @@ -104,6 +107,29 @@ def test_v2_without_peppers_configured(self): with self.assertRaises(ValidationError): token.clean() + def test_generate_uses_csprng(self): + """ + Regression: Token.generate() must use secrets.choice (CSPRNG), not random.choice + (Mersenne Twister). Verify that the call is routed through the secrets module. + """ + with patch('users.models.tokens.secrets.choice', wraps=secrets.choice) as mock_choice: + value = Token.generate() + + self.assertEqual(mock_choice.call_count, TOKEN_DEFAULT_LENGTH, + "secrets.choice must be called once per token character") + self.assertEqual(len(value), TOKEN_DEFAULT_LENGTH) + self.assertTrue(all(c in TOKEN_CHARSET for c in value), + "Generated token must only contain characters from TOKEN_CHARSET") + + def test_generate_length_parameter(self): + """ + Token.generate(length=N) returns a string of exactly N characters from TOKEN_CHARSET. + """ + for length in (8, 20, TOKEN_DEFAULT_LENGTH, 64): + value = Token.generate(length=length) + self.assertEqual(len(value), length, f"Expected length {length}, got {len(value)}") + self.assertTrue(all(c in TOKEN_CHARSET for c in value)) + class UserConfigTestCase(TestCase):