Repository: astropy/astropy

Issue:
# Switch SAMP to safe XML-RPC parsing for better robustness against external entities

SAMP currently relies on the standard `xmlrpc` library for Hub and Proxy communication. While checking the implementation, I noticed that `xmlrpc.client.ServerProxy` is susceptible to XML External Entity (XXE) processing by default. This behavior could allow a malicious Hub to potentially access local files or trigger SSRF when processing responses from untrusted sources.

Switching these instances to `defusedxml.xmlrpc` would resolve the underlying logic issue while maintaining protocol compatibility. I'm checking to see if adding `defusedxml` as a dependency is acceptable for the core package, as it's the established standard for handling XML-RPC data safely in Python. Precision in parsing is key for a project of this scale.

Implementation patch:
diff --git a/astropy/samp/hub.py b/astropy/samp/hub.py
index aecfa619b1ef..6710643e1118 100644
--- a/astropy/samp/hub.py
+++ b/astropy/samp/hub.py
@@ -19,7 +19,7 @@
 from .errors import SAMPHubError, SAMPProxyError, SAMPProxyTimeoutError, SAMPWarning
 from .lockfile_helpers import create_lock_file, read_lockfile
 from .standard_profile import ThreadingXMLRPCServer
-from .utils import ServerProxyPool, _HubAsClient, internet_on
+from .utils import SAMPXXEServerProxy, ServerProxyPool, _HubAsClient, internet_on
 from .web_profile import WebProfileXMLRPCServer, web_profile_text_dialog
 
 __all__ = ["SAMPHubServer", "WebProfileDialog"]
@@ -744,7 +744,7 @@ def _set_xmlrpc_callback(self, private_key, xmlrpc_addr):
             server_proxy_pool = None
 
             server_proxy_pool = ServerProxyPool(
-                self._pool_size, xmlrpc.ServerProxy, xmlrpc_addr, allow_none=1
+                self._pool_size, SAMPXXEServerProxy, xmlrpc_addr, allow_none=1
             )
 
             public_id = self._private_keys[private_key][0]
diff --git a/astropy/samp/hub_proxy.py b/astropy/samp/hub_proxy.py
index 2f56621b8627..2f52034a333f 100644
--- a/astropy/samp/hub_proxy.py
+++ b/astropy/samp/hub_proxy.py
@@ -6,7 +6,7 @@
 
 from .errors import SAMPHubError
 from .lockfile_helpers import get_main_running_hub
-from .utils import ServerProxyPool
+from .utils import SAMPXXEServerProxy, ServerProxyPool
 
 __all__ = ["SAMPHubProxy"]
 
@@ -65,7 +65,7 @@ def connect(self, hub=None, hub_params=None, pool_size=20):
             url = hub_params["samp.hub.xmlrpc.url"].replace("\\", "")
 
             self.proxy = ServerProxyPool(
-                pool_size, xmlrpc.ServerProxy, url, allow_none=1
+                pool_size, SAMPXXEServerProxy, url, allow_none=1
             )
 
             self.ping()
diff --git a/astropy/samp/lockfile_helpers.py b/astropy/samp/lockfile_helpers.py
index 97adb181303a..f2afec3c81c1 100644
--- a/astropy/samp/lockfile_helpers.py
+++ b/astropy/samp/lockfile_helpers.py
@@ -17,6 +17,7 @@
 from astropy.utils.data import get_readable_fileobj
 
 from .errors import SAMPHubError, SAMPWarning
+from .utils import SAMPXXEServerProxy
 
 
 def read_lockfile(lockfilename):
@@ -210,7 +211,7 @@ def check_running_hub(lockfilename):
 
     if "samp.hub.xmlrpc.url" in lockfiledict:
         try:
