Просмотр исходного кода

Closes #22409: Disallow chosen-plaintext API tokens

Jeremy Stretch 3 недель назад
Родитель
Сommit
814050a3c9

+ 1 - 1
docs/integrations/rest-api.md

@@ -781,7 +781,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
 
 
 ### Tokens
 ### 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"
 !!! 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.
     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.

+ 1 - 2
netbox/users/api/serializers_/tokens.py

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

+ 1 - 6
netbox/users/forms/bulk_import.py

@@ -44,15 +44,10 @@ class TokenImportForm(CSVModelForm):
         required=False,
         required=False,
         help_text=_("Specify version 1 or 2 (v2 will be used by default)")
         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:
     class Meta:
         model = Token
         model = Token
-        fields = ('user', 'version', 'token', 'enabled', 'write_enabled', 'expires', 'description',)
+        fields = ('user', 'version', 'enabled', 'write_enabled', 'expires', 'description',)
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)

+ 7 - 0
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."))
             raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined."))
 
 
         if self._state.adding:
         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:
             if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS:
                 raise ValidationError(_(
                 raise ValidationError(_(
                     "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS."
                     "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS."

+ 37 - 0
netbox/users/tests/test_api.py

@@ -357,6 +357,43 @@ class TokenTestCase(
         # Each token should be unique
         # Each token should be unique
         self.assertEqual(len(plaintexts), len(data))
         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):
     def test_reassign_token(self):
         """
         """
         Check that a Token cannot be reassigned to another User.
         Check that a Token cannot be reassigned to another User.

+ 4 - 4
netbox/users/tests/test_views.py

@@ -245,10 +245,10 @@ class TokenTestCase(
         }
         }
 
 
         cls.csv_data = (
         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 = (
         cls.csv_update_data = (