From ef62731c0aabca01d6471e3bbae0fc440b8c88aa Mon Sep 17 00:00:00 2001 From: Lydia Vilchez Date: Mon, 31 Aug 2026 21:31:31 +0200 Subject: [PATCH] fix(api): tighten Azure certificate cap and route oversized/whitespace inputs through the typed error --- api/src/backend/api/specs/v1.yaml | 16 ++++---- api/src/backend/api/tests/test_serializers.py | 41 +++++++++++++++++-- api/src/backend/api/v1/serializers.py | 26 ++++++++++-- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index ec31cbd03b..c23b1112c4 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -21318,7 +21318,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 tenant_id: type: string description: The Azure tenant ID, representing the directory @@ -21371,7 +21371,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 required: - client_id - tenant_id @@ -23469,7 +23469,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 tenant_id: type: string description: The Azure tenant ID, representing the directory where @@ -23522,7 +23522,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 required: - client_id - tenant_id @@ -23936,7 +23936,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 tenant_id: type: string description: The Azure tenant ID, representing the directory @@ -23989,7 +23989,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 required: - client_id - tenant_id @@ -24425,7 +24425,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 tenant_id: type: string description: The Azure tenant ID, representing the directory where @@ -24478,7 +24478,7 @@ components: type: string description: Base64-encoded PEM or PKCS#12/PFX bundle containing the certificate and its matching private key for certificate-based authentication. - maxLength: 68266 + maxLength: 68268 required: - client_id - tenant_id diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 0d54417acf..ca153ffbe8 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -370,9 +370,12 @@ class TestAzureProviderSecret: ) def test_rejects_oversized_certificate_content(self): - # Payloads larger than the base64 cap must be rejected at the DRF - # field layer before base64 decoding or bundle parsing runs, so a - # multi-MB blob cannot exhaust API-worker memory. + # Payloads larger than the base64 cap must be rejected inside + # `validate_certificate_content` (before base64 decoding or bundle + # parsing runs) so a multi-MB blob cannot exhaust API-worker + # memory. The typed `azure-certificate-content` code lets JSON:API + # clients recognize this as a certificate failure rather than a + # generic length violation. from api.v1.serializers import _MAX_CERTIFICATE_CONTENT_LENGTH serializer = AzureProviderSecret( @@ -384,6 +387,38 @@ class TestAzureProviderSecret: assert not serializer.is_valid() assert "certificate_content" in serializer.errors + assert ( + serializer.errors["certificate_content"][0].code + == "azure-certificate-content" + ) + + def test_tolerates_whitespace_in_certificate_content(self): + # A base64 payload with embedded whitespace (CRLF from a Windows + # terminal, wrapped copy-paste) must not be rejected as "invalid + # base64" — whitespace carries no information in the encoding. + import base64 + + bundle_b64 = self.certificate_bundle() + # Insert CRLF every 64 chars and leading/trailing spaces to mimic + # a copy-paste from a terminal export. + wrapped = ( + " " + + "\r\n".join(bundle_b64[i : i + 64] for i in range(0, len(bundle_b64), 64)) + + " " + ) + + serializer = AzureProviderSecret( + data={**self.BASE, "certificate_content": wrapped} + ) + + assert serializer.is_valid(), serializer.errors + # The stored value is the whitespace-stripped payload, so the SDK + # sees exactly the bytes it would from a clean base64 upload. + assert serializer.validated_data["certificate_content"] == bundle_b64 + # And it still decodes to the original bundle unchanged. + assert base64.b64decode( + serializer.validated_data["certificate_content"] + ) == base64.b64decode(bundle_b64) def test_mutex_errors_carry_stable_codes(self): # JSON:API clients key on `code`; without it they cannot tell the diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 9cf9e646b3..071d8708fa 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1793,16 +1793,21 @@ class AwsProviderSecret(serializers.Serializer): # limit on the decoded bundle. Rejects oversized payloads at the request # layer so DRF never allocates the doubled memory that base64 decoding plus # PKCS#12/PEM parsing would need for a multi-MB blob. -_MAX_CERTIFICATE_CONTENT_LENGTH = 68266 +# `((51200 + 2) // 3) * 4` = 68268 base64 chars for a bundle exactly at the +# SDK cap. Keep the two constants aligned if either side moves. +_MAX_CERTIFICATE_CONTENT_LENGTH = 68268 class AzureProviderSecret(serializers.Serializer): client_id = serializers.CharField() client_secret = serializers.CharField(required=False) tenant_id = serializers.CharField() - certificate_content = serializers.CharField( - required=False, max_length=_MAX_CERTIFICATE_CONTENT_LENGTH - ) + # The size cap is enforced in `validate_certificate_content` with the + # typed `azure-certificate-content` code, not via `max_length=`. DRF's + # built-in max-length check runs before per-field validators and emits + # `code="max_length"`, which JSON:API clients keyed to the typed + # certificate error code cannot recognize as a certificate failure. + certificate_content = serializers.CharField(required=False) def validate(self, attrs): if attrs.get("client_secret") and attrs.get("certificate_content"): @@ -1820,6 +1825,19 @@ class AzureProviderSecret(serializers.Serializer): def validate_certificate_content(self, certificate_content): """Validate the Azure certificate and matching private-key bundle.""" if certificate_content: + # Tolerate whitespace inside the base64 payload: exports from + # Windows terminals (CRLF) or wrapped copy-paste survive without + # tripping `base64.b64decode(validate=True)`, and the reader is + # base64 anyway — internal whitespace carries no information. + certificate_content = "".join(certificate_content.split()) + if len(certificate_content) > _MAX_CERTIFICATE_CONTENT_LENGTH: + # Reject oversized payloads with the typed certificate code + # so JSON:API clients recognize this as a certificate-content + # failure rather than a generic length violation. + raise serializers.ValidationError( + "Certificate content exceeds the maximum size.", + code="azure-certificate-content", + ) try: certificate_data = base64.b64decode(certificate_content, validate=True) validate_certificate_bundle(certificate_data)