From 814050a3c908f1a72be140fc17eaa5e52cbbd510 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2026 13:57:25 -0400 Subject: [PATCH] Closes #22409: Disallow chosen-plaintext API tokens --- docs/integrations/rest-api.md | 2 +- netbox/users/api/serializers_/tokens.py | 3 +- netbox/users/forms/bulk_import.py | 7 +---- netbox/users/models/tokens.py | 7 +++++ netbox/users/tests/test_api.py | 37 +++++++++++++++++++++++++ netbox/users/tests/test_views.py | 8 +++--- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index d55471168..6533d5ee9 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -781,7 +781,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc ### Tokens -A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value. +A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically generate a random token value. This value is always generated by the server and cannot be specified by the client; any `token` value included in a creation request is ignored. !!! note "Tokens cannot be retrieved once created" Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created. diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index ce467e1a6..2b9687436 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -16,8 +16,7 @@ __all__ = ( class TokenSerializer(ValidatedModelSerializer): token = serializers.CharField( - required=False, - default=Token.generate, + read_only=True, ) user = UserSerializer( nested=True diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 776fb6932..54d27623b 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -44,15 +44,10 @@ class TokenImportForm(CSVModelForm): required=False, help_text=_("Specify version 1 or 2 (v2 will be used by default)") ) - token = forms.CharField( - label=_('Token'), - required=False, - help_text=_("If no token is provided, one will be generated automatically.") - ) class Meta: model = Token - fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',) + fields = ('user', 'version', 'enabled', 'write_enabled', 'expires', 'description',) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index b47064c84..ea3b9f199 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -221,6 +221,13 @@ class Token(models.Model): raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined.")) if self._state.adding: + # Ensure a randomly-generated plaintext is always assigned to new tokens. A client-supplied value is + # never accepted via the REST API (the serializer's `token` field is read-only); generating it here + # guarantees the version-dependent key/digest fields are populated before constraint validation + # (full_clean) runs. + if self.token is None: + self.token = self.generate() + if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: raise ValidationError(_( "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index a50ce74bc..28bebe1b0 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -357,6 +357,43 @@ class TokenTestCase( # Each token should be unique self.assertEqual(len(plaintexts), len(data)) + def test_create_token_ignores_client_supplied_plaintext(self): + """ + A client must not be able to choose a Token's plaintext value. Any `token` value supplied in a + create request must be ignored in favor of a randomly-generated value (for both v1 and v2 tokens). + """ + self.add_permissions('users.add_token') + url = reverse('users-api:token-list') + chosen_plaintext = 'a' * TOKEN_DEFAULT_LENGTH + + for version in (1, 2): + user = User.objects.create_user(username=f'chosen_plaintext_user_v{version}') + data = { + 'user': user.pk, + 'version': version, + 'token': chosen_plaintext, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 201) + + # The returned plaintext must not match the client-supplied value + returned = response.data['token'] + self.assertIsNotNone(returned) + self.assertEqual(len(returned), TOKEN_DEFAULT_LENGTH) + self.assertNotEqual(returned, chosen_plaintext) + + # The stored secret must be the randomly-generated value, not the client-supplied one + token = Token.objects.get(pk=response.data['id']) + self.assertEqual(token.version, version) + if version == 1: + # v1 tokens are authenticated by direct plaintext lookup + self.assertEqual(token.plaintext, returned) + self.assertNotEqual(token.plaintext, chosen_plaintext) + else: + # v2 tokens are authenticated by validating the plaintext against the stored HMAC digest + self.assertTrue(token.validate(returned)) + self.assertFalse(token.validate(chosen_plaintext)) + def test_reassign_token(self): """ Check that a Token cannot be reassigned to another User. diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 97a91ab79..b7d5ebe55 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -245,10 +245,10 @@ class TokenTestCase( } cls.csv_data = ( - "token,user,description,enabled,write_enabled", - f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token,true,true", - f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token,true,false", - f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token,false,true", + "user,description,enabled,write_enabled", + f"{users[0].pk},Test token,true,true", + f"{users[1].pk},Test token,true,false", + f"{users[1].pk},Test token,false,true", ) cls.csv_update_data = (