Closes #22409: Disallow chosen-plaintext API tokens

This commit is contained in:
Jeremy Stretch 2026-06-08 13:57:25 -04:00
parent 5b6d7887f2
commit 814050a3c9
6 changed files with 51 additions and 13 deletions

View File

@ -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.

View File

@ -16,8 +16,7 @@ __all__ = (
class TokenSerializer(ValidatedModelSerializer):
token = serializers.CharField(
required=False,
default=Token.generate,
read_only=True,
)
user = UserSerializer(
nested=True

View File

@ -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)

View File

@ -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."

View File

@ -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.

View File

@ -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 = (