fix(azure): validate certificate key pairs

- Reject malformed, key-only, and mismatched certificate bundles
- Support matching PEM and unencrypted PKCS#12 credentials
- Cover SDK and API validation paths
This commit is contained in:
Hugo P.Brito committed 2026-08-21 09:45:01 +01:00
1 parent d9b3bbebba
commit 970bb22df2
8 files changed
+420 -20

No files matched your search

+70 -6
View File
@@ -208,9 +208,37 @@ class TestAzureProviderSecret:
"client_id": "87654321-4321-4321-4321-210987654321",
"tenant_id": "12345678-1234-1234-1234-123456789012",
}
# Valid base64 of a tiny DER-shaped payload; the serializer only checks
# that the string decodes as base64, not that it parses as a real cert.
CERT_CONTENT_B64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
@staticmethod
def certificate_bundle():
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_accepts_client_secret_only(self):
# Backwards-compatibility guard: rows saved by the previous serializer
@@ -223,11 +251,12 @@ class TestAzureProviderSecret:
assert "certificate_content" not in serializer.validated_data
def test_accepts_certificate_content_only(self):
certificate_content = self.certificate_bundle()
serializer = AzureProviderSecret(
data={**self.BASE, "certificate_content": self.CERT_CONTENT_B64}
data={**self.BASE, "certificate_content": certificate_content}
)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["certificate_content"] == self.CERT_CONTENT_B64
assert serializer.validated_data["certificate_content"] == certificate_content
assert "client_secret" not in serializer.validated_data
def test_rejects_both_client_secret_and_certificate_content(self):
@@ -238,7 +267,7 @@ class TestAzureProviderSecret:
data={
**self.BASE,
"client_secret": "fake-client-secret",
"certificate_content": self.CERT_CONTENT_B64,
"certificate_content": self.certificate_bundle(),
}
)
assert not serializer.is_valid()
@@ -259,6 +288,41 @@ class TestAzureProviderSecret:
assert not serializer.is_valid()
assert "certificate_content" in serializer.errors
def test_rejects_invalid_tenant_and_client_ids(self):
serializer = AzureProviderSecret(
data={
"tenant_id": "not-a-uuid",
"client_id": "also-not-a-uuid",
"client_secret": "fake-client-secret",
}
)
assert not serializer.is_valid()
assert "tenant_id" in serializer.errors
assert "client_id" in serializer.errors
def test_rejects_key_only_certificate_content(self):
import base64
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
key_only_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
serializer = AzureProviderSecret(
data={
**self.BASE,
"certificate_content": base64.b64encode(key_only_pem).decode("ascii"),
}
)
assert not serializer.is_valid()
assert "certificate_content" in serializer.errors
def test_rejects_empty_strings_for_both(self):
# DRF's CharField rejects "" at field-level before `validate()` runs.
# The errors surface per-field rather than as non_field_errors, but
+28 -5
View File
@@ -2,6 +2,7 @@ import base64
import json
import logging
from datetime import UTC, datetime, timedelta
from uuid import UUID
import yaml
from api.celery_utils import decode_celery_field
@@ -60,7 +61,10 @@ from api.v1.serializer_utils.lighthouse import (
)
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from api.validators import validate_lighthouse_openai_compatible_base_url
from api.validators import (
validate_certificate_bundle,
validate_lighthouse_openai_compatible_base_url,
)
from config.custom_logging import BackendLogger
from django.conf import settings
from django.contrib.auth import authenticate
@@ -1791,6 +1795,24 @@ class AzureProviderSecret(serializers.Serializer):
tenant_id = serializers.CharField()
certificate_content = serializers.CharField(required=False)
def validate_client_id(self, client_id):
try:
UUID(client_id)
except (TypeError, ValueError) as error:
raise serializers.ValidationError(
"Client ID must be a valid UUID."
) from error
return client_id
def validate_tenant_id(self, tenant_id):
try:
UUID(tenant_id)
except (TypeError, ValueError) as error:
raise serializers.ValidationError(
"Tenant ID must be a valid UUID."
) from error
return tenant_id
def validate(self, attrs):
if attrs.get("client_secret") and attrs.get("certificate_content"):
raise serializers.ValidationError(
@@ -1803,10 +1825,11 @@ class AzureProviderSecret(serializers.Serializer):
return super().validate(attrs)
def validate_certificate_content(self, certificate_content):
"""Validate that Azure certificate content is valid base64 encoded data."""
"""Validate the Azure certificate and matching private-key bundle."""
if certificate_content:
try:
base64.b64decode(certificate_content, validate=True)
certificate_data = base64.b64decode(certificate_content, validate=True)
validate_certificate_bundle(certificate_data)
except Exception as e:
# Field validators are invoked per-field; DRF already knows
# this error belongs to `certificate_content` and will nest
@@ -1814,9 +1837,9 @@ class AzureProviderSecret(serializers.Serializer):
# double-nest the JSON:API pointer as
# `/certificate_content/certificate_content`.
raise serializers.ValidationError(
f"The provided certificate content is not valid base64 encoded data: {str(e)}",
"Certificate content must be valid base64 containing an X.509 certificate and its matching private key.",
code="azure-certificate-content",
)
) from e
return certificate_content
class Meta:
+44
View File
@@ -1,8 +1,13 @@
import ipaddress
import re
import socket
import string
from urllib.parse import urlparse
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs12
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
@@ -20,6 +25,45 @@ LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
)
def validate_certificate_bundle(certificate_data: bytes) -> None:
"""Validate that certificate data contains a matching certificate and key."""
try:
private_key, certificate, _ = pkcs12.load_key_and_certificates(
certificate_data, None, default_backend()
)
except ValueError:
certificate_match = re.search(
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
certificate_data,
re.DOTALL,
)
private_key_match = re.search(
rb"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----.*?-----END (?:RSA |EC )?PRIVATE KEY-----",
certificate_data,
re.DOTALL,
)
if not certificate_match or not private_key_match:
raise ValueError(
"the payload must contain a certificate and its private key"
)
certificate = x509.load_pem_x509_certificate(
certificate_match.group(), default_backend()
)
private_key = serialization.load_pem_private_key(
private_key_match.group(), password=None, backend=default_backend()
)
if certificate is None or private_key is None:
raise ValueError("the payload must contain a certificate and its private key")
encoding = serialization.Encoding.DER
public_format = serialization.PublicFormat.SubjectPublicKeyInfo
if certificate.public_key().public_bytes(
encoding, public_format
) != private_key.public_key().public_bytes(encoding, public_format):
raise ValueError("the certificate does not match the private key")
def _normalize_hostname(hostname: str) -> str:
return hostname.rstrip(".").lower()