| opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java | ●●●●● patch | view | raw | blame | history | |
| opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties | ●●●●● patch | view | raw | blame | history | |
| opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java | ●●●●● patch | view | raw | blame | history | |
| opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.java | ●●●●● patch | view | raw | blame | history | |
| opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java | ●●●●● patch | view | raw | blame | history | |
| opendj-server-legacy/src/messages/org/opends/messages/protocol.properties | ●●●●● patch | view | raw | blame | history | |
| opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java | ●●●●● patch | view | raw | blame | history |
opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java
@@ -12,9 +12,12 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS. * Portions Copyright 2024-2026 3A Systems, LLC */ package org.forgerock.opendj.io; import static com.forgerock.opendj.ldap.CoreMessages.ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH; import java.io.IOException; import java.util.Arrays; import java.util.Collections; @@ -44,6 +47,15 @@ public final class LDAP { // @Checkstyle:ignore AvoidNestedBlocks /** * The maximum number of nested AND/OR/NOT components allowed in a search * filter when decoding it from its ASN.1 representation. This bounds the * decoder recursion so that a maliciously over-nested filter is rejected * rather than overflowing the JVM stack (GHSA-rv4q-c6mr-wxp7). It matches * the limit applied on the filter evaluation path. */ private static final int MAX_NESTED_FILTER_DEPTH = 100; /** The OID for the Kerberos V GSSAPI mechanism. */ public static final String OID_GSSAPI_KERBEROS_V = "1.2.840.113554.1.2.2"; @@ -402,14 +414,39 @@ * If an error occurs while reading from {@code reader}. */ public static Filter readFilter(final ASN1Reader reader) throws IOException { return readFilter(reader, 0); } /** * Reads the next ASN.1 element from the provided {@code ASN1Reader} as a * {@code Filter}, tracking the current nesting depth so that a maliciously * over-nested filter is rejected rather than overflowing the JVM stack * (GHSA-rv4q-c6mr-wxp7). * * @param reader * The {@code ASN1Reader} from which the ASN.1 encoded * {@code Filter} should be read. * @param depth * The current filter nesting depth (0 for the top-level filter). * @return The decoded {@code Filter}. * @throws IOException * If an error occurs while reading from {@code reader}, or if * the filter is nested more deeply than * {@link #MAX_NESTED_FILTER_DEPTH}. */ private static Filter readFilter(final ASN1Reader reader, final int depth) throws IOException { if (depth >= MAX_NESTED_FILTER_DEPTH) { throw DecodeException.fatalError( ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH)); } final byte type = reader.peekType(); switch (type) { case LDAP.TYPE_FILTER_AND: return readAndFilter(reader); return readAndFilter(reader, depth); case LDAP.TYPE_FILTER_OR: return readOrFilter(reader); return readOrFilter(reader, depth); case LDAP.TYPE_FILTER_NOT: return readNotFilter(reader); return readNotFilter(reader, depth); case LDAP.TYPE_FILTER_EQUALITY: return readEqualityMatchFilter(reader); case LDAP.TYPE_FILTER_GREATER_OR_EQUAL: @@ -568,13 +605,13 @@ writer.writeEndSequence(); } private static Filter readAndFilter(final ASN1Reader reader) throws IOException { private static Filter readAndFilter(final ASN1Reader reader, final int depth) throws IOException { reader.readStartSequence(LDAP.TYPE_FILTER_AND); try { if (reader.hasNextElement()) { final List<Filter> subFilters = new LinkedList<>(); do { subFilters.add(readFilter(reader)); subFilters.add(readFilter(reader, depth + 1)); } while (reader.hasNextElement()); return Filter.and(subFilters); } else { @@ -654,22 +691,22 @@ } } private static Filter readNotFilter(final ASN1Reader reader) throws IOException { private static Filter readNotFilter(final ASN1Reader reader, final int depth) throws IOException { reader.readStartSequence(LDAP.TYPE_FILTER_NOT); try { return Filter.not(readFilter(reader)); return Filter.not(readFilter(reader, depth + 1)); } finally { reader.readEndSequence(); } } private static Filter readOrFilter(final ASN1Reader reader) throws IOException { private static Filter readOrFilter(final ASN1Reader reader, final int depth) throws IOException { reader.readStartSequence(LDAP.TYPE_FILTER_OR); try { if (reader.hasNextElement()) { final List<Filter> subFilters = new LinkedList<>(); do { subFilters.add(readFilter(reader)); subFilters.add(readFilter(reader, depth + 1)); } while (reader.hasNextElement()); return Filter.or(subFilters); } else { opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties
@@ -112,6 +112,9 @@ ERR_ATTR_SYNTAX_DN_MAX_DEPTH=The provided value "%s" could not be parsed as a \ valid distinguished name because it contains more than %d levels of nested \ distinguished name (DN-syntax) attribute values ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH=Cannot decode the provided ASN.1 \ element as an LDAP search filter because it is nested more than %d levels \ deep, which exceeds the maximum allowed filter nesting depth WARN_ATTR_SYNTAX_INTEGER_INITIAL_ZERO=The provided value "%s" could \ not be parsed as a valid integer because the first digit may not be zero \ unless it is the only digit opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java
New file @@ -0,0 +1,139 @@ /* * 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.forgerock.opendj.io; import java.util.ArrayList; import java.util.List; import org.forgerock.opendj.ldap.ByteStringBuilder; import org.forgerock.opendj.ldap.DecodeException; import org.forgerock.opendj.ldap.Filter; import org.forgerock.opendj.ldap.SdkTestCase; import org.testng.annotations.Test; import static org.testng.Assert.*; /** * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001), SDK decoder twin. * * <p>{@link LDAP#readFilter(ASN1Reader)} (AND/OR/NOT) recursed once per nesting * level with no depth bound, so a maliciously over-nested search filter could * overflow the JVM stack with a {@link StackOverflowError} during decode. The * fix threads a depth counter and rejects over-nested filters with a * {@link DecodeException} before the stack is exhausted. * * @see org.opends.server.types.RawFilterDecodeStackOverflowTestCase the matching * test for the legacy server decoder ({@code RawFilter.decode}). */ @SuppressWarnings("javadoc") public class LDAPFilterDecodeNestingTest extends SdkTestCase { private static final int OVERFLOW_DEPTH = 100_000; private static final long DECODE_STACK_BYTES = 256L * 1024L; @Test public void shallowNestedFilterDecodes() throws Exception { Throwable result = decodeOnBoundedStack(buildNestedAndFilter(50)); assertNull(result, "A 50-level filter must decode cleanly, but threw: " + result); } @Test public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception { Throwable result = decodeOnBoundedStack(buildNestedAndFilter(OVERFLOW_DEPTH)); assertNotNull(result, "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded"); assertFalse(result instanceof StackOverflowError, "VULNERABLE: LDAP.readFilter overflowed the stack instead of rejecting: " + result); assertTrue(result instanceof DecodeException, "Expected a controlled DecodeException, but got: " + result); } private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException { final Throwable[] escaped = new Throwable[1]; Runnable decode = new Runnable() { @Override public void run() { try { LDAP.readFilter(ASN1.getReader(payload)); } catch (Throwable t) { escaped[0] = t; } } }; Thread worker = new Thread(null, decode, "ghsa-rv4q-sdk-decode", DECODE_STACK_BYTES); worker.start(); worker.join(); return escaped[0]; } /** Builds the definite-length BER encoding of an AND filter nested {@code depth} deep. */ private static byte[] buildNestedAndFilter(final int depth) throws Exception { ByteStringBuilder leafBuilder = new ByteStringBuilder(); ASN1Writer writer = ASN1.getWriter(leafBuilder); LDAP.writeFilter(writer, Filter.present("objectClass")); writer.flush(); byte[] leaf = leafBuilder.toByteArray(); List<byte[]> prefixes = new ArrayList<>(depth); int contentLen = leaf.length; for (int i = 0; i < depth; i++) { byte[] lengthBytes = berLength(contentLen); byte[] prefix = new byte[1 + lengthBytes.length]; prefix[0] = LDAP.TYPE_FILTER_AND; // 0xA0 System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length); prefixes.add(prefix); contentLen += prefix.length; } byte[] out = new byte[contentLen]; int pos = 0; for (int i = prefixes.size() - 1; i >= 0; i--) { byte[] prefix = prefixes.get(i); System.arraycopy(prefix, 0, out, pos, prefix.length); pos += prefix.length; } System.arraycopy(leaf, 0, out, pos, leaf.length); return out; } private static byte[] berLength(final int len) { if (len < 0x80) { return new byte[] { (byte) len }; } int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4; byte[] out = new byte[1 + numBytes]; out[0] = (byte) (0x80 | numBytes); for (int i = 0; i < numBytes; i++) { out[numBytes - i] = (byte) (len >>> (8 * i)); } return out; } } opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.java
@@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC */ package org.opends.server.protocols.ldap; @@ -192,6 +193,16 @@ readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true, e.getMessageObject()); } catch (StackOverflowError e) { // Defense in depth for over-nested protocol elements (e.g. search // filters - GHSA-rv4q-c6mr-wxp7). A StackOverflowError is an Error, // not an Exception, so without this it would escape and kill the // shared request-handler thread. Drop only the offending connection. logger.traceException(e); readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true, LocalizableMessage.raw(e.toString())); } catch (Exception e) { logger.traceException(e); opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java
@@ -13,6 +13,7 @@ * * Copyright 2006-2009 Sun Microsystems, Inc. * Portions Copyright 2013-2015 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC */ package org.opends.server.types; @@ -31,6 +32,7 @@ import static org.opends.messages.ProtocolMessages.*; import static org.opends.server.protocols.ldap.LDAPConstants.*; import static org.opends.server.protocols.ldap.LDAPResultCode.*; import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH; import static org.opends.server.util.StaticUtils.*; @@ -531,6 +533,42 @@ public static LDAPFilter decode(ASN1Reader reader) throws LDAPException { return decode(reader, 0); } /** * Decodes the elements from the provided ASN.1 reader as a raw * search filter, tracking the current nesting depth. * <p> * The {@code depth} guard bounds the recursion so that a maliciously * over-nested AND/OR/NOT filter is rejected with a * {@code PROTOCOL_ERROR} instead of overflowing the JVM stack with a * {@code StackOverflowError} (GHSA-rv4q-c6mr-wxp7). Without it an * unauthenticated client could kill the request-handler thread, since * a {@code StackOverflowError} is an {@code Error} and escapes the * {@code catch (Exception)} blocks on the decode path. * * @param reader The ASN.1 reader. * @param depth The current filter nesting depth (0 for the top-level * filter). * * @return The decoded search filter. * * @throws LDAPException If the provided ASN.1 element cannot be * decoded as a raw search filter, or if it is * nested more deeply than * {@link org.opends.server.util.ServerConstants#MAX_NESTED_FILTER_DEPTH}. */ private static LDAPFilter decode(ASN1Reader reader, int depth) throws LDAPException { if (depth >= MAX_NESTED_FILTER_DEPTH) { LocalizableMessage message = ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH); throw new LDAPException(PROTOCOL_ERROR, message); } byte type; try { @@ -546,10 +584,10 @@ { case TYPE_FILTER_AND: case TYPE_FILTER_OR: return decodeCompoundFilter(reader); return decodeCompoundFilter(reader, depth); case TYPE_FILTER_NOT: return decodeNotFilter(reader); return decodeNotFilter(reader, depth); case TYPE_FILTER_EQUALITY: case TYPE_FILTER_GREATER_OR_EQUAL: @@ -586,7 +624,7 @@ * decode the provided ASN.1 element as a * raw search filter. */ private static LDAPFilter decodeCompoundFilter(ASN1Reader reader) private static LDAPFilter decodeCompoundFilter(ASN1Reader reader, int depth) throws LDAPException { byte type; @@ -627,7 +665,7 @@ // could also be an absolute true/false filter while (reader.hasNextElement()) { filterComponents.add(LDAPFilter.decode(reader)); filterComponents.add(decode(reader, depth + 1)); } reader.readEndSequence(); } @@ -659,14 +697,14 @@ * decode the provided ASN.1 element as a * raw search filter. */ private static LDAPFilter decodeNotFilter(ASN1Reader reader) private static LDAPFilter decodeNotFilter(ASN1Reader reader, int depth) throws LDAPException { RawFilter notComponent; try { reader.readStartSequence(); notComponent = decode(reader); notComponent = decode(reader, depth + 1); reader.readEndSequence(); } catch (LDAPException le) opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
@@ -262,6 +262,9 @@ ERR_LDAP_FILTER_DECODE_NOT_COMPONENT_143=Cannot decode the provided \ ASN.1 element as an LDAP search filter because the NOT component element \ could not be decoded as an LDAP filter: %s ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH_1538=Cannot decode the provided \ ASN.1 element as an LDAP search filter because it is nested more than %d \ levels deep, which exceeds the maximum allowed filter nesting depth ERR_LDAP_FILTER_DECODE_TV_SEQUENCE_144=Cannot decode the provided ASN.1 \ element as an LDAP search filter because the element could not be decoded as \ a type-and-value sequence: %s opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java
New file @@ -0,0 +1,212 @@ /* * 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.types; import java.util.ArrayList; import java.util.List; import org.forgerock.opendj.io.ASN1; import org.forgerock.opendj.io.ASN1Reader; import org.forgerock.opendj.io.ASN1Writer; import org.forgerock.opendj.ldap.ByteStringBuilder; import org.opends.server.DirectoryServerTestCase; import org.opends.server.protocols.ldap.LDAPFilter; import org.testng.annotations.Test; import static org.opends.server.protocols.ldap.LDAPConstants.TYPE_FILTER_AND; import static org.opends.server.protocols.ldap.LDAPResultCode.PROTOCOL_ERROR; import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH; import static org.testng.Assert.*; /** * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001): * <em>Unauthenticated LDAP search-filter decode stack exhaustion</em>. * * <p>Before the fix, the BER decoder {@link RawFilter#decode(ASN1Reader)} —> * {@code decodeCompoundFilter} (AND/OR) / {@code decodeNotFilter} (NOT) recursed * once per nesting level with <strong>no depth bound</strong>. The * {@code MAX_NESTED_FILTER_DEPTH} guard only protected the filter * <em>evaluation</em> path, which runs strictly <em>after</em> decode. An * anonymous client could therefore send a single small {@code SearchRequest} * whose filter was nested tens of thousands of levels deep; decoding it * overflowed the JVM stack with a {@link StackOverflowError} <strong>before</strong> * the evaluation guard was ever reached. Because {@code StackOverflowError} is a * {@link java.lang.Error} (not an {@link Exception}), it escaped the * {@code catch (Exception)} blocks and killed the shared request-handler thread * → listener-wide DoS. * * <p>After the fix, {@link RawFilter#decode(ASN1Reader)} threads a depth counter * and rejects over-nested filters with a {@link LDAPException} * ({@code PROTOCOL_ERROR}) instead of recursing into a stack overflow. * * <p>This test builds the exact malicious wire encoding (a definite-length BER * tree of nested {@code 0xA0} AND TLVs wrapping an {@code (objectClass=*)} * present leaf) and decodes it on a thread with a small stack, so that — were the * bound ever removed — the overflow would still be deterministic and fast on any * platform. */ @SuppressWarnings("javadoc") public class RawFilterDecodeStackOverflowTestCase extends DirectoryServerTestCase { /** Nesting depth that would overflow even an oversized default stack. */ private static final int OVERFLOW_DEPTH = 100_000; /** A small, deterministic stack so any unbounded recursion overflows quickly. */ private static final long DECODE_STACK_BYTES = 256L * 1024L; /** * A filter nested well within {@link #MAX_NESTED_FILTER_DEPTH} decodes * normally. This is the control that proves the harness and the wire encoding * are correct, so that the rejection in * {@link #deeplyNestedFilterIsRejectedWithoutStackOverflow()} is attributable * to the depth guard and not to a malformed payload. */ @Test public void shallowNestedFilterDecodesWithoutError() throws Exception { byte[] payload = buildNestedAndFilter(MAX_NESTED_FILTER_DEPTH / 2); Throwable result = decodeOnBoundedStack(payload); assertNull(result, "A filter nested " + (MAX_NESTED_FILTER_DEPTH / 2) + " levels deep must decode cleanly, but threw: " + result); } /** * Reproduction-turned-regression check: a deeply nested AND filter must be * rejected with a controlled {@link LDAPException} ({@code PROTOCOL_ERROR}) * rather than overflowing the stack with a {@link StackOverflowError}. * * <p>On the vulnerable (pre-fix) code this fails because decode recurses until * the stack overflows and the resulting {@code java.lang.Error} escapes. With * the depth guard in place it passes. */ @Test public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception { byte[] payload = buildNestedAndFilter(OVERFLOW_DEPTH); // Well under the ~5 MB ds-cfg-max-request-size cap: depth costs only a few // bytes per level, so the request-size limit never bounds the depth. assertTrue(payload.length < 5 * 1024 * 1024, "PoC payload (" + payload.length + " bytes) should be far below the 5 MB request cap"); Throwable result = decodeOnBoundedStack(payload); assertNotNull(result, "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded"); assertFalse(result instanceof StackOverflowError, "VULNERABLE: decode overflowed the stack instead of rejecting the over-nested filter: " + result); assertTrue(result instanceof LDAPException, "Expected a controlled LDAPException, but got: " + result); assertEquals(((LDAPException) result).getResultCode(), PROTOCOL_ERROR, "Over-nested filter should be rejected as a protocol error"); } /** * Decodes {@code payload} via {@link RawFilter#decode(ASN1Reader)} on a worker * thread with a small stack and returns the {@link Throwable} that escaped * decode, or {@code null} if decode completed normally. Captures * {@link Throwable} (including {@link Error}) so a regression to the unbounded * recursion surfaces as a {@link StackOverflowError} rather than killing the * test JVM thread silently. */ private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException { final Throwable[] escaped = new Throwable[1]; Runnable decode = new Runnable() { @Override public void run() { try { ASN1Reader reader = ASN1.getReader(payload); RawFilter.decode(reader); } catch (Throwable t) { escaped[0] = t; } } }; Thread worker = new Thread(null, decode, "ghsa-rv4q-decode", DECODE_STACK_BYTES); worker.start(); worker.join(); return escaped[0]; } /** * Builds the BER (definite-length) wire encoding of an AND filter nested * {@code depth} levels deep, wrapping an {@code (objectClass=*)} present leaf: * <pre> * A0 L ( A0 L ( ... ( 87 0B "objectClass" ) ... ) ) * </pre> * Constructed inside-out so the length fields are exact, which is what * {@link RawFilter#decode} reads. (Indefinite-length BER is not used because * the OpenDJ reader does not support it.) */ private static byte[] buildNestedAndFilter(final int depth) throws Exception { // Innermost leaf: presence filter (objectClass=*). ByteStringBuilder leafBuilder = new ByteStringBuilder(); ASN1Writer writer = ASN1.getWriter(leafBuilder); LDAPFilter.createPresenceFilter("objectClass").write(writer); writer.flush(); byte[] leaf = leafBuilder.toByteString().toByteArray(); // Build each AND wrapper prefix (0xA0 + definite length) from the inside out. List<byte[]> prefixes = new ArrayList<>(depth); int contentLen = leaf.length; for (int i = 0; i < depth; i++) { byte[] lengthBytes = berLength(contentLen); byte[] prefix = new byte[1 + lengthBytes.length]; prefix[0] = TYPE_FILTER_AND; // 0xA0 System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length); prefixes.add(prefix); contentLen += prefix.length; } // Assemble: outermost prefix first ... innermost prefix ... leaf. byte[] out = new byte[contentLen]; int pos = 0; for (int i = prefixes.size() - 1; i >= 0; i--) { byte[] prefix = prefixes.get(i); System.arraycopy(prefix, 0, out, pos, prefix.length); pos += prefix.length; } System.arraycopy(leaf, 0, out, pos, leaf.length); return out; } /** Encodes {@code len} as a BER definite-length field (short or long form). */ private static byte[] berLength(final int len) { if (len < 0x80) { return new byte[] { (byte) len }; } int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4; byte[] out = new byte[1 + numBytes]; out[0] = (byte) (0x80 | numBytes); for (int i = 0; i < numBytes; i++) { out[numBytes - i] = (byte) (len >>> (8 * i)); } return out; } }