You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/19 08:43:02 UTC

(groovy) branch master updated: GROOVY-12270: Map connector authentication properties onto the keys a connector consumes

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new b4b41b0eae GROOVY-12270: Map connector authentication properties onto the keys a connector consumes
b4b41b0eae is described below

commit b4b41b0eaea87b282eb5cf84f7edd607d28d44a0
Author: Paul King <pa...@asert.com.au>
AuthorDate: Tue Aug 18 15:30:06 2026 +1000

    GROOVY-12270: Map connector authentication properties onto the keys a connector consumes
    
    JmxBuilder's connectorServer documents properties:[authenticate:true,
    passwordFile:..., accessFile:...], and wrote them into the connector
    environment under com.sun.management.jmxremote.* names. Those names belong
    to the JDK's out-of-the-box management agent, not to a connector server.
    The agent reads them and translates them into the jmx.remote.x.* names the
    connector actually consumes, then installs the authenticator itself; see
    sun.management.jmxremote.ConnectorBootstrap. Nothing performed that
    translation here, so an operator following the documented syntax started a
    connector with no authenticator at all.
    
    Verified rather than reasoned: with the environment this class built, the
    connector reported no authenticator and a credential-less client connected
    and read the MBean count; with jmx.remote.x.password.file the same client
    is rejected with "Authentication failed! Credentials required".
    
    Translate the aliases, and only when authentication was requested. Add
    loginConfig for JAAS, mapping to jmx.remote.x.login.config. Reject
    authenticate:true with no source of credentials at all, since that asks for
    authentication and would otherwise start open, which is the failure being
    fixed; a caller-supplied jmx.remote.authenticator counts as such a source,
    since passing a JMXAuthenticator through is the standard JSR-160 route for
    custom authentication.
    
    The com.sun.management.jmxremote.* names remain accepted as input spellings
    but are no longer copied into the environment, where they mean nothing;
    the ssl alias is likewise consumed to select the socket factories rather
    than passed through. Three GROOVY-12119 tests asserted the presence of
    those inert keys as a witness that the environment map was not discarded;
    they now assert the effective configuration instead, which is what their
    comments describe.
    
    Note on urgency rather than severity: no released version has ever passed
    the property map to the connector, because the building method returned
    null until GROOVY-12119, which is in no GA release. There is therefore no
    installed base of connectors that believe they are authenticated. What
    makes this worth fixing before GA is that GROOVY-12119 leaves SSL working
    while authentication silently does not, which is a quieter failure than the
    wholly broken configuration it replaced.
    
    The default remains an unauthenticated connector when no authentication is
    requested. Warning on that is a separate question from this one.
---
 .../jmx/builder/JmxServerConnectorFactory.groovy   |  38 ++++++-
 .../builder/JmxServerConnectorFactoryTest.groovy   | 123 ++++++++++++++++++++-
 2 files changed, 152 insertions(+), 9 deletions(-)

diff --git a/subprojects/groovy-jmx/src/main/groovy/groovy/jmx/builder/JmxServerConnectorFactory.groovy b/subprojects/groovy-jmx/src/main/groovy/groovy/jmx/builder/JmxServerConnectorFactory.groovy
index e53a6306d0..19892251ab 100644
--- a/subprojects/groovy-jmx/src/main/groovy/groovy/jmx/builder/JmxServerConnectorFactory.groovy
+++ b/subprojects/groovy-jmx/src/main/groovy/groovy/jmx/builder/JmxServerConnectorFactory.groovy
@@ -19,6 +19,7 @@
 package groovy.jmx.builder
 
 import javax.management.MBeanServer
+import javax.management.remote.JMXAuthenticator
 import javax.management.remote.JMXConnectorServer
 import javax.management.remote.JMXConnectorServerFactory
 import javax.management.remote.JMXServiceURL
@@ -42,11 +43,19 @@ import javax.rmi.ssl.SslRMIServerSocketFactory
  *            "authenticate":true|false,
  *            "passwordFile":"...",
  *            "accessFile":"...",
