From e01ce68f105430044b2e6beefc77ab0ade2fa80f Mon Sep 17 00:00:00 2001 From: Lydia Vilchez Date: Tue, 1 Sep 2026 21:49:16 +0200 Subject: [PATCH] fix(api): scope the secret reassignment to Azure certificate_content only --- api/src/backend/api/tests/test_views.py | 106 ++++++++++++++++++++++++ api/src/backend/api/v1/serializers.py | 41 +++++++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 92dae204bb..f3ed8a2365 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3591,6 +3591,112 @@ current-context: test-context provider_secret.refresh_from_db() assert provider_secret.secret["certificate_content"] == clean_b64 + def test_provider_secrets_create_m365_preserves_whitespace_in_client_secret( + self, + authenticated_client, + m365_provider, + ): + # DRF `CharField.trim_whitespace` (the default) would silently strip + # surrounding whitespace off opaque credentials if the outer write + # path blindly persisted the validated dict. Legitimate M365 client + # secrets can contain leading/trailing whitespace, so the raw + # submitted value must survive the round-trip unchanged. + whitespace_secret = " M365-Secret-With-Padding " + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "M365 Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "client_id": "87654321-4321-4321-4321-210987654321", + "tenant_id": "12345678-1234-1234-1234-123456789012", + "client_secret": whitespace_secret, + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(m365_provider.id)} + } + }, + } + } + + response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.content + provider_secret = ProviderSecret.objects.get(id=response.json()["data"]["id"]) + assert provider_secret.secret["client_secret"] == whitespace_secret + + def test_provider_secrets_update_image_preserves_whitespace_in_registry_password( + self, + authenticated_client, + image_provider, + ): + # Container-registry passwords can be opaque high-entropy strings + # generated by tooling that includes trailing whitespace as part of + # the credential. The outer write path must keep the submitted + # value verbatim so the registry still accepts it after a PATCH. + create_response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps( + { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Registry Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "registry_username": "prowler", + "registry_password": "initial-password", + }, + }, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": str(image_provider.id), + } + } + }, + } + } + ), + content_type="application/vnd.api+json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + + whitespace_password = " registry-password-with-padding " + provider_secret = ProviderSecret.objects.get( + id=create_response.json()["data"]["id"] + ) + response = authenticated_client.patch( + reverse("providersecret-detail", kwargs={"pk": provider_secret.id}), + data=json.dumps( + { + "data": { + "type": "provider-secrets", + "id": str(provider_secret.id), + "attributes": { + "secret": { + "registry_username": "prowler", + "registry_password": whitespace_password, + } + }, + } + } + ), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK, response.content + provider_secret.refresh_from_db() + assert provider_secret.secret["registry_password"] == whitespace_password + @pytest.mark.parametrize( "attributes, error_code, error_pointer", ( diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index eb805bfba3..97ff5d53b9 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2163,11 +2163,24 @@ class ProviderSecretCreateSerializer( validated_secret = self.validate_secret_based_on_provider( provider.provider, secret_type, secret ) - # Persist the per-provider validator's normalized output (e.g. - # `AzureProviderSecret` strips whitespace inside `certificate_content`). - # Previously this reassignment only happened for OCI, so every - # per-provider validator's normalization was discarded on write. - validated_attrs["secret"] = validated_secret + # OCI persists the full validated dict on purpose (its serializer + # already performs the sanitization it wants). For Azure, only the + # `certificate_content` field must be replaced with the normalized + # (whitespace-stripped) value; the rest of the dict is left as the + # caller submitted it so opaque credentials elsewhere in the payload + # keep whatever whitespace they carried. Every other provider stays + # on the outer JSONField's raw dict — DRF's `CharField.trim_whitespace` + # would otherwise silently mutate opaque tokens/passwords. + if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + validated_attrs["secret"] = validated_secret + elif ( + provider.provider == Provider.ProviderChoices.AZURE.value + and validated_secret.get("certificate_content") + ): + validated_attrs["secret"] = { + **secret, + "certificate_content": validated_secret["certificate_content"], + } return validated_attrs @@ -2202,9 +2215,21 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer): validated_secret = self.validate_secret_based_on_provider( provider.provider, secret_type, secret ) - # Persist the per-provider validator's normalized output — same - # rationale as `ProviderSecretCreateSerializer.validate`. - validated_attrs["secret"] = validated_secret + # Same targeted persistence as `ProviderSecretCreateSerializer.validate`: + # OCI keeps its full validated dict, Azure replaces only + # `certificate_content`, and every other provider stays on the raw + # submitted dict so opaque credentials do not get their whitespace + # silently trimmed. + if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + validated_attrs["secret"] = validated_secret + elif ( + provider.provider == Provider.ProviderChoices.AZURE.value + and validated_secret.get("certificate_content") + ): + validated_attrs["secret"] = { + **secret, + "certificate_content": validated_secret["certificate_content"], + } return validated_attrs