/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.protocols.jmx; import static org.opends.server.protocols.internal.InternalClientConnection.*; import static org.testng.Assert.*; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; import java.util.HashMap; import java.util.Map; import javax.management.MBeanServer; import javax.management.MBeanServerConnection; import javax.management.ObjectName; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; import org.opends.server.TestCaseUtils; import org.opends.server.core.DeleteOperation; import org.opends.server.core.DirectoryServer; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; /** * Regression test for GHSA-qj63-3vrg-vcfx / OPENDJ-002 — incomplete fix of * CVE-2026-46495. *

* The CVE-2026-46495 fix scoped its JEP 290 deserialization filter to the * {@code credentials} object only * ({@code jmx.remote.rmi.server.credentials.filter.pattern}), leaving the * arguments of MBean operations ({@code Object[] params}) to be unmarshalled * after authentication with no serial filter at all. The follow-up fix also * installs the connector-wide * {@code jmx.remote.rmi.server.serial.filter.pattern}, restricting MBean * operation arguments to a small allowlist of JDK / JMX management types. *

* This test exercises the surface end-to-end. It: *

    *
  1. registers a do-nothing target MBean in the server's MBean server (so the * JDK's {@code RMIConnectionImpl.invoke} passes its {@code getClassLoaderFor} * check and reaches the argument-unmarshalling step with a class loader that * can see the canary type);
  2. *
  3. authenticates over JMX/RMI as a user holding {@code JMX_READ};
  4. *
  5. invokes an operation passing a custom {@link Serializable} "canary" as an * argument.
  6. *
* With the fix in place the connector-wide serial filter rejects the canary * type during unmarshalling: the call fails with a filter rejection and the * canary's {@code readObject()} never runs. Because the test server runs * in-process, that server-side deserialization (had it happened) would have * flipped a static flag the test observes directly. Before the fix this test * fails (the flag is set), which is exactly the vulnerability it guards * against. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "jmx" }, sequential = true) public class JmxMBeanArgDeserializationTest extends JmxTestCase { /** DN of the JMX_READ user created for this test. */ private static final String JMX_USER_DN = "cn=Privileged JMX User,o=test"; /** ObjectName of the helper target MBean registered for the duration of the test. */ private static final String TARGET_MBEAN = "org.opends.server.test:type=JmxDeserCanaryTarget"; /** Minimal Standard MBean used only as a registered {@code invoke()} target. */ public interface CanaryTargetMBean { // No attributes or operations: the operation we invoke deliberately does // not exist, so dispatch fails -- but only after the arguments have been // deserialized. } /** Loaded by the test/app class loader, which can resolve {@link DeserializationCanary}. */ public static final class CanaryTarget implements CanaryTargetMBean { } /** * A serializable marker whose custom {@code readObject} records that it was * deserialized. It stands in for an attacker-supplied gadget: a connector-wide * serial filter restricting MBean argument types to the small set OpenDJ * actually accepts would reject this class before {@code readObject} ever ran. */ public static final class DeserializationCanary implements Serializable { private static final long serialVersionUID = 1L; /** Set true server-side when this object is unmarshalled. */ static volatile boolean deserialized; private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); deserialized = true; } } @Override @BeforeClass public void setUp() throws Exception { super.setUp(); TestCaseUtils.addEntries( "dn: " + JMX_USER_DN, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "cn: Privileged JMX User", "givenName: Privileged", "sn: User", "uid: privileged.jmx.user", "userPassword: password", "ds-privilege-name: jmx-read", "ds-privilege-name: jmx-write", "ds-pwp-password-policy-dn: cn=Clear UserPassword Policy,cn=Password Policies,cn=config"); } @AfterClass public void afterClass() throws Exception { DeleteOperation deleteOperation = getRootConnection().processDelete(DN.valueOf(JMX_USER_DN)); assertEquals(deleteOperation.getResultCode(), ResultCode.SUCCESS); } /** * An authenticated client's MBean operation argument of an arbitrary type is * rejected by the connector-wide serial filter during unmarshalling, before * its {@code readObject()} can run. */ @Test public void mbeanOperationArgumentsAreFilteredBeforeDeserialization() throws Exception { DeserializationCanary.deserialized = false; MBeanServer serverMBeans = DirectoryServer.getJMXMBeanServer(); ObjectName target = new ObjectName(TARGET_MBEAN); if (serverMBeans.isRegistered(target)) { serverMBeans.unregisterMBean(target); } serverMBeans.registerMBean(new CanaryTarget(), target); Exception thrown = null; OpendsJmxConnector connector = connect(JMX_USER_DN, "password", TestCaseUtils.getServerJmxPort()); assertNotNull(connector, "JMX_READ user should be able to authenticate to the JMX connector"); try { MBeanServerConnection mbsc = connector.getMBeanServerConnection(); // getClassLoaderFor(target) succeeds (target is registered), so the call // reaches argument unmarshalling. The connector-wide serial filter must // reject the canary type there, before readObject() runs. try { mbsc.invoke(target, "thisOperationDoesNotExist", new Object[] { new DeserializationCanary() }, new String[] { "java.lang.Object" }); fail("Expected the non-allowlisted argument to be rejected by the serial filter"); } catch (Exception expected) { thrown = expected; } } finally { connector.close(); if (serverMBeans.isRegistered(target)) { serverMBeans.unregisterMBean(target); } } assertFalse(DeserializationCanary.deserialized, "VULNERABLE (GHSA-qj63-3vrg-vcfx): an authenticated client's MBean operation argument " + "was deserialized server-side. The connector-wide serial filter " + "(jmx.remote.rmi.server.serial.filter.pattern) must reject non-allowlisted types " + "before readObject() runs."); assertTrue(isSerialFilterRejection(thrown), "Expected a JEP 290 serial-filter rejection of the argument, but got: " + thrown); } /** True if the throwable chain shows a JEP 290 serial-filter rejection. */ private static boolean isSerialFilterRejection(Throwable t) { for (Throwable c = t; c != null; c = c.getCause()) { if (c instanceof java.io.InvalidClassException) { return true; } String message = c.getMessage(); if (message != null && (message.contains("REJECTED") || message.contains("filter status") || message.contains("serial filter"))) { return true; } } return false; } /** Connects to the in-process JMX/RMI connector with the given credentials. */ private OpendsJmxConnector connect(String user, String password, int jmxPort) throws IOException { Map env = new HashMap<>(); env.put("jmx.remote.credentials", new String[] { user, password }); env.put("jmx.remote.x.client.connection.check.period", 0); try { OpendsJmxConnector connector = new OpendsJmxConnector("localhost", jmxPort, env); connector.connect(); return connector; } catch (SecurityException | IOException e) { return null; } } }