+ *            "loginConfig":"...",
  *            "sslEnabled" : true | false
  *         ...
  *        ]
  *     )
  * </pre>
+ * <p>
+ * When {@code authenticate} is true a source of credentials must be supplied, being one of
+ * {@code passwordFile}, {@code loginConfig}, or a {@code jmx.remote.authenticator} entry
+ * holding a {@link javax.management.remote.JMXAuthenticator}. A connector which was asked to
+ * authenticate but has none of these would start open, so that combination is rejected rather
+ * than accepted silently. Any other entry in {@code properties} is passed to the connector
+ * environment unaltered.
  *
  * @see javax.management.remote.JMXConnectorServer
  */
@@ -149,17 +158,36 @@ class JmxServerConnectorFactory extends AbstractFactory {
         if (!props) return null
         HashMap<String, Object> env = new HashMap<String, Object>()
 
-        // secure connection
+        // Authentication. The com.sun.management.jmxremote.* names belong to the JDK's
+        // out-of-the-box management agent, which translates them into the jmx.remote.x.*
+        // names a connector server actually consumes (see sun.management.jmxremote.
+        // ConnectorBootstrap). Nothing performs that translation here, so do it: putting the
+        // agent's names into a connector environment leaves the connector with no
+        // authenticator at all, and it accepts credential-less clients.
         def auth = props.remove("com.sun.management.jmxremote.authenticate") ?: props.remove("authenticate")
-        env.put("com.sun.management.jmxremote.authenticate", auth)
         def pFile = props.remove("com.sun.management.jmxremote.password.file") ?: props.remove("passwordFile")
-        env.put("com.sun.management.jmxremote.password.file", pFile)
         def aFile = props.remove("com.sun.management.jmxremote.access.file") ?: props.remove("accessFile")
-        env.put("com.sun.management.jmxremote.access.file", aFile)
+        def loginConfig = props.remove("com.sun.management.jmxremote.login.config") ?: props.remove("loginConfig")
+
+        if (Boolean.valueOf(auth?.toString())) {
+            // A caller may instead pass a JMXAuthenticator straight through, which is the
+            // standard JSR-160 route for custom authentication and is a credential source too.
+            // Validate the value, not just the key: a present-but-null (or wrong-typed) entry is
+            // not a credential source and would leave the connector unauthenticated.
+            boolean suppliedAuthenticator = props.get(JMXConnectorServer.AUTHENTICATOR) instanceof JMXAuthenticator
+            if (!pFile && !loginConfig && !suppliedAuthenticator) {
+                throw new JmxBuilderException("Connector authentication was requested but no source " +
+                        "of credentials was provided; supply 'passwordFile', 'loginConfig' or a " +
+                        "'${JMXConnectorServer.AUTHENTICATOR}' entry, otherwise the connector would " +
+                        "start unauthenticated.")
+            }
+            if (pFile) env.put("jmx.remote.x.password.file", pFile)
+            if (loginConfig) env.put("jmx.remote.x.login.config", loginConfig)
+            if (aFile) env.put("jmx.remote.x.access.file", aFile)
+        }
 
         // SSL connection
         def ssl = props.remove("com.sun.management.jmxremote.ssl") ?: props.remove("sslEnabled")
-        env.put("com.sun.management.jmxremote.ssl", ssl)
 
         // config other rmi props
         if (protocol == "rmi") {
diff --git a/subprojects/groovy-jmx/src/test/groovy/groovy/jmx/builder/JmxServerConnectorFactoryTest.groovy b/subprojects/groovy-jmx/src/test/groovy/groovy/jmx/builder/JmxServerConnectorFactoryTest.groovy
index 9271dda07d..6e2e75fe33 100644
--- a/subprojects/groovy-jmx/src/test/groovy/groovy/jmx/builder/JmxServerConnectorFactoryTest.groovy
+++ b/subprojects/groovy-jmx/src/test/groovy/groovy/jmx/builder/JmxServerConnectorFactoryTest.groovy
@@ -23,7 +23,11 @@ import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 
+import static groovy.test.GroovyAssert.shouldFail
+
+import javax.management.remote.JMXAuthenticator
 import javax.management.remote.JMXConnector
+import javax.management.remote.JMXConnectorServer
 import javax.management.remote.JMXConnectorFactory
 import javax.management.remote.JMXServiceURL
 import javax.management.remote.rmi.RMIConnectorServer
@@ -48,6 +52,113 @@ class JmxServerConnectorFactoryTest {
         JmxConnectorHelper.destroyRmiRegistry(rmi.registry)
     }
 
+    // GROOVY-12270: authentication properties must land on the keys a connector server
+    // consumes, not on the JDK management agent's names, which it ignores.
+    @Test
+    void testAuthenticationPropertiesMapToConsumedKeys() {
+        def factory = new JmxServerConnectorFactory()
+        def env = factory.confiConnectorProperties('rmi', rmi.port,
+                [authenticate: true, passwordFile: 'pwd.properties', accessFile: 'access.properties'])
+
+        assert env['jmx.remote.x.password.file'] == 'pwd.properties'
+        assert env['jmx.remote.x.access.file'] == 'access.properties'
+        assert !env.containsKey('com.sun.management.jmxremote.password.file')
+        assert !env.containsKey('com.sun.management.jmxremote.access.file')
+    }
+
+    @Test
+    void testLoginConfigMapsToConsumedKey() {
+        def factory = new JmxServerConnectorFactory()
+        def env = factory.confiConnectorProperties('rmi', rmi.port,
+                [authenticate: true, loginConfig: 'MyLoginModule'])
+
+        assert env['jmx.remote.x.login.config'] == 'MyLoginModule'
+    }
+
+    // Credentials are only configured when authentication was actually asked for.
+    @Test
+    void testCredentialsIgnoredWhenAuthenticationNotRequested() {
+        def factory = new JmxServerConnectorFactory()
+        def env = factory.confiConnectorProperties('rmi', rmi.port,
+                [authenticate: false, passwordFile: 'pwd.properties'])
+
+        assert !env.containsKey('jmx.remote.x.password.file')
+    }
+
+    // Asking for authentication without any source of credentials would start an open
+    // connector, which is the failure this ticket is about, so it is rejected.
+    @Test
+    void testAuthenticationWithoutCredentialSourceIsRejected() {
+        def factory = new JmxServerConnectorFactory()
+        def ex = shouldFail(JmxBuilderException) {
+            factory.confiConnectorProperties('rmi', rmi.port, [authenticate: true])
+        }
+        assert ex.message.contains('passwordFile')
+        assert ex.message.contains('loginConfig')
+    }
+
+    // A caller may supply their own JMXAuthenticator instead of a password file; that is the
+    // standard JSR-160 route and must count as a source of credentials.
+    @Test
+    void testCallerSuppliedAuthenticatorSatisfiesAuthenticationRequest() {
+        def factory = new JmxServerConnectorFactory()
+        def authenticator = { env -> new javax.security.auth.Subject() } as JMXAuthenticator
+        def env = factory.confiConnectorProperties('rmi', rmi.port,
+                [authenticate: true, (JMXConnectorServer.AUTHENTICATOR): authenticator])
+
+        assert env[JMXConnectorServer.AUTHENTICATOR].is(authenticator)
+    }
+
+    // A present-but-null (or wrong-typed) authenticator entry is not a credential source; it must
+    // be rejected rather than pass the check on the key's presence alone and leave the connector
+    // unauthenticated.
+    @Test
+    void testNullAuthenticatorIsNotACredentialSource() {
+        def factory = new JmxServerConnectorFactory()
+        def ex = shouldFail(JmxBuilderException) {
+            factory.confiConnectorProperties('rmi', rmi.port,
+                    [authenticate: true, (JMXConnectorServer.AUTHENTICATOR): null])
+        }
+        assert ex.message.contains(JMXConnectorServer.AUTHENTICATOR)
+
+        shouldFail(JmxBuilderException) {
+            factory.confiConnectorProperties('rmi', rmi.port,
+                    [authenticate: true, (JMXConnectorServer.AUTHENTICATOR): 'not-an-authenticator'])
+        }
+    }
+
+    // End-to-end: a connector configured to authenticate must reject a credential-less client.
+    @Test
+    void testAuthenticatedConnectorRejectsAnonymousClient() {
+        File dir = File.createTempDir()
+        try {
+            File password = new File(dir, 'jmxremote.password')
+            password.text = 'probeuser probepass\n'
+            File access = new File(dir, 'jmxremote.access')
+            access.text = 'probeuser readwrite\n'
+            [password, access].each { it.setReadable(false, false); it.setReadable(true, true) }
+
+            def server = builder.serverConnector(port: rmi.port,
+                    properties: [authenticate: true, passwordFile: password.path, accessFile: access.path])
+            server.start()
+            try {
+                JMXServiceURL url = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://localhost:${rmi.port}/jmxrmi")
+                shouldFail(SecurityException) {
+                    JMXConnectorFactory.connect(url, null).withCloseable { it.MBeanServerConnection.MBeanCount }
+                }
+                // ...and accept the configured one.
+                def creds = [(JMXConnector.CREDENTIALS): ['probeuser', 'probepass'] as String[]]
+                JMXConnectorFactory.connect(url, creds).withCloseable {
+                    assert it.MBeanServerConnection.MBeanCount > 0
+                }
+            } finally {
+                server.stop()
+            }
+        } finally {
+            dir.deleteDir()
+        }
+    }
+
     @Test
     void testJmxServerConnectorNode() {
         RMIConnectorServer result = builder.serverConnector(port: rmi.port)
@@ -81,8 +192,11 @@ class JmxServerConnectorFactoryTest {
         def env = factory.confiConnectorProperties('rmi', rmi.port, [authenticate: false])
 
         assert env != null : 'connector environment map must not be discarded'
-        // supplied/derived entries are present
-        assert env.containsKey('com.sun.management.jmxremote.authenticate')
+        // GROOVY-12270: the com.sun.management.jmxremote.* names belong to the JDK management
+        // agent and mean nothing in a connector environment, so they are no longer copied into
+        // it; authentication was not requested here, so nothing is configured for it.
+        assert !env.containsKey('com.sun.management.jmxremote.authenticate')
+        assert !env.containsKey('jmx.remote.x.password.file')
     }
 
     // GROOVY-12119: when SSL is requested the env map must carry the SSL socket factories
@@ -92,7 +206,8 @@ class JmxServerConnectorFactoryTest {
         def env = factory.confiConnectorProperties('rmi', rmi.port, [sslEnabled: true])
 
         assert env != null
-        assert env['com.sun.management.jmxremote.ssl']
+        // The socket factories are what actually enable SSL; see GROOVY-12270 for why the
+        // com.sun.management.jmxremote.ssl key itself is no longer placed in the environment.
         assert env[RMIConnectorServer.RMI_SERVER_SOCKET_FACTORY_ATTRIBUTE] instanceof SslRMIServerSocketFactory
         assert env[RMIConnectorServer.RMI_CLIENT_SOCKET_FACTORY_ATTRIBUTE] instanceof SslRMIClientSocketFactory
     }
@@ -127,7 +242,7 @@ class JmxServerConnectorFactoryTest {
         def env = factory.confiConnectorProperties('rmi', rmi.port, ['com.sun.management.jmxremote.ssl': true])
 
         assert env != null
-        assert env['com.sun.management.jmxremote.ssl']
+        // Recognition is evidenced by the socket factories being configured from it.
         assert env[RMIConnectorServer.RMI_SERVER_SOCKET_FACTORY_ATTRIBUTE] instanceof SslRMIServerSocketFactory
     }