From 8b7cbc2dcced1334e2829a722d456cfa4105cf1c Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Wed, 21 Oct 2026 08:53:55 +0000
Subject: [PATCH] GHSA-7778-jm7p-68g5 LDAP listener lets unauthenticated clients hold connections until the server stops accepting new ones
---
opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java | 154 +++++++++++++++++++++++++++++++++++++++++----------
1 files changed, 124 insertions(+), 30 deletions(-)
diff --git a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java
index 0af2017..c9d483e 100644
--- a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java
+++ b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java
@@ -43,8 +43,8 @@
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -422,6 +422,12 @@
newSetFromMap(new ConcurrentHashMap<ClientConnection, Boolean>());
/**
+ * The number of connections held by each client address, against {@code max-client-connections-per-address}.
+ * Counted whatever the limit, so that a limit set while connections are open applies to them as well.
+ */
+ private final ConcurrentHashMap<InetAddress, Integer> connectionsPerAddress = new ConcurrentHashMap<>();
+
+ /**
* Creates a new instance of this LDAP connection handler. It must be initialized before it may be used.
*/
public LDAPConnectionHandler2() {
@@ -735,8 +741,13 @@
// Create and start a connection finalizer thread for this
// connection handler.
- connectionFinalizer = Executors.newSingleThreadScheduledExecutor(new DirectoryThread.Factory(
+ final ScheduledThreadPoolExecutor finalizer = new ScheduledThreadPoolExecutor(1, new DirectoryThread.Factory(
"LDAP Connection Finalizer for connection handler " + toString()));
+ // The first request time limits still pending must not keep the thread, and the server shutdown, waiting.
+ finalizer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ // A connection which sent its first request, or closed, takes its time limit and itself out of the queue.
+ finalizer.setRemoveOnCancelPolicy(true);
+ connectionFinalizer = finalizer;
connectionFinalizerActiveJobQueue = new ArrayList<>();
connectionFinalizerPendingJobQueue = new ArrayList<>();
@@ -865,7 +876,10 @@
private void connectionClosed(ClientConnection connection, Collection<ClientConnection> accepted) {
accepted.remove(connection);
- clientConnections.remove(connection);
+ // Several events may report the end of the same connection: only the first gives back its place.
+ if (clientConnections.remove(connection)) {
+ releasePlaceOfAddress(connection.getRemoteAddress());
+ }
for (TransportDrain drain : drains) {
drain.connectionClosed();
}
@@ -936,6 +950,14 @@
connectionClosed(conn, accepted);
}
});
+ // The context reports a close only to the listeners it has at that time: a connection which
+ // ended before this listener was added, for example from a post-connect plugin, was never
+ // reported to it. A close reported twice gives back the place of its address only once.
+ if (!conn.isConnectionValid()) {
+ connectionClosed(conn, accepted);
+ }
+ // Once the listener is in place, so that a connection the time limit closes is reported to it.
+ limitTimeToFirstRequest(conn);
return new ReactiveHandler<LDAPClientContext, LdapRequestEnvelope, Stream<Response>>() {
@Override
public Stream<Response> handle(final LDAPClientContext context,
@@ -1100,39 +1122,111 @@
clientConnection.getServerHostPort()));
throw LdapException.newLdapException(ResultCode.CONSTRAINT_VIOLATION);
}
+ // Check that the address of the client does not hold all the connections it is allowed already.
+ final int maxConnectionsPerAddress = currentConfig.getMaxClientConnectionsPerAddress();
+ if (!takePlaceOfAddress(clientAddr, maxConnectionsPerAddress)) {
+ clientConnection.disconnect(
+ DisconnectReason.ADMIN_LIMIT_EXCEEDED,
+ currentConfig.isSendRejectionNotice(),
+ ERR_CONNHANDLER_CLIENT_ADDRESS_LIMIT_REACHED.get(clientConnection.getClientHostPort(),
+ clientConnection.getServerHostPort(), maxConnectionsPerAddress));
+ throw LdapException.newLdapException(ResultCode.ADMIN_LIMIT_EXCEEDED);
+ }
- // If we've gotten here, then we'll take the
- // connection so invoke the post-connect plugins and
- // register the client connection with a request
- // handler.
+ boolean accepted = false;
try {
- PluginConfigManager pluginManager = DirectoryServer.getPluginConfigManager();
- PluginResult.PostConnect pluginResult = pluginManager.invokePostConnectPlugins(clientConnection);
- if (!pluginResult.continueProcessing()) {
- clientConnection.disconnect(pluginResult.getDisconnectReason(),
- pluginResult.sendDisconnectNotification(), pluginResult.getErrorMessage());
- throw LdapException.newLdapException(ResultCode.CONSTRAINT_VIOLATION);
- }
- } catch (Exception e) {
- logger.traceException(e);
-
- LocalizableMessage message = INFO_CONNHANDLER_UNABLE_TO_REGISTER_CLIENT.get(
- clientConnection.getClientHostPort(), clientConnection.getServerHostPort(), getExceptionMessage(e));
- logger.debug(message);
-
- clientConnection.disconnect(DisconnectReason.SERVER_ERROR, currentConfig.isSendRejectionNotice(), message);
- throw LdapException.newLdapException(ResultCode.OPERATIONS_ERROR);
- }
-
- if (useSSL()) {
+ // If we've gotten here, then we'll take the
+ // connection so invoke the post-connect plugins and
+ // register the client connection with a request
+ // handler.
try {
- clientContext.enableTLS(createSSLEngine(), false);
- } catch (DirectoryException e) {
- throw LdapException.newLdapException(e.getResultCode(), e);
+ PluginConfigManager pluginManager = DirectoryServer.getPluginConfigManager();
+ PluginResult.PostConnect pluginResult = pluginManager.invokePostConnectPlugins(clientConnection);
+ if (!pluginResult.continueProcessing()) {
+ clientConnection.disconnect(pluginResult.getDisconnectReason(),
+ pluginResult.sendDisconnectNotification(), pluginResult.getErrorMessage());
+ throw LdapException.newLdapException(ResultCode.CONSTRAINT_VIOLATION);
+ }
+ } catch (Exception e) {
+ logger.traceException(e);
+
+ LocalizableMessage message = INFO_CONNHANDLER_UNABLE_TO_REGISTER_CLIENT.get(
+ clientConnection.getClientHostPort(), clientConnection.getServerHostPort(),
+ getExceptionMessage(e));
+ logger.debug(message);
+
+ clientConnection.disconnect(
+ DisconnectReason.SERVER_ERROR, currentConfig.isSendRejectionNotice(), message);
+ throw LdapException.newLdapException(ResultCode.OPERATIONS_ERROR);
+ }
+
+ if (useSSL()) {
+ try {
+ clientContext.enableTLS(createSSLEngine(), false);
+ } catch (DirectoryException e) {
+ throw LdapException.newLdapException(e.getResultCode(), e);
+ }
+ }
+
+ accepted = true;
+ return clientConnection;
+ } finally {
+ // The connection is not accepted after all, whatever was thrown: it gives back the place it took.
+ if (!accepted) {
+ releasePlaceOfAddress(clientAddr);
}
}
+ }
- return clientConnection;
+ /**
+ * Counts a new connection from the given address, unless the address holds the given number of connections
+ * already.
+ *
+ * @return {@code false} if the connection would exceed the limit, which is then not counted
+ */
+ private boolean takePlaceOfAddress(final InetAddress address, final int maxConnections) {
+ final AtomicBoolean taken = new AtomicBoolean(true);
+ connectionsPerAddress.compute(address, (key, held) -> {
+ final int count = held != null ? held : 0;
+ if (maxConnections > 0 && count >= maxConnections) {
+ taken.set(false);
+ return held;
+ }
+ return count + 1;
+ });
+ return taken.get();
+ }
+
+ /** Uncounts a connection from the given address, which no longer appears once it holds none. */
+ private void releasePlaceOfAddress(final InetAddress address) {
+ if (address != null) {
+ connectionsPerAddress.computeIfPresent(address, (key, held) -> held > 1 ? held - 1 : null);
+ }
+ }
+
+ /**
+ * Closes the connection unless a complete request arrives on it within {@code first-request-time-limit}, so that
+ * clients which send nothing, or only part of a request, cannot hold connections open.
+ */
+ private void limitTimeToFirstRequest(final LDAPClientConnection2 connection) {
+ final long timeLimit = currentConfig.getFirstRequestTimeLimit();
+ if (timeLimit <= 0) {
+ return;
+ }
+ synchronized (connectionFinalizerLock) {
+ // Accepted while this handler is finalized, before its listener stops: nothing is left to time the
+ // connection. A disabled handler is no longer registered, so idle-time-limit does not reach it either:
+ // like every connection the handler leaves open, it lasts until the client closes it or the server stops.
+ if (connectionFinalizer == null) {
+ return;
+ }
+ connection.setFirstRequestTimer(connectionFinalizer.schedule(() -> {
+ if (!connection.hasReceivedRequest()) {
+ connection.disconnect(DisconnectReason.IDLE_TIME_LIMIT_EXCEEDED, true,
+ INFO_CONNHANDLER_FIRST_REQUEST_TIME_LIMIT_EXCEEDED.get(timeLimit));
+ }
+ }, timeLimit, TimeUnit.MILLISECONDS));
+ }
}
/**
--
Gitblit v1.10.0