diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index c23b1112c4..9ec4daf914 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -21369,9 +21369,8 @@ components: where the application is registered. certificate_content: type: string - description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate - and its matching private key for certificate-based authentication. - maxLength: 68268 + description: The certificate content in base64 format for certificate-based + authentication. required: - client_id - tenant_id @@ -23520,9 +23519,8 @@ components: the application is registered. certificate_content: type: string - description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate - and its matching private key for certificate-based authentication. - maxLength: 68268 + description: The certificate content in base64 format for certificate-based + authentication. required: - client_id - tenant_id @@ -23987,9 +23985,8 @@ components: where the application is registered. certificate_content: type: string - description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate - and its matching private key for certificate-based authentication. - maxLength: 68268 + description: The certificate content in base64 format for certificate-based + authentication. required: - client_id - tenant_id @@ -24476,9 +24473,8 @@ components: the application is registered. certificate_content: type: string - description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate - and its matching private key for certificate-based authentication. - maxLength: 68268 + description: The certificate content in base64 format for certificate-based + authentication. required: - client_id - tenant_id diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 7b153efb8f..92dae204bb 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3444,6 +3444,153 @@ current-context: test-context provider_secret.refresh_from_db() assert "region" not in provider_secret.secret + @staticmethod + def _azure_certificate_bundle_base64(): + import base64 + from datetime import UTC, datetime, timedelta + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prowler")]) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC)) + .not_valid_after(datetime.now(UTC) + timedelta(days=1)) + .sign(private_key, hashes.SHA256()) + ) + bundle = certificate.public_bytes( + serialization.Encoding.PEM + ) + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return base64.b64encode(bundle).decode("ascii") + + def test_provider_secrets_create_azure_persists_whitespace_stripped_certificate( + self, + authenticated_client, + azure_provider, + ): + # `AzureProviderSecret.validate_certificate_content` strips whitespace + # inside the base64 payload. The outer write path must reassign the + # validator's normalized output so the value that reaches Fernet + # storage matches what the SDK will later decode. + clean_b64 = self._azure_certificate_bundle_base64() + wrapped_b64 = ( + " " + + "\r\n".join(clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64)) + + " " + ) + + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Azure Cert Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "client_id": "87654321-4321-4321-4321-210987654321", + "tenant_id": "12345678-1234-1234-1234-123456789012", + "certificate_content": wrapped_b64, + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(azure_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"]) + # Persisted value is the whitespace-stripped payload — proves the + # per-provider validator's return value is what Fernet encrypted, + # not the raw upload. + assert provider_secret.secret["certificate_content"] == clean_b64 + + def test_provider_secrets_update_azure_persists_whitespace_stripped_certificate( + self, + authenticated_client, + azure_provider, + ): + create_response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps( + { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "Azure Cert Secret", + "secret_type": ProviderSecret.TypeChoices.STATIC, + "secret": { + "client_id": "87654321-4321-4321-4321-210987654321", + "tenant_id": "12345678-1234-1234-1234-123456789012", + "certificate_content": self._azure_certificate_bundle_base64(), + }, + }, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": str(azure_provider.id), + } + } + }, + } + } + ), + content_type="application/vnd.api+json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + + provider_secret = ProviderSecret.objects.get( + id=create_response.json()["data"]["id"] + ) + + clean_b64 = self._azure_certificate_bundle_base64() + wrapped_b64 = "\r\n".join( + clean_b64[i : i + 64] for i in range(0, len(clean_b64), 64) + ) + 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": { + "client_id": "87654321-4321-4321-4321-210987654321", + "tenant_id": "12345678-1234-1234-1234-123456789012", + "certificate_content": wrapped_b64, + } + }, + } + } + ), + 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["certificate_content"] == clean_b64 + @pytest.mark.parametrize( "attributes, error_code, error_pointer", ( diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index a0ec894edf..b5e1b874f7 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -169,7 +169,7 @@ from rest_framework_json_api import serializers }, "certificate_content": { "type": "string", - "description": "Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication.", + "description": "The certificate content in base64 format for certificate-based authentication.", }, }, "required": [ diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 071d8708fa..eb805bfba3 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1871,20 +1871,16 @@ class M365ProviderSecret(serializers.Serializer): tenant_id = serializers.CharField() user = serializers.EmailField(required=False) password = serializers.CharField(required=False) - certificate_content = serializers.CharField( - required=False, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH - ) + certificate_content = serializers.CharField(required=False) def validate(self, attrs): if attrs.get("client_secret") and attrs.get("certificate_content"): raise serializers.ValidationError( - "You cannot provide both client_secret and certificate_content.", - code="m365-credential-mutex", + "You cannot provide both client_secret and certificate_content." ) if not attrs.get("client_secret") and not attrs.get("certificate_content"): raise serializers.ValidationError( - "You must provide either client_secret or certificate_content.", - code="m365-credential-required", + "You must provide either client_secret or certificate_content." ) return super().validate(attrs) @@ -1893,13 +1889,13 @@ class M365ProviderSecret(serializers.Serializer): if certificate_content: try: base64.b64decode(certificate_content, validate=True) - # `base64.b64decode(validate=True)` raises `binascii.Error`; the - # legacy alias `ValueError` is preserved for older builds. - except (binascii.Error, ValueError) as e: - # DRF field validators are already keyed to `certificate_content`, - # so raising a dict here would double-nest the JSON:API pointer. - raise serializers.ValidationError( - f"The provided certificate content is not valid base64 encoded data: {str(e)}", + except Exception as e: + raise ValidationError( + { + "certificate_content": [ + f"The provided certificate content is not valid base64 encoded data: {str(e)}" + ] + }, code="m365-certificate-content", ) return certificate_content @@ -2167,8 +2163,11 @@ class ProviderSecretCreateSerializer( validated_secret = self.validate_secret_based_on_provider( provider.provider, secret_type, secret ) - if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: - validated_attrs["secret"] = validated_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 return validated_attrs @@ -2203,8 +2202,9 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer): validated_secret = self.validate_secret_based_on_provider( provider.provider, secret_type, secret ) - if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: - validated_attrs["secret"] = validated_secret + # Persist the per-provider validator's normalized output — same + # rationale as `ProviderSecretCreateSerializer.validate`. + validated_attrs["secret"] = validated_secret return validated_attrs