-            proxy = xmlrpc.ServerProxy(
+            proxy = SAMPXXEServerProxy(
                 lockfiledict["samp.hub.xmlrpc.url"].replace("\\", ""), allow_none=1
             )
             proxy.samp.hub.ping()
diff --git a/astropy/samp/standard_profile.py b/astropy/samp/standard_profile.py
index 31ee743b9d72..ac97c37e341a 100644
--- a/astropy/samp/standard_profile.py
+++ b/astropy/samp/standard_profile.py
@@ -10,6 +10,7 @@
 
 from .constants import SAMP_ICON
 from .errors import SAMPWarning
+from .utils import safe_xmlrpc_loads
 
 __all__ = []
 
@@ -53,7 +54,7 @@ def do_POST(self):
                 size_remaining -= len(L[-1])
             data = b"".join(L)
 
-            params, method = xmlrpc.loads(data)
+            params, method = safe_xmlrpc_loads(data)
 
             if method == "samp.webhub.register":
                 params = list(params)
diff --git a/astropy/samp/utils.py b/astropy/samp/utils.py
index f3d338f0596c..434bb82cd714 100644
--- a/astropy/samp/utils.py
+++ b/astropy/samp/utils.py
@@ -28,9 +28,58 @@ def internet_on():
             return True
 
 
-__all__ = ["SAMPMsgReplierWrapper"]
+__all__ = ["SAMPMsgReplierWrapper", "SAMPXXEServerProxy", "safe_xmlrpc_loads"]
 
-__doctest_skip__ = ["."]
+
+def get_safe_parser(use_datetime=False, use_builtin_types=False):
+    """
+    Return a safe XML parser and its associated unmarshaller.
+    """
+    unmarshaller = xmlrpc.Unmarshaller(use_datetime, use_builtin_types)
+    parser = xmlrpc.ExpatParser(unmarshaller)
+    if hasattr(parser, "_parser"):
+        # Explicitly disable external entities to prevent XXE.
+        # While None is often the default, being explicit ensures security
+        # across different environments and expat builds.
+        parser._parser.ExternalEntityRefHandler = None
+    return parser, unmarshaller
+
+
+def safe_xmlrpc_loads(data, use_datetime=False, use_builtin_types=False):
+    """
+    A secure replacement for `xmlrpc.client.loads` that prevents XXE.
+    """
+    parser, unmarshaller = get_safe_parser(use_datetime, use_builtin_types)
+    parser.feed(data)
+    parser.close()
+    return unmarshaller.close(), unmarshaller.getmethodname()
+
+
+class SAMPXXEServerProxy(xmlrpc.ServerProxy):
+    """
+    An XML-RPC server proxy that uses a safe transport to prevent XXE.
+    """
+
+    def __init__(self, uri, *args, **kwargs):
+        if "transport" not in kwargs:
+            from urllib.parse import urlparse
+
+            parsed_uri = urlparse(uri)
+            base_class = (
+                xmlrpc.SafeTransport
+                if parsed_uri.scheme == "https"
+                else xmlrpc.Transport
+            )
+
+            class XXESafeTransport(base_class):
+                def getparser(self):
+                    return get_safe_parser(self._use_datetime, self._use_builtin_types)
+
+            kwargs["transport"] = XXESafeTransport(
+                use_datetime=kwargs.get("use_datetime", False),
+                use_builtin_types=kwargs.get("use_builtin_types", False),
+            )
+        super().__init__(uri, *args, **kwargs)
 
 
 def getattr_recursive(variable, attribute):


Test patch:
diff --git a/astropy/samp/tests/web_profile_test_helpers.py b/astropy/samp/tests/web_profile_test_helpers.py
index f9f98ef4bca3..0126debfd6f6 100644
--- a/astropy/samp/tests/web_profile_test_helpers.py
+++ b/astropy/samp/tests/web_profile_test_helpers.py
@@ -7,7 +7,7 @@
 from astropy.samp.hub import WebProfileDialog
 from astropy.samp.hub_proxy import SAMPHubProxy
 from astropy.samp.integrated_client import SAMPIntegratedClient
-from astropy.samp.utils import ServerProxyPool
+from astropy.samp.utils import SAMPXXEServerProxy, ServerProxyPool
 
 
 class AlwaysApproveWebProfileDialog(WebProfileDialog):
@@ -51,7 +51,7 @@ def connect(self, pool_size=20, web_port=21012):
         try:
             self.proxy = ServerProxyPool(
                 pool_size,
-                xmlrpc.ServerProxy,
+                SAMPXXEServerProxy,
                 f"http://127.0.0.1:{web_port}",
                 allow_none=1,
             )