Root cause
SSRF & network boundaries
feat: add SAML provider admin API and migrate SAML utilities
How AI contributed: Flawed AI-written codePublished May 5, 2026 in
openedx/edx-enterprise
Enterprise Admin can PATCH SAMLProviderConfig.metadata_source to an arbitrary URL then POST sync_provider_data; fetch_metadata_xml() calls requests.get(url) with only a non-enforcing HTTPS log warning, no scheme/IP/timeout controls, yielding SSRF (cloud metadata, RFC1918).
Root cause
feat: add SAML provider admin API and migrate SAML utilities
Fix
fix: block SSRF in SAML metadata URL fetching (GHSA-64cv-vxpr-j6vc)
AI-assisted fix: Claude Code · Feanil Patel
Case facts
@@ -0,0 +1,68 @@+"""+Utility functions for SAML provider administration.++Migrated from openedx-platform's third_party_auth/utils.py since they are only+used by the SAML admin viewsets now hosted in edx-enterprise.+"""+import logging+from uuid import UUID++import requests+from lxml import etree++log = logging.getLogger(__name__)+++def validate_uuid4_string(uuid_string):+ """+ Returns True if valid uuid4 string, or False+ """+ try:+ UUID(uuid_string, version=4)+ except (ValueError, TypeError, AttributeError):+ return False+ return True+++def convert_saml_slug_provider_id(provider):+ """+ Provider id is stored with the backend type prefixed to it (ie "saml-")+ Slug is stored without this prefix.+ This just converts between them whenever you expect the opposite of what you currently have.++ Arguments:+ provider (string): provider_id or slug++ Returns:+ (string): Opposite of what you inputted (slug -> provider_id; provider_id -> slug)+ """+ if provider.startswith('saml-'):+ return provider[5:]+ else:+ return 'saml-' + provider+++def fetch_metadata_xml(url):+ """+ Fetches IDP metadata from provider url+ Returns: xml document+ """+ try:+ log.info("Fetching %s", url)+ if not url.lower().startswith('https'):+ log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)+ response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError+ response.raise_for_status() # May raise an HTTPError++ try:+ parser = etree.XMLParser(remove_comments=True)+ xml = etree.fromstring(response.content, parser)+ except etree.XMLSyntaxError: # lint-amnesty, pylint: disable=try-except-raise+ raise+ return xml+ except (requests.exceptions.SSLError, requests.exceptions.HTTPError, requests.exceptions.RequestException) as error:+ log.exception(str(error), exc_info=error)+ raise+ except etree.XMLSyntaxError as error:+ log.exception(str(error), exc_info=error)+ raise@@ -0,0 +1,131 @@+"""+Viewset for enterprise SAML provider data administration.+"""+import logging++from edx_rbac.mixins import PermissionRequiredMixin+from requests.exceptions import HTTPError, MissingSchema, SSLError+from rest_framework import permissions, status, viewsets+from rest_framework.decorators import action+from rest_framework.exceptions import ParseError+from rest_framework.response import Response++from django.http import Http404+from django.shortcuts import get_object_or_404++from enterprise.api.v1.views.saml_utils import convert_saml_slug_provider_id, fetch_metadata_xml, validate_uuid4_string+from enterprise.models import EnterpriseCustomerIdentityProvider++try:+ from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLProviderData+except ImportError:+ SAMLProviderConfig = None+ SAMLProviderData = None++try:+ from common.djangoapps.third_party_auth.samlproviderdata.serializers import SAMLProviderDataSerializer+except ImportError:+ SAMLProviderDataSerializer = None++try:+ from common.djangoapps.third_party_auth.utils import create_or_update_bulk_saml_provider_data, parse_metadata_xml+except ImportError:+ create_or_update_bulk_saml_provider_data = None+ parse_metadata_xml = None++log = logging.getLogger(__name__)+++class SAMLProviderDataViewSet(PermissionRequiredMixin, viewsets.ModelViewSet):+ """+ A View to handle SAMLProviderData CRUD for enterprise admin users.++ Usage::++ GET /auth/saml/v0/provider_data/?enterprise-id=<uuid>+ POST /auth/saml/v0/provider_data/+ PATCH /auth/saml/v0/provider_data/<pk>/+ DELETE /auth/saml/v0/provider_data/<pk>/+ POST /auth/saml/v0/provider_data/sync_provider_data+ """++ permission_classes = [permissions.IsAuthenticated]+ permission_required = 'enterprise.can_access_admin_dashboard'++ def get_serializer_class(self):+ return SAMLProviderDataSerializer++ def get_queryset(self):+ """+ Find and return the matching providerid for the given enterprise uuid.++ Note: There is no direct association between samlproviderdata and enterprisecustomer.+ So we make that association in code via samlproviderdata > samlproviderconfig (via entity_id)+ then, we fetch enterprisecustomer via samlproviderconfig > enterprisecustomer (via association table).+ """+ if self.requested_enterprise_uuid is None:+ raise ParseError('Required enterprise-id query parameter or enterprise_customer_uuid field is missing')+ enterprise_customer_idp = get_object_or_404(+ EnterpriseCustomerIdentityProvider,+ enterprise_customer__uuid=self.requested_enterprise_uuid+ )+ try:+ saml_provider = SAMLProviderConfig.objects.current_set().get(+ slug=convert_saml_slug_provider_id(enterprise_customer_idp.provider_id))+ except SAMLProviderConfig.DoesNotExist:+ raise Http404('No matching SAML provider found.') # pylint: disable=raise-missing-from+ provider_data_id = self.request.parser_context.get('kwargs', {}).get('pk')+ if provider_data_id:+ return SAMLProviderData.objects.filter(id=provider_data_id)+ return SAMLProviderData.objects.filter(entity_id=saml_provider.entity_id)++ @property+ def requested_enterprise_uuid(self):+ """The enterprise customer uuid from request params or post body."""+ value = (+ self.request.query_params.get('enterprise-id') or+ self.request.data.get('enterprise_customer_uuid')+ )+ if value is not None and not validate_uuid4_string(value):@@ -0,0 +1,113 @@+"""+Viewset for enterprise SAML provider config administration.+"""+from edx_rbac.mixins import PermissionRequiredMixin+from rest_framework import permissions, status, viewsets+from rest_framework.exceptions import ParseError, ValidationError+from rest_framework.response import Response++from django.db.utils import IntegrityError+from django.shortcuts import get_list_or_404++from enterprise.api.v1.views.saml_utils import convert_saml_slug_provider_id, validate_uuid4_string+from enterprise.models import EnterpriseCustomer, EnterpriseCustomerIdentityProvider++try:+ from common.djangoapps.third_party_auth.models import SAMLProviderConfig+except ImportError:+ SAMLProviderConfig = None++try:+ from common.djangoapps.third_party_auth.samlproviderconfig.serializers import SAMLProviderConfigSerializer+except ImportError:+ SAMLProviderConfigSerializer = None+++class SAMLProviderConfigViewSet(PermissionRequiredMixin, viewsets.ModelViewSet):+ """+ A View to handle SAMLProviderConfig CRUD for enterprise admin users.++ Usage::++ GET /auth/saml/v0/provider_config/?enterprise-id=<uuid>+ POST /auth/saml/v0/provider_config/+ PATCH /auth/saml/v0/provider_config/<pk>/+ DELETE /auth/saml/v0/provider_config/<pk>/+ """++ permission_classes = [permissions.IsAuthenticated]+ permission_required = 'enterprise.can_access_admin_dashboard'++ def get_serializer_class(self):+ return SAMLProviderConfigSerializer++ def get_queryset(self):+ """+ Find and return the matching providerconfig for the given enterprise uuid+ if an association exists in EnterpriseCustomerIdentityProvider model.+ """+ if self.requested_enterprise_uuid is None:+ raise ParseError('Required enterprise-id query parameter or enterprise_customer_uuid field is missing')+ enterprise_customer_idps = get_list_or_404(+ EnterpriseCustomerIdentityProvider,+ enterprise_customer__uuid=self.requested_enterprise_uuid+ )+ slug_list = [idp.provider_id for idp in enterprise_customer_idps]+ saml_config_ids = [+ config.id for config in SAMLProviderConfig.objects.current_set()+ if config.provider_id in slug_list+ ]+ return SAMLProviderConfig.objects.filter(id__in=saml_config_ids)++ def destroy(self, request, *args, **kwargs):+ saml_provider_config = self.get_object()+ config_id = saml_provider_config.id+ provider_config_provider_id = saml_provider_config.provider_id+ customer_uuid = self.requested_enterprise_uuid+ try:+ enterprise_customer = EnterpriseCustomer.objects.get(pk=customer_uuid)+ except EnterpriseCustomer.DoesNotExist:+ raise ValidationError(f'Enterprise customer not found at uuid: {customer_uuid}') # pylint: disable=raise-missing-from+ EnterpriseCustomerIdentityProvider.objects.filter(+ enterprise_customer=enterprise_customer,+ provider_id=provider_config_provider_id,+ ).delete()+ SAMLProviderConfig.objects.filter(id=config_id).update(archived=True, enabled=False)+ return Response(status=status.HTTP_200_OK, data={'id': config_id})++ def create(self, request, *args, **kwargs):+ """Process POST /auth/saml/v0/provider_config/ {postData}"""+ enterprise_customer_uuid = request.data.get('enterprise_customer_uuid')+ if not enterprise_customer_uuid or not validate_uuid4_string(enterprise_customer_uuid):+ raise ParseError('enterprise_customer_uuid is missing or invalid')+ try:+ enterprise_customer = EnterpriseCustomer.objects.get(pk=enterprise_customer_uuid)+ except EnterpriseCustomer.DoesNotExist:+ raise ValidationError(f'Enterprise customer not found at uuid: {enterprise_customer_uuid}') # pylint: disable=raise-missing-from+ serializer = self.get_serializer(data=request.data)+ serializer.is_valid(raise_exception=True)+ try:@@ -418,3 +418,10 @@ def root(*args): ENTERPRISE_CUSTOMER_SUCCESS_EMAIL = '[email protected]' ENTERPRISE_INTEGRATIONS_EMAIL = '[email protected]'++# When False, fetching SAML metadata from RFC 1918 private IP ranges is blocked+# as a defense against SSRF. Set True only for deployments where the SAML IdP+# is on the same private network as the Open edX server. Loopback and link-local+# addresses (including cloud metadata endpoints like 169.254.169.254) are always+# blocked regardless of this setting.+SAML_METADATA_URL_ALLOW_PRIVATE_IPS = False@@ -4,15 +4,78 @@ Migrated from openedx-platform's third_party_auth/utils.py since they are only used by the SAML admin viewsets now hosted in edx-enterprise. """+import ipaddress import logging+from urllib.parse import urlparse from uuid import UUID import requests+from django.conf import settings from lxml import etree log = logging.getLogger(__name__) +class SAMLMetadataURLError(Exception):+ """A SAML metadata URL failed security validation."""+++def validate_saml_metadata_url(url):+ """+ Validate that a SAML metadata URL is safe to fetch.++ Enforces HTTPS and blocks requests to loopback, link-local, and reserved IP+ addresses. Link-local specifically covers cloud instance metadata endpoints+ (169.254.0.0/16, e.g. the AWS metadata service at 169.254.169.254).+ Reserved addresses (e.g. 240.0.0.0/4) are IETF-assigned ranges that are+ never routable on real networks.++ Private IP ranges (RFC 1918: 10.x, 172.16.x, 192.168.x) are also blocked by+ default, since most Open edX deployments fetch SAML metadata from public IdPs.+ Operators running in a private network where the SAML IdP has a private IP can+ opt out by setting SAML_METADATA_URL_ALLOW_PRIVATE_IPS = True in Django settings.++ Limitation: IP address checks only apply to literal IPs in the URL. Hostname-+ based URLs are not validated against the IP blocklists. Operators are encouraged+ to complement this with network-level egress filtering that blocks outbound+ connections from the Open edX server to link-local (169.254.0.0/16) and RFC+ 1918 private address ranges.++ Raises SAMLMetadataURLError if the URL fails validation.+ """+ parsed = urlparse(url)++ if parsed.scheme != 'https':+ raise SAMLMetadataURLError(+ f"SAML metadata URL must use HTTPS, got scheme: {parsed.scheme!r}"+ )++ hostname = parsed.hostname+ if not hostname:+ raise SAMLMetadataURLError("SAML metadata URL has no hostname")++ try:+ addr = ipaddress.ip_address(hostname)+ except ValueError:+ # hostname is a domain name, not a numeric IP literal — pass through.+ return++ # Loopback, link-local, and reserved ranges are never legitimate SAML IdP+ # addresses regardless of deployment topology.+ if addr.is_loopback or addr.is_link_local or addr.is_reserved:+ raise SAMLMetadataURLError(+ f"SAML metadata URL hostname is a forbidden IP address: {addr}"+ )++ # Private ranges are blocked by default but can be allowed via Django settings+ # for deployments where the SAML IdP lives on the same private network.+ if addr.is_private and not settings.SAML_METADATA_URL_ALLOW_PRIVATE_IPS:+ raise SAMLMetadataURLError(+ f"SAML metadata URL hostname is a private IP address: {addr}. "+ "Set SAML_METADATA_URL_ALLOW_PRIVATE_IPS = True in Django settings to allow this."+ )++ def validate_uuid4_string(uuid_string): """ Returns True if valid uuid4 string, or False@@ -48,10 +111,9 @@ def fetch_metadata_xml(url): Returns: xml document """ try:+ validate_saml_metadata_url(url) log.info("Fetching %s", url)- if not url.lower().startswith('https'):- log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)- response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError+ response = requests.get(url, verify=True, timeout=30) # May raise HTTPError or SSLError or ConnectionError response.raise_for_status() # May raise an HTTPError@@ -13,7 +13,12 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 -from enterprise.api.v1.views.saml_utils import convert_saml_slug_provider_id, fetch_metadata_xml, validate_uuid4_string+from enterprise.api.v1.views.saml_utils import (+ SAMLMetadataURLError,+ convert_saml_slug_provider_id,+ fetch_metadata_xml,+ validate_uuid4_string,+) from enterprise.models import EnterpriseCustomerIdentityProvider try:@@ -115,7 +120,7 @@ def sync_provider_data(self, request): metadata_url = saml_provider.metadata_source try: xml = fetch_metadata_xml(metadata_url)- except (SSLError, MissingSchema, HTTPError) as exc:+ except (SSLError, MissingSchema, HTTPError, SAMLMetadataURLError) as exc: return Response( data={'error': f'Failed to fetch metadata XML: {exc}'}, status=status.HTTP_400_BAD_REQUEST,AI-assisted change f70b0741eeee354824d219ea0dbbedcf848b1fddf749e72bc43757a1ffa673ac · Fix c584314b4c1ac9ecd230bb9af1ac6cf8d3a721fec62da712a53fb9a4f8e5f3e0