CVE-2026-42858
Description
Open edX Platform enables the authoring and delivery of online learning at any scale. The sync_provider_data endpoint in SAMLProviderDataViewSet allows authenticated Enterprise Admin users to supply an arbitrary URL via the metadata_url POST parameter. This URL is passed directly to requests.get() in fetch_metadata_xml() without any URL validation, IP filtering, or scheme enforcement. An attacker with Enterprise Admin privileges can force the server to make HTTP requests to internal network services, cloud metadata endpoints (e.g., AWS 169.254.169.254), or other attacker-controlled destinations. This vulnerability is fixed by commit 6fda1f120ff5a590d120ae1180185525f399c6d0 and 70a56246dd9c9df57c596e64bdd8a11b1d9da054.
Affected products
1Patches
26fda1f120ff5style: Fix pylint violations.
2 files changed · +14 −5
common/djangoapps/third_party_auth/tasks.py+7 −1 modified@@ -97,7 +97,13 @@ def fetch_saml_metadata(): num_updated += 1 else: log.info(f"→ Updated existing SAMLProviderData. Nothing has changed for entityID {entity_id}") - except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError, SAMLMetadataURLError) as error: + except ( + exceptions.SSLError, + exceptions.HTTPError, + exceptions.RequestException, + MetadataParseError, + SAMLMetadataURLError, + ) as error: # Catch and process exception in case of errors during fetching and processing saml metadata. # Here is a description of each exception. # SSLError is raised in case of errors caused by SSL (e.g. SSL cer verification failure etc.)
openedx/envs/common.py+7 −4 modified@@ -1925,16 +1925,19 @@ def add_optional_apps(optional_apps, installed_apps): SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = {} SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = {} -# .. setting_name: SAML_METADATA_URL_ALLOW_PRIVATE_IPS -# .. setting_default: False -# .. setting_description: When False (the default), fetching SAML metadata from +# .. toggle_name: SAML_METADATA_URL_ALLOW_PRIVATE_IPS +# .. toggle_default: False +# .. toggle_description: When False (the default), fetching SAML metadata from # private IP address ranges (RFC 1918: 10.x, 172.16.x, 192.168.x) is blocked # as a defense against SSRF attacks. Set to True only in deployments where the # SAML Identity Provider is hosted on the same private network as the Open edX # server. Note: loopback (127.x) and link-local (169.254.x) addresses remain -# blocked regardless of this setting. Operators are also encouraged to enforce +# blocked regardless of this toggle. Operators are also encouraged to enforce # network-level egress filtering as a complementary control, particularly to # cover hostname-based URLs that are not subject to IP validation. +# .. toggle_implementation: DjangoSetting +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2026-04-24 SAML_METADATA_URL_ALLOW_PRIVATE_IPS = False ########################### django-fernet-fields ###########################
70a56246dd9cfix: block SSRF in SAML metadata URL fetching
4 files changed · +142 −4
common/djangoapps/third_party_auth/tasks.py+5 −4 modified@@ -16,8 +16,10 @@ from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig from common.djangoapps.third_party_auth.utils import ( MetadataParseError, + SAMLMetadataURLError, create_or_update_bulk_saml_provider_data, parse_metadata_xml, + validate_saml_metadata_url, ) log = logging.getLogger(__name__) @@ -74,10 +76,9 @@ def fetch_saml_metadata(): failure_messages = [] # We return the length of this array for num_failed for url, entity_ids in url_map.items(): 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 try: @@ -96,7 +97,7 @@ def fetch_saml_metadata(): num_updated += 1 else: log.info(f"→ Updated existing SAMLProviderData. Nothing has changed for entityID {entity_id}") - except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError) as error: + except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError, SAMLMetadataURLError) as error: # Catch and process exception in case of errors during fetching and processing saml metadata. # Here is a description of each exception. # SSLError is raised in case of errors caused by SSL (e.g. SSL cer verification failure etc.)
common/djangoapps/third_party_auth/tests/test_utils.py+61 −0 modified@@ -6,19 +6,23 @@ from unittest.mock import MagicMock import ddt +import pytest +from django.test import override_settings from lxml import etree from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.third_party_auth.models import SAMLProviderData from common.djangoapps.third_party_auth.tests.testutil import TestCase from common.djangoapps.third_party_auth.utils import ( + SAMLMetadataURLError, create_or_update_bulk_saml_provider_data, get_associated_user_by_email_response, get_user_from_email, is_enterprise_customer_user, is_oauth_provider, parse_metadata_xml, user_exists, + validate_saml_metadata_url, ) from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.features.enterprise_support.tests.factories import ( @@ -272,3 +276,60 @@ def test_multiple_keys(self): entity_id='http://entity1', ).count() assert count == 2 + + +@ddt.ddt +@skip_unless_lms +class TestValidateSAMLMetadataURL(TestCase): + """Tests for validate_saml_metadata_url.""" + + @ddt.data( + 'https://idp.example.com/metadata', + 'https://1.1.1.1/metadata', + ) + def test_valid_urls_pass(self, url): + validate_saml_metadata_url(url) # should not raise + + @ddt.data( + ('http://idp.example.com/metadata', 'must use HTTPS'), + ('ftp://idp.example.com/metadata', 'must use HTTPS'), + ('https://', 'no hostname'), + ) + @ddt.unpack + def test_invalid_scheme_or_missing_hostname(self, url, expected_fragment): + with pytest.raises(SAMLMetadataURLError, match=expected_fragment): + validate_saml_metadata_url(url) + + @ddt.data( + 'https://127.0.0.1/metadata', # IPv4 loopback + 'https://[::1]/metadata', # IPv6 loopback + 'https://169.254.169.254/latest', # AWS metadata endpoint + 'https://169.254.0.1/metadata', # other link-local + 'https://[fe80::1]/metadata', # IPv6 link-local + 'https://240.0.0.1/metadata', # reserved (Class E) + ) + def test_always_blocked_regardless_of_setting(self, url): + for allow_private in (False, True): + with override_settings(SAML_METADATA_URL_ALLOW_PRIVATE_IPS=allow_private): + with pytest.raises(SAMLMetadataURLError): + validate_saml_metadata_url(url) + + @ddt.data( + 'https://10.0.0.1/metadata', + 'https://172.16.0.1/metadata', + 'https://192.168.1.1/metadata', + 'https://[fc00::1]/metadata', # IPv6 unique local + ) + def test_private_ip_blocked_by_default(self, url): + with pytest.raises(SAMLMetadataURLError): + validate_saml_metadata_url(url) + + @ddt.data( + 'https://10.0.0.1/metadata', + 'https://172.16.0.1/metadata', + 'https://192.168.1.1/metadata', + 'https://[fc00::1]/metadata', # IPv6 unique local + ) + @override_settings(SAML_METADATA_URL_ALLOW_PRIVATE_IPS=True) + def test_private_ip_allowed_when_setting_enabled(self, url): + validate_saml_metadata_url(url) # should not raise
common/djangoapps/third_party_auth/utils.py+64 −0 modified@@ -3,9 +3,12 @@ """ import datetime +import ipaddress +from urllib.parse import urlparse from zoneinfo import ZoneInfo import dateutil.parser +from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.utils.timezone import now from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser @@ -27,6 +30,67 @@ class MetadataParseError(Exception): pass # lint-amnesty, pylint: disable=unnecessary-pass +class SAMLMetadataURLError(Exception): + """ A SAML metadata URL failed security validation """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + +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 parse_metadata_xml(xml, entity_id): """ Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of
openedx/envs/common.py+12 −0 modified@@ -1925,6 +1925,18 @@ def add_optional_apps(optional_apps, installed_apps): SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = {} SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = {} +# .. setting_name: SAML_METADATA_URL_ALLOW_PRIVATE_IPS +# .. setting_default: False +# .. setting_description: When False (the default), fetching SAML metadata from +# private IP address ranges (RFC 1918: 10.x, 172.16.x, 192.168.x) is blocked +# as a defense against SSRF attacks. Set to True only in deployments where the +# SAML Identity Provider is hosted on the same private network as the Open edX +# server. Note: loopback (127.x) and link-local (169.254.x) addresses remain +# blocked regardless of this setting. Operators are also encouraged to enforce +# network-level egress filtering as a complementary control, particularly to +# cover hostname-based URLs that are not subject to IP validation. +SAML_METADATA_URL_ALLOW_PRIVATE_IPS = False + ########################### django-fernet-fields ########################### FERNET_KEYS = [
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
3News mentions
0No linked articles in our index yet.