51 files modified
5 files added
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-cli</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-config</artifactId> |
| | | <name>OpenDJ Configuration API</name> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-core</artifactId> |
| | |
| | | * 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; |
| | |
| | | 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"; |
| | | |
| | |
| | | * 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: |
| | |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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 |
| New file |
| | |
| | | /* |
| | | * 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; |
| | | } |
| | | } |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-doc-generated-ref</artifactId> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-doc-maven-plugin</artifactId> |
| | |
| | | information: "Portions Copyright [year] [name of copyright owner]". |
| | | |
| | | Copyright 2015-2016 ForgeRock AS. |
| | | Portions Copyright 2026 3A Systems, LLC. |
| | | --> |
| | | <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
| | | <modelVersion>4.0.0</modelVersion> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-dsml-servlet</artifactId> |
| | |
| | | <version>4.0.5</version> |
| | | <scope>runtime</scope> |
| | | </dependency> |
| | | |
| | | <!-- Test dependencies (TestNG itself is inherited from the parent) --> |
| | | <dependency> |
| | | <groupId>org.openidentityplatform.commons</groupId> |
| | | <artifactId>build-tools</artifactId> |
| | | <scope>test</scope> |
| | | </dependency> |
| | | </dependencies> |
| | | |
| | | <build><finalName>${project.groupId}.${project.artifactId}</finalName> |
| | |
| | | <?xml version="1.0" encoding="ISO-8859-1"?> |
| | | |
| | | <!-- |
| | | Portions Copyright 2026 3A Systems, LLC. |
| | | --> |
| | | |
| | | <web-app xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| | | xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd" |
| | | version="2.4"> |
| | |
| | | </context-param> |
| | | --> |
| | | |
| | | <!-- Server-side dereferencing of xsd:anyURI values is DISABLED by default. |
| | | When disabled, an anyURI value is stored verbatim instead of being fetched |
| | | by the gateway. Fetching attacker-supplied URIs server-side is a |
| | | server-side request forgery, local-file disclosure and unbounded-read DoS |
| | | primitive (GHSA-68r5-9hpg-7qw9); enable it only if you fully trust every |
| | | DSML client. When enabled, only the allowlisted schemes are permitted, |
| | | requests to loopback/link-local/private/reserved addresses are rejected, |
| | | HTTP redirects are refused, and the fetched content is capped. Known |
| | | limitation: the address check and the fetch resolve the host name |
| | | independently, so a DNS name whose records change between the two lookups |
| | | (DNS rebinding) can still reach an internal address; only enable |
| | | dereferencing for trusted clients. |
| | | <context-param> |
| | | <description>Enable server-side dereferencing of anyURI values (default false)</description> |
| | | <param-name>ldap.dsml.dereference.anyuri</param-name> |
| | | <param-value>true</param-value> |
| | | </context-param> |
| | | <context-param> |
| | | <description>Comma-separated allowlist of URI schemes to dereference (default http,https)</description> |
| | | <param-name>ldap.dsml.dereference.anyuri.schemes</param-name> |
| | | <param-value>http,https</param-value> |
| | | </context-param> |
| | | <context-param> |
| | | <description>Maximum number of bytes fetched from an anyURI value (default 10485760)</description> |
| | | <param-name>ldap.dsml.dereference.anyuri.maxsize</param-name> |
| | | <param-value>10485760</param-value> |
| | | </context-param> |
| | | --> |
| | | |
| | | <!-- Add an extra <context-param> like the one below for each extended operation |
| | | that is known to return a string in the LDAP response. --> |
| | | <context-param> |
| | |
| | | <url-pattern>/DSMLServlet</url-pattern> |
| | | </servlet-mapping> |
| | | |
| | | <!-- Require container-managed authentication for the gateway so that it is |
| | | not reachable anonymously (GHSA-68r5-9hpg-7qw9). Map real accounts to the |
| | | "dsml-user" role in your servlet container's security realm; the same |
| | | HTTP Basic credentials are then forwarded as the LDAP bind. To serve the |
| | | gateway without authentication (NOT recommended), remove this |
| | | security-constraint, the login-config and the security-role below. |
| | | Uncomment the user-data-constraint to additionally require TLS. --> |
| | | <security-constraint> |
| | | <web-resource-collection> |
| | | <web-resource-name>DSML Gateway</web-resource-name> |
| | | <url-pattern>/DSMLServlet</url-pattern> |
| | | </web-resource-collection> |
| | | <auth-constraint> |
| | | <role-name>dsml-user</role-name> |
| | | </auth-constraint> |
| | | <!-- |
| | | <user-data-constraint> |
| | | <transport-guarantee>CONFIDENTIAL</transport-guarantee> |
| | | </user-data-constraint> |
| | | --> |
| | | </security-constraint> |
| | | |
| | | <login-config> |
| | | <auth-method>BASIC</auth-method> |
| | | <realm-name>OpenDJ DSML Gateway</realm-name> |
| | | </login-config> |
| | | |
| | | <security-role> |
| | | <role-name>dsml-user</role-name> |
| | | </security-role> |
| | | |
| | | </web-app> |
| | | |
| | | |
| | |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2012-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.net.HttpURLConnection; |
| | | import java.net.InetAddress; |
| | | import java.net.URI; |
| | | import java.net.URLConnection; |
| | | import java.net.UnknownHostException; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.HashSet; |
| | | import java.util.Locale; |
| | | import java.util.Set; |
| | | |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.opendj.ldap.ByteStringBuilder; |
| | |
| | | */ |
| | | class ByteStringUtility |
| | | { |
| | | /** Number of bytes read from a dereferenced URI per iteration. */ |
| | | private static final int READ_CHUNK = 2048; |
| | | |
| | | /** Connect timeout (ms) applied when dereferencing an anyURI value. */ |
| | | private static final int CONNECT_TIMEOUT_MS = 10000; |
| | | |
| | | /** Read timeout (ms) applied when dereferencing an anyURI value. */ |
| | | private static final int READ_TIMEOUT_MS = 30000; |
| | | |
| | | /** |
| | | * Whether xsd:anyURI values are dereferenced (fetched) server-side. |
| | | * <p> |
| | | * Disabled by default: fetching an attacker-supplied URI on the server is a |
| | | * server-side request forgery, local-file disclosure and unbounded-read DoS |
| | | * primitive (GHSA-68r5-9hpg-7qw9). When disabled, an anyURI value is stored |
| | | * verbatim (its string form) instead of being dereferenced. |
| | | */ |
| | | private static volatile boolean dereferenceUri; |
| | | |
| | | /** URI schemes that may be dereferenced when {@link #dereferenceUri} is enabled. */ |
| | | private static volatile Set<String> allowedUriSchemes = |
| | | Collections.unmodifiableSet(new HashSet<>(Arrays.asList("http", "https"))); |
| | | |
| | | /** Maximum number of bytes read from a dereferenced URI (10 MiB by default). */ |
| | | private static volatile long maxUriContentLength = 10L * 1024 * 1024; |
| | | |
| | | /** |
| | | * Enables or disables server-side dereferencing of xsd:anyURI values. |
| | | * |
| | | * @param enabled {@code true} to fetch anyURI values (subject to the scheme |
| | | * allowlist, address filter and size cap), {@code false} to |
| | | * store them verbatim. |
| | | */ |
| | | static void setDereferenceUri(boolean enabled) |
| | | { |
| | | dereferenceUri = enabled; |
| | | } |
| | | |
| | | /** |
| | | * Sets the schemes that may be dereferenced from a comma-separated list. |
| | | * |
| | | * @param schemes comma-separated scheme names (e.g. {@code "http,https"}). |
| | | */ |
| | | static void setAllowedUriSchemes(String schemes) |
| | | { |
| | | Set<String> parsed = new HashSet<>(); |
| | | for (String scheme : schemes.split(",")) |
| | | { |
| | | String trimmed = scheme.trim(); |
| | | if (!trimmed.isEmpty()) |
| | | { |
| | | parsed.add(trimmed.toLowerCase(Locale.ROOT)); |
| | | } |
| | | } |
| | | allowedUriSchemes = Collections.unmodifiableSet(parsed); |
| | | } |
| | | |
| | | /** |
| | | * Sets the maximum number of bytes read from a dereferenced URI. |
| | | * |
| | | * @param maxLength the maximum size, in bytes; must be positive. |
| | | * @throws IllegalArgumentException if the size is zero or negative. |
| | | */ |
| | | static void setMaxUriContentLength(long maxLength) |
| | | { |
| | | if (maxLength <= 0) |
| | | { |
| | | throw new IllegalArgumentException( |
| | | "The maximum anyURI content length must be positive, but was: " + maxLength); |
| | | } |
| | | maxUriContentLength = maxLength; |
| | | } |
| | | |
| | | /** |
| | | * Returns a ByteString from a DsmlValue Object. |
| | | * |
| | |
| | | } |
| | | else if (obj instanceof URI) |
| | | { |
| | | // read raw content and return as a byte[]. |
| | | try (InputStream is = ((URI) obj).toURL().openStream()) |
| | | { |
| | | ByteStringBuilder bsb = new ByteStringBuilder(); |
| | | while (bsb.appendBytes(is, 2048) != -1) |
| | | { |
| | | // do nothing |
| | | } |
| | | return bsb.toByteString(); |
| | | } |
| | | return convertUri((URI) obj); |
| | | } |
| | | else if (obj instanceof Element) |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Converts an xsd:anyURI value into a ByteString. |
| | | * <p> |
| | | * Unless dereferencing has been explicitly enabled, the URI is treated as an |
| | | * opaque string and is not fetched. When enabled, the scheme must be on the |
| | | * allowlist, the target must not resolve to a loopback/link-local/private |
| | | * address (SSRF hardening), HTTP redirects are refused (following one would |
| | | * connect to a host that never went through the address filter) and the |
| | | * amount of data read is capped. |
| | | * <p> |
| | | * Known limitation: the address filter and the subsequent connection resolve |
| | | * the host name independently, so DNS records changing between the two |
| | | * lookups (DNS rebinding) can still direct the fetch at an internal address. |
| | | * The JVM's positive DNS cache ({@code networkaddress.cache.ttl}, 30 seconds |
| | | * by default) narrows this window but does not close it; only enable |
| | | * dereferencing for trusted clients. |
| | | * |
| | | * @param uri the anyURI value. |
| | | * @return the resulting ByteString. |
| | | * @throws IOException if the URI is rejected or an I/O error occurs. |
| | | */ |
| | | static ByteString convertUri(URI uri) throws IOException |
| | | { |
| | | if (!dereferenceUri) |
| | | { |
| | | // Secure default: do not fetch remote or local content. |
| | | return ByteString.valueOfUtf8(uri.toString()); |
| | | } |
| | | |
| | | String scheme = uri.getScheme(); |
| | | if (scheme == null || !allowedUriSchemes.contains(scheme.toLowerCase(Locale.ROOT))) |
| | | { |
| | | throw new IOException( |
| | | "Refusing to dereference anyURI value with disallowed scheme: " + uri); |
| | | } |
| | | assertHostAllowed(uri); |
| | | |
| | | URLConnection connection = uri.toURL().openConnection(); |
| | | connection.setConnectTimeout(CONNECT_TIMEOUT_MS); |
| | | connection.setReadTimeout(READ_TIMEOUT_MS); |
| | | if (connection instanceof HttpURLConnection) |
| | | { |
| | | HttpURLConnection httpConnection = (HttpURLConnection) connection; |
| | | httpConnection.setInstanceFollowRedirects(false); |
| | | assertNotRedirect(httpConnection.getResponseCode(), uri); |
| | | } |
| | | try (InputStream is = connection.getInputStream()) |
| | | { |
| | | return readCapped(is, uri); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Rejects HTTP 3xx responses: the redirect target has not been validated by |
| | | * {@link #assertHostAllowed}, so following it would let an allowlisted public |
| | | * host bounce the gateway onto an internal or metadata address. |
| | | * |
| | | * @param responseCode the HTTP response code. |
| | | * @param uri the anyURI value being dereferenced. |
| | | * @throws IOException if the response is a redirect. |
| | | */ |
| | | static void assertNotRedirect(int responseCode, URI uri) throws IOException |
| | | { |
| | | if (responseCode >= 300 && responseCode < 400) |
| | | { |
| | | throw new IOException("Refusing to follow redirect (HTTP " + responseCode |
| | | + ") while dereferencing anyURI value: " + uri); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Reads a stream into a ByteString, enforcing the configured size cap. |
| | | * |
| | | * @param is the stream to read. |
| | | * @param uri the anyURI value being dereferenced, for diagnostics. |
| | | * @return the stream content. |
| | | * @throws IOException on I/O error, or if the content exceeds the cap. |
| | | */ |
| | | static ByteString readCapped(InputStream is, URI uri) throws IOException |
| | | { |
| | | ByteStringBuilder bsb = new ByteStringBuilder(); |
| | | while (bsb.appendBytes(is, READ_CHUNK) != -1) |
| | | { |
| | | if (bsb.length() > maxUriContentLength) |
| | | { |
| | | throw new IOException("anyURI content exceeds the maximum allowed size of " |
| | | + maxUriContentLength + " bytes: " + uri); |
| | | } |
| | | } |
| | | return bsb.toByteString(); |
| | | } |
| | | |
| | | /** |
| | | * Rejects a URI whose host resolves (in whole or in part) to a non-routable |
| | | * or otherwise internal address, to prevent server-side request forgery |
| | | * against internal services and the cloud metadata endpoint. |
| | | * |
| | | * @param uri the anyURI value being dereferenced. |
| | | * @throws IOException if the host is missing, unresolvable or internal. |
| | | */ |
| | | static void assertHostAllowed(URI uri) throws IOException |
| | | { |
| | | String host = uri.getHost(); |
| | | if (host == null) |
| | | { |
| | | throw new IOException("Refusing to dereference anyURI value with no host: " + uri); |
| | | } |
| | | InetAddress[] addresses; |
| | | try |
| | | { |
| | | addresses = InetAddress.getAllByName(host); |
| | | } |
| | | catch (UnknownHostException e) |
| | | { |
| | | throw new IOException("Unable to resolve anyURI host: " + host, e); |
| | | } |
| | | for (InetAddress address : addresses) |
| | | { |
| | | if (isInternalAddress(address)) |
| | | { |
| | | throw new IOException("Refusing to dereference anyURI value targeting a " |
| | | + "non-routable/internal address: " + host + " -> " + address.getHostAddress()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Returns {@code true} if the address is one the gateway must not connect to |
| | | * on behalf of a request: loopback, wildcard, link-local (covers the |
| | | * 169.254.169.254 metadata endpoint), site-local/private, multicast, an |
| | | * IANA-reserved IPv4 range (including 100.64.0.0/10, home of the Alibaba |
| | | * Cloud metadata endpoint 100.100.100.200), or an IPv6 unique-local address |
| | | * (fc00::/7). |
| | | */ |
| | | static boolean isInternalAddress(InetAddress address) |
| | | { |
| | | if (address.isLoopbackAddress() || address.isAnyLocalAddress() |
| | | || address.isLinkLocalAddress() || address.isSiteLocalAddress() |
| | | || address.isMulticastAddress()) |
| | | { |
| | | return true; |
| | | } |
| | | byte[] bytes = address.getAddress(); |
| | | if (bytes.length == 4) |
| | | { |
| | | int b0 = bytes[0] & 0xff; |
| | | int b1 = bytes[1] & 0xff; |
| | | return b0 == 0 // 0.0.0.0/8 "this network" |
| | | || (b0 == 100 && (b1 & 0xc0) == 0x40) // 100.64.0.0/10 CGNAT |
| | | || (b0 == 192 && b1 == 0 && (bytes[2] & 0xff) == 0) // 192.0.0.0/24 IETF assignments |
| | | || (b0 == 198 && (b1 & 0xfe) == 18) // 198.18.0.0/15 benchmarking |
| | | || b0 >= 240; // 240.0.0.0/4 reserved + broadcast |
| | | } |
| | | // IPv6 unique-local addresses (fc00::/7) are not covered by isSiteLocalAddress(). |
| | | return bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc; |
| | | } |
| | | |
| | | /** |
| | | * Returns a DsmlValue (Object) from an LDAP ByteString. The conversion is |
| | | * simplistic - try and convert it to UTF-8 and if that fails return a byte[]. |
| | | * |
| | |
| | | * |
| | | * Copyright 2006-2010 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | |
| | | private static final String TRUSTALLCERTS = "ldap.trustall"; |
| | | private static final String USEHTTPAUTHZID = "ldap.authzidtypeisid"; |
| | | private static final String EXOPSTRINGPREFIX = "ldap.exop.string."; |
| | | private static final String DEREF_ANYURI = "ldap.dsml.dereference.anyuri"; |
| | | private static final String DEREF_ANYURI_SCHEMES = "ldap.dsml.dereference.anyuri.schemes"; |
| | | private static final String DEREF_ANYURI_MAXSIZE = "ldap.dsml.dereference.anyuri.maxsize"; |
| | | private static final long serialVersionUID = -3748022009593442973L; |
| | | private static final AtomicInteger nextMessageID = new AtomicInteger(1); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | // allow the use of anyURI values in adds and modifies |
| | | // Materialise anyURI values as java.net.URI so that, when dereferencing |
| | | // is explicitly enabled, they can be fetched. Dereferencing itself is |
| | | // gated and hardened in ByteStringUtility (disabled by default). |
| | | System.setProperty("mapAnyUriToUri", "true"); |
| | | |
| | | // Server-side dereferencing of anyURI values is disabled by default |
| | | // (SSRF / local-file / unbounded-read hardening, GHSA-68r5-9hpg-7qw9). |
| | | // Enable it explicitly, and optionally tune the scheme allowlist and the |
| | | // maximum fetched size, via the corresponding context-params. |
| | | boolean derefAnyUri = booleanValue(config, DEREF_ANYURI); |
| | | ByteStringUtility.setDereferenceUri(derefAnyUri); |
| | | if (derefAnyUri) |
| | | { |
| | | String schemes = stringValue(config, DEREF_ANYURI_SCHEMES); |
| | | if (schemes != null && !schemes.trim().isEmpty()) |
| | | { |
| | | ByteStringUtility.setAllowedUriSchemes(schemes); |
| | | } |
| | | String maxSize = stringValue(config, DEREF_ANYURI_MAXSIZE); |
| | | if (maxSize != null && !maxSize.trim().isEmpty()) |
| | | { |
| | | try |
| | | { |
| | | ByteStringUtility.setMaxUriContentLength(Long.parseLong(maxSize.trim())); |
| | | } |
| | | catch (IllegalArgumentException e) |
| | | { |
| | | throw new ServletException(DEREF_ANYURI_MAXSIZE |
| | | + " must be a positive number of bytes, but was: " + maxSize); |
| | | } |
| | | } |
| | | } |
| | | |
| | | if(jaxbContext==null) |
| | | { |
| | | jaxbContext = JAXBContext.newInstance(PKG_NAME, getClass().getClassLoader()); |
| New file |
| | |
| | | /* |
| | | * 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.dsml.protocol; |
| | | |
| | | import java.io.ByteArrayInputStream; |
| | | import java.io.IOException; |
| | | import java.net.InetAddress; |
| | | import java.net.URI; |
| | | |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.testng.ForgeRockTestCase; |
| | | import org.testng.annotations.AfterMethod; |
| | | import org.testng.annotations.DataProvider; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertFalse; |
| | | import static org.testng.Assert.assertTrue; |
| | | |
| | | /** |
| | | * Tests the anyURI dereferencing hardening added for GHSA-68r5-9hpg-7qw9: |
| | | * secure-by-default (no fetch), scheme allowlist, internal-address filter, |
| | | * redirect refusal and content size cap. None of these tests performs network |
| | | * or DNS I/O: hosts are IP literals and streams are in-memory. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | @Test(groups = { "precommit", "dsml" }) |
| | | public class ByteStringUtilityTestCase extends ForgeRockTestCase |
| | | { |
| | | @AfterMethod |
| | | public void restoreDefaults() |
| | | { |
| | | ByteStringUtility.setDereferenceUri(false); |
| | | ByteStringUtility.setAllowedUriSchemes("http,https"); |
| | | ByteStringUtility.setMaxUriContentLength(10L * 1024 * 1024); |
| | | } |
| | | |
| | | @Test |
| | | public void testUriIsStoredVerbatimByDefault() throws Exception |
| | | { |
| | | ByteString value = ByteStringUtility.convertValue(new URI("http://203.0.113.10/secret")); |
| | | assertEquals(value, ByteString.valueOfUtf8("http://203.0.113.10/secret")); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] disallowedSchemeUris() |
| | | { |
| | | return new Object[][] { |
| | | { "file:///etc/passwd" }, |
| | | { "ftp://203.0.113.10/file" }, |
| | | { "gopher://203.0.113.10/1" }, |
| | | { "jar:http://203.0.113.10/a.jar!/b" }, |
| | | { "urn:isbn:0451450523" }, // opaque URI, no scheme match |
| | | { "/relative/path" }, // no scheme at all |
| | | }; |
| | | } |
| | | |
| | | @Test(dataProvider = "disallowedSchemeUris", expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*disallowed scheme.*") |
| | | public void testDisallowedSchemesAreRejected(String uri) throws Exception |
| | | { |
| | | ByteStringUtility.setDereferenceUri(true); |
| | | ByteStringUtility.convertUri(new URI(uri)); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*disallowed scheme.*") |
| | | public void testSchemeAllowlistIsConfigurable() throws Exception |
| | | { |
| | | ByteStringUtility.setDereferenceUri(true); |
| | | ByteStringUtility.setAllowedUriSchemes(" HTTPS , "); |
| | | // http is no longer on the allowlist, so it is rejected before any I/O. |
| | | ByteStringUtility.convertUri(new URI("http://203.0.113.10/")); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] internalUris() |
| | | { |
| | | return new Object[][] { |
| | | { "http://127.0.0.1/" }, |
| | | { "http://0.0.0.0/" }, |
| | | { "http://10.1.2.3/" }, |
| | | { "http://172.16.5.5/" }, |
| | | { "http://192.168.1.1/" }, |
| | | { "http://169.254.169.254/latest/meta-data/" }, |
| | | { "http://100.100.100.200/latest/meta-data/" }, |
| | | { "http://198.18.0.1/" }, |
| | | { "http://240.0.0.1/" }, |
| | | { "http://[::1]/" }, |
| | | { "http://[fe80::1]/" }, |
| | | { "http://[fc00::1]/" }, |
| | | { "http://[fd12:3456::1]/" }, |
| | | }; |
| | | } |
| | | |
| | | @Test(dataProvider = "internalUris", expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*(internal|no host).*") |
| | | public void testInternalAddressesAreRejected(String uri) throws Exception |
| | | { |
| | | ByteStringUtility.assertHostAllowed(new URI(uri)); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*no host.*") |
| | | public void testUriWithoutHostIsRejected() throws Exception |
| | | { |
| | | ByteStringUtility.assertHostAllowed(new URI("http:///path")); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] addressClassification() |
| | | { |
| | | return new Object[][] { |
| | | // address, expected isInternalAddress |
| | | { "8.8.8.8", false }, |
| | | { "1.1.1.1", false }, |
| | | { "203.0.113.10", false }, |
| | | { "2001:4860:4860::8888", false }, |
| | | { "127.0.0.1", true }, |
| | | { "0.1.2.3", true }, |
| | | { "169.254.169.254", true }, |
| | | { "239.255.255.255", true }, // multicast |
| | | { "255.255.255.255", true }, // broadcast |
| | | // 100.64.0.0/10 boundaries |
| | | { "100.63.255.255", false }, |
| | | { "100.64.0.0", true }, |
| | | { "100.100.100.200", true }, |
| | | { "100.127.255.255", true }, |
| | | { "100.128.0.0", false }, |
| | | // 192.0.0.0/24 boundaries |
| | | { "192.0.0.1", true }, |
| | | { "192.0.1.1", false }, |
| | | // 198.18.0.0/15 boundaries |
| | | { "198.17.255.255", false }, |
| | | { "198.18.0.0", true }, |
| | | { "198.19.255.255", true }, |
| | | { "198.20.0.0", false }, |
| | | // 240.0.0.0/4 boundary |
| | | { "239.0.0.1", true }, // multicast anyway |
| | | { "240.0.0.0", true }, |
| | | // IPv6 unique-local fc00::/7 boundaries |
| | | { "fbff::1", false }, |
| | | { "fc00::1", true }, |
| | | { "fdff::1", true }, |
| | | { "fe00::1", false }, |
| | | }; |
| | | } |
| | | |
| | | @Test(dataProvider = "addressClassification") |
| | | public void testIsInternalAddress(String literal, boolean expectedInternal) throws Exception |
| | | { |
| | | // IP literals do not trigger DNS resolution. |
| | | InetAddress address = InetAddress.getByName(literal); |
| | | assertEquals(ByteStringUtility.isInternalAddress(address), expectedInternal, |
| | | "isInternalAddress(" + literal + ")"); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] redirectCodes() |
| | | { |
| | | return new Object[][] { { 300 }, { 301 }, { 302 }, { 303 }, { 307 }, { 308 } }; |
| | | } |
| | | |
| | | @Test(dataProvider = "redirectCodes", expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*redirect.*") |
| | | public void testRedirectsAreRefused(int responseCode) throws Exception |
| | | { |
| | | ByteStringUtility.assertNotRedirect(responseCode, new URI("http://203.0.113.10/")); |
| | | } |
| | | |
| | | @Test |
| | | public void testNonRedirectCodesAreAccepted() throws Exception |
| | | { |
| | | ByteStringUtility.assertNotRedirect(200, new URI("http://203.0.113.10/")); |
| | | ByteStringUtility.assertNotRedirect(404, new URI("http://203.0.113.10/")); |
| | | } |
| | | |
| | | @Test |
| | | public void testContentWithinCapIsReadFully() throws Exception |
| | | { |
| | | byte[] content = new byte[8192]; |
| | | for (int i = 0; i < content.length; i++) |
| | | { |
| | | content[i] = (byte) i; |
| | | } |
| | | ByteStringUtility.setMaxUriContentLength(content.length); |
| | | ByteString value = ByteStringUtility.readCapped( |
| | | new ByteArrayInputStream(content), new URI("http://203.0.113.10/")); |
| | | assertEquals(value, ByteString.wrap(content)); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*maximum allowed size.*") |
| | | public void testContentOverCapIsRejected() throws Exception |
| | | { |
| | | ByteStringUtility.setMaxUriContentLength(1024); |
| | | ByteStringUtility.readCapped( |
| | | new ByteArrayInputStream(new byte[1025]), new URI("http://203.0.113.10/")); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IllegalArgumentException.class) |
| | | public void testZeroMaxSizeIsRejected() |
| | | { |
| | | ByteStringUtility.setMaxUriContentLength(0); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IllegalArgumentException.class) |
| | | public void testNegativeMaxSizeIsRejected() |
| | | { |
| | | ByteStringUtility.setMaxUriContentLength(-1); |
| | | } |
| | | |
| | | @Test |
| | | public void testSchemeCheckPrecedesHostCheck() throws Exception |
| | | { |
| | | ByteStringUtility.setDereferenceUri(true); |
| | | // A disallowed scheme must be rejected even for an internal host, |
| | | // and the internal host must be rejected for an allowed scheme. |
| | | try |
| | | { |
| | | ByteStringUtility.convertUri(new URI("file://127.0.0.1/etc/passwd")); |
| | | assertFalse(true, "expected IOException for disallowed scheme"); |
| | | } |
| | | catch (IOException e) |
| | | { |
| | | assertTrue(e.getMessage().contains("disallowed scheme"), e.getMessage()); |
| | | } |
| | | try |
| | | { |
| | | ByteStringUtility.convertUri(new URI("http://127.0.0.1/")); |
| | | assertFalse(true, "expected IOException for internal address"); |
| | | } |
| | | catch (IOException e) |
| | | { |
| | | assertTrue(e.getMessage().contains("internal"), e.getMessage()); |
| | | } |
| | | } |
| | | } |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-embedded-server-examples</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <name>OpenDJ Embedded Server</name> |
| | | <artifactId>opendj-embedded</artifactId> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-grizzly</artifactId> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-ldap-sdk-examples</artifactId> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-ldap-toolkit</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-legacy</artifactId> |
| | | <name>OpenDJ Legacy</name> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-maven-plugin</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-openidm-account-change-notification-handler</artifactId> |
| | | <name>OpenDJ account change notification handler for OpenIDM</name> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-deb</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-deb-standard</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-packages</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <profiles> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-packages</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <profiles> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-msi</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-msi-standard</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-packages</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-msi</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-rpm</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-rpm-standard</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-packages</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <packaging>pom</packaging> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-packages</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-svr4</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-packages</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-rest2ldap-servlet</artifactId> |
| | |
| | | <parent> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-rest2ldap</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-server-example-plugin</artifactId> |
| | | <name>OpenDJ Server Example Plugin</name> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-server-legacy</artifactId> |
| | | <packaging>jar</packaging> |
| | |
| | | * Header, with the fields enclosed by brackets [] replaced by your own identifying |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2024-2025 3A Systems, LLC. |
| | | * Copyright 2024-2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | |
| | | if (((CachedConnection) con).parent.getClass().getName().contains("oracle")) { |
| | | return "h char(128),k raw(2000),v blob,primary key(h,k)"; |
| | | }else if (((CachedConnection) con).parent.getClass().getName().contains("mysql")) { |
| | | return "h char(128),k tinyblob,v longblob,primary key(h(128),k(255))"; |
| | | return "h char(128),k varbinary(255),v longblob,primary key(h,k)"; |
| | | }else if (((CachedConnection) con).parent.getClass().getName().contains("microsoft")) { |
| | | return "h char(128),k varbinary(max),v image,primary key(h)"; |
| | | } |
| | |
| | | |
| | | @Override |
| | | public void openTree(TreeName treeName, boolean createOnDemand) { |
| | | if (createOnDemand && !isExistsTable(treeName)) { |
| | | try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){ |
| | | execute(statement); |
| | | con.commit(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | if (createOnDemand) { |
| | | if (!isExistsTable(treeName)) { |
| | | try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){ |
| | | execute(statement); |
| | | con.commit(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | // CursorImpl iterates with "where k>? order by k" batches: primary key (h,k) cannot serve them |
| | | final String driverName=((CachedConnection) con).parent.getClass().getName(); |
| | | final String tableName=getTableName(treeName); |
| | | if (driverName.contains("postgres")) { |
| | | try (final PreparedStatement statement=con.prepareStatement("create index if not exists k_"+tableName.substring("opendj_".length())+" on "+tableName+" (k)")){ |
| | | execute(statement); |
| | | con.commit(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | }else if (driverName.contains("mysql")) { |
| | | try { |
| | | if (!isExistsIndex(tableName,"k_"+tableName.substring("opendj_".length()))) { // mysql has no "create index if not exists" |
| | | try (final PreparedStatement statement=con.prepareStatement("create index k_"+tableName.substring("opendj_".length())+" on "+tableName+" (k)")){ |
| | | execute(statement); |
| | | con.commit(); |
| | | } |
| | | } |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | boolean isExistsIndex(String tableName, String indexName) throws SQLException { |
| | | try (final ResultSet rs = con.getMetaData().getIndexInfo(null, null, tableName, false, false)) { |
| | | while (rs.next()) { |
| | | if (indexName.equalsIgnoreCase(rs.getString("INDEX_NAME"))) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | public void clearTree(TreeName treeName) { |
| | | try (final PreparedStatement statement=con.prepareStatement("delete from "+getTableName(treeName))){ |
| | |
| | | } |
| | | } |
| | | |
| | | // Iterates in batches of "fetchsize" records via keyset pagination ("where k>? order by k limit n"): |
| | | // scrollable ResultSet is not an option, the postgres/mysql drivers materialize it entirely in memory. |
| | | private final class CursorImpl implements Cursor<ByteString, ByteString> { |
| | | final TreeName treeName; |
| | | |
| | | final PreparedStatement statement; |
| | | final ResultSet rc; |
| | | final Connection con; |
| | | final String tableName; |
| | | final boolean isReadOnly; |
| | | final int batchSize=Math.max(1,Integer.getInteger("org.openidentityplatform.opendj.jdbc.fetchsize",1000)); |
| | | final String limitClause; |
| | | |
| | | final ArrayDeque<byte[][]> buffer=new ArrayDeque<>(); |
| | | byte[] currentKeyDb; |
| | | ByteString currentKey; |
| | | ByteString currentValue; |
| | | boolean defined; |
| | | |
| | | public CursorImpl(boolean isReadOnly, Connection con, TreeName treeName) { |
| | | this.treeName=treeName; |
| | | this.isReadOnly=isReadOnly; |
| | | try { |
| | | statement=con.prepareStatement("select h,k,v from "+getTableName(treeName)+" order by k", |
| | | isReadOnly?ResultSet.TYPE_SCROLL_INSENSITIVE:ResultSet.TYPE_SCROLL_SENSITIVE, |
| | | isReadOnly?ResultSet.CONCUR_READ_ONLY:ResultSet.CONCUR_UPDATABLE); |
| | | rc=executeResultSet(statement); |
| | | this.con=con; |
| | | this.tableName=getTableName(treeName); |
| | | this.limitClause=((CachedConnection)con).parent.getClass().getName().contains("mysql") |
| | | ? " limit ?,?" : " offset ? rows fetch next ? rows only"; |
| | | } |
| | | |
| | | boolean fetchBatch(String condition, byte[] dbKey, long offset, boolean descending, int limit) { |
| | | buffer.clear(); |
| | | try (final PreparedStatement statement=con.prepareStatement("select k,v from "+tableName |
| | | +(condition!=null?" where k"+condition+"?":"") |
| | | +" order by k"+(descending?" desc":"")+limitClause)){ |
| | | int i=1; |
| | | if (condition!=null) { |
| | | statement.setBytes(i++,dbKey); |
| | | } |
| | | statement.setLong(i++,offset); |
| | | statement.setLong(i,limit); |
| | | try(final ResultSet rc=executeResultSet(statement)) { |
| | | while (rc.next()) { |
| | | buffer.add(new byte[][]{rc.getBytes(1),rc.getBytes(2)}); |
| | | } |
| | | } |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return !buffer.isEmpty(); |
| | | } |
| | | |
| | | void advanceFromBuffer() { |
| | | final byte[][] row=buffer.poll(); |
| | | currentKeyDb=row[0]; |
| | | currentKey=ByteString.wrap(db2real(row[0])); |
| | | currentValue=ByteString.wrap(row[1]); |
| | | defined=true; |
| | | } |
| | | |
| | | @Override |
| | | public boolean next() { |
| | | try { |
| | | return rc.next(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | if (buffer.isEmpty() && !fetchBatch(currentKeyDb==null?null:">",currentKeyDb,0,false,batchSize)) { |
| | | defined=false; |
| | | return false; |
| | | } |
| | | advanceFromBuffer(); |
| | | return true; |
| | | } |
| | | |
| | | @Override |
| | | public boolean isDefined() { |
| | | try{ |
| | | return rc.getRow()>0; |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return defined; |
| | | } |
| | | |
| | | @Override |
| | | public ByteString getKey() throws NoSuchElementException { |
| | | if (!isDefined()) { |
| | | if (!defined) { |
| | | throw new NoSuchElementException(); |
| | | } |
| | | try{ |
| | | return ByteString.wrap(db2real(rc.getBytes("k"))); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return currentKey; |
| | | } |
| | | |
| | | @Override |
| | | public ByteString getValue() throws NoSuchElementException { |
| | | if (!isDefined()) { |
| | | if (!defined) { |
| | | throw new NoSuchElementException(); |
| | | } |
| | | try{ |
| | | return ByteString.wrap(rc.getBytes("v")); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return currentValue; |
| | | } |
| | | |
| | | @Override |
| | | public void delete() throws NoSuchElementException, UnsupportedOperationException { |
| | | if (!isDefined()) { |
| | | if (!defined) { |
| | | throw new NoSuchElementException(); |
| | | } |
| | | try{ |
| | | rc.deleteRow(); |
| | | if (isReadOnly) { |
| | | throw new UnsupportedOperationException(); |
| | | } |
| | | try (final PreparedStatement statement=con.prepareStatement("delete from "+tableName+" where h=? and k=?")){ |
| | | statement.setString(1,key2hash.get(ByteBuffer.wrap(db2real(currentKeyDb)))); |
| | | statement.setBytes(2,currentKeyDb); |
| | | execute(statement); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | |
| | | |
| | | @Override |
| | | public void close() { |
| | | try{ |
| | | rc.close(); |
| | | statement.close(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | buffer.clear(); |
| | | defined=false; |
| | | } |
| | | |
| | | |
| | | @Override |
| | | public boolean positionToKeyOrNext(ByteSequence key) { |
| | | if (!isDefined() || key.compareTo(getKey())<0) { //restart iterator |
| | | try{ |
| | | rc.first(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | try{ |
| | | if (!isDefined()){ |
| | | return false; |
| | | } |
| | | do { |
| | | if (key.compareTo(getKey())<=0) { |
| | | return true; |
| | | } |
| | | }while(rc.next()); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | @Override |
| | | public boolean positionToKey(ByteSequence key) { |
| | | if (!isDefined() || key.compareTo(getKey())<0) { //restart iterator |
| | | try{ |
| | | rc.first(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | if (!isDefined()){ |
| | | return false; |
| | | } |
| | | if (isDefined() && key.compareTo(getKey())==0) { |
| | | if (fetchBatch(">=",real2db(key.toByteArray()),0,false,batchSize)) { |
| | | advanceFromBuffer(); |
| | | return true; |
| | | } |
| | | try{ |
| | | do { |
| | | if (key.compareTo(getKey())==0) { |
| | | return true; |
| | | } |
| | | }while(rc.next()); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | defined=false; |
| | | return false; |
| | | } |
| | | |
| | | |
| | | @Override |
| | | public boolean positionToLastKey() { |
| | | try{ |
| | | return rc.last(); |
| | | public boolean positionToKey(ByteSequence key) { |
| | | final byte[] real=key.toByteArray(); |
| | | try (final PreparedStatement statement=con.prepareStatement("select v from "+tableName+" where h=? and k=?")){ |
| | | statement.setString(1,key2hash.get(ByteBuffer.wrap(real))); |
| | | statement.setBytes(2,real2db(real)); |
| | | try(final ResultSet rc=executeResultSet(statement)) { |
| | | if (rc.next()) { |
| | | buffer.clear(); |
| | | currentKeyDb=real2db(real); |
| | | currentKey=ByteString.wrap(real); |
| | | currentValue=ByteString.wrap(rc.getBytes("v")); |
| | | defined=true; |
| | | return true; |
| | | } |
| | | } |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | defined=false; |
| | | return false; |
| | | } |
| | | |
| | | @Override |
| | | public boolean positionToLastKey() { |
| | | if (fetchBatch(null,null,0,true,1)) { |
| | | advanceFromBuffer(); |
| | | return true; |
| | | } |
| | | defined=false; |
| | | return false; |
| | | } |
| | | |
| | | @Override |
| | | public boolean positionToIndex(int index) { |
| | | try{ |
| | | rc.first(); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | if (index>=0 && fetchBatch(null,null,index,false,batchSize)) { |
| | | advanceFromBuffer(); |
| | | return true; |
| | | } |
| | | if (!isDefined()){ |
| | | return false; |
| | | } |
| | | int ct=0; |
| | | try{ |
| | | do { |
| | | if (ct==index) { |
| | | return true; |
| | | } |
| | | ct++; |
| | | }while(rc.next()); |
| | | }catch (SQLException e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | defined=false; |
| | | return false; |
| | | } |
| | | } |
| | |
| | | * Copyright 2006-2010 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions copyright 2013 Manuel Gaupp |
| | | * Portions Copyright 2026 3A Systems, LLC |
| | | */ |
| | | package org.opends.server.backends.pluggable; |
| | | |
| | |
| | | afterCount = 0; |
| | | } |
| | | |
| | | int count = 1 + beforeCount + afterCount; |
| | | // Never allocate more longs than the number of entries actually available |
| | | // from startPos, and compute the size with long arithmetic so an |
| | | // attacker-supplied before/after count cannot overflow or drive an oversized |
| | | // array (GHSA-q4wx-wj4j-4657). |
| | | final int available = startPos >= 0 && startPos < sortMap.size() ? sortMap.size() - startPos : 0; |
| | | final int count = (int) Math.max(0L, Math.min(1L + beforeCount + afterCount, available)); |
| | | long[] sortedIDs = new long[count]; |
| | | int treePos = 0; |
| | | int arrayPos = 0; |
| | | for (EntryID id : sortMap.values()) |
| | | if (count > 0) |
| | | { |
| | | if (treePos++ < startPos) |
| | | for (EntryID id : sortMap.values()) |
| | | { |
| | | continue; |
| | | } |
| | | if (treePos++ < startPos) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | sortedIDs[arrayPos++] = id.longValue(); |
| | | if (arrayPos >= count) |
| | | { |
| | | break; |
| | | sortedIDs[arrayPos++] = id.longValue(); |
| | | if (arrayPos >= count) |
| | | { |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | * |
| | | * Copyright 2006-2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC |
| | | */ |
| | | package org.opends.server.backends.pluggable; |
| | | |
| | |
| | | } |
| | | |
| | | final long[] selectedIDs; |
| | | final int count = 1 + beforeCount + afterCount; |
| | | // Never allocate more longs than the number of entries actually available |
| | | // from startPos, and compute the size with long arithmetic so an |
| | | // attacker-supplied before/after count cannot overflow or drive an oversized |
| | | // array (GHSA-q4wx-wj4j-4657). |
| | | final int available = startPos >= 0 && startPos < currentCount ? currentCount - startPos : 0; |
| | | final int count = (int) Math.max(0L, Math.min(1L + beforeCount + afterCount, available)); |
| | | try (Cursor<ByteString, ByteString> cursor = txn.openCursor(getName())) |
| | | { |
| | | if (cursor.positionToIndex(startPos)) |
| | |
| | | * |
| | | * Copyright 2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2014-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC |
| | | */ |
| | | package org.opends.server.controls; |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | |
| | | int beforeCount = (int)reader.readInteger(); |
| | | int afterCount = (int)reader.readInteger(); |
| | | |
| | | // The VLV draft defines beforeCount/afterCount as INTEGER (0..maxInt). |
| | | // Reject negative values (including those produced by the (int) cast |
| | | // wrapping a wire value above 2^31-1): unchecked they would flow into an |
| | | // unbounded / overflowing array allocation downstream (GHSA-q4wx-wj4j-4657). |
| | | if (beforeCount < 0 || afterCount < 0) |
| | | { |
| | | throw new DirectoryException(ResultCode.PROTOCOL_ERROR, |
| | | INFO_VLVREQ_CONTROL_CANNOT_DECODE_VALUE.get("beforeCount and afterCount must not be negative")); |
| | | } |
| | | |
| | | int offset = 0; |
| | | int contentCount = 0; |
| | | ByteString greaterThanOrEqual = null; |
| | |
| | | offset = (int)reader.readInteger(); |
| | | contentCount = (int)reader.readInteger(); |
| | | reader.readEndSequence(); |
| | | if (offset < 0 || contentCount < 0) |
| | | { |
| | | throw new DirectoryException(ResultCode.PROTOCOL_ERROR, |
| | | INFO_VLVREQ_CONTROL_CANNOT_DECODE_VALUE.get("offset and contentCount must not be negative")); |
| | | } |
| | | break; |
| | | |
| | | case TYPE_TARGET_GREATERTHANOREQUAL: |
| | |
| | | * |
| | | * Copyright 2006-2009 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyrighted 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.extensions; |
| | | |
| | |
| | | return; |
| | | } |
| | | } |
| | | |
| | | if (! checkProxyAccess(bindOperation, userEntry, authZEntry)) |
| | | { |
| | | return; |
| | | } |
| | | } |
| | | } |
| | | else |
| | |
| | | bindOperation.setAuthFailureReason(message); |
| | | return; |
| | | } |
| | | |
| | | if (! checkProxyAccess(bindOperation, userEntry, authZEntry)) |
| | | { |
| | | return; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | return; |
| | | } |
| | | |
| | | /** |
| | | * Checks whether the authenticated user is allowed by the access control |
| | | * subsystem to assume the given authorization identity (i.e. holds the |
| | | * "proxy" access control right for the target entry), and if not sets the |
| | | * bind failure result. This delegates to {@link SASLContext#hasProxyAccess} |
| | | * so that SASL PLAIN enforces the same ACI scope check as the proxied |
| | | * authorization controls and the DIGEST-MD5 / GSSAPI {@code authzid}, in |
| | | * addition to the {@code PROXIED_AUTH} privilege check. The denial uses the |
| | | * same {@code INVALID_CREDENTIALS} result and message as DIGEST-MD5 / GSSAPI |
| | | * so a client cannot distinguish a missing privilege from a missing proxy |
| | | * grant. |
| | | * |
| | | * @param bindOperation The bind operation being processed. |
| | | * @param authEntry The authenticated user entry (the proxy user). |
| | | * @param authZEntry The entry to be assumed, or {@code null} for the |
| | | * anonymous / root authorization identity. |
| | | * @return {@code true} if the access control configuration permits the |
| | | * authenticated user to assume the authorization identity. |
| | | */ |
| | | private boolean checkProxyAccess(BindOperation bindOperation, Entry authEntry, Entry authZEntry) |
| | | { |
| | | if (! SASLContext.hasProxyAccess(authEntry, authZEntry, bindOperation)) |
| | | { |
| | | bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS); |
| | | bindOperation.setAuthFailureReason( |
| | | ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName())); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | @Override |
| | | public boolean isPasswordBased(String mechanism) |
| | | { |
| | |
| | | * |
| | | * Copyright 2008-2009 Sun Microsystems, Inc. |
| | | * Portions Copyright 2011-2016 ForgeRock AS. |
| | | * Portions Copyrighted 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.extensions; |
| | | |
| | |
| | | import org.opends.server.types.AuthenticationInfo; |
| | | import org.opends.server.types.DirectoryException; |
| | | import org.opends.server.types.Entry; |
| | | import org.opends.server.types.Operation; |
| | | import org.opends.server.types.Privilege; |
| | | |
| | | /** |
| | |
| | | */ |
| | | private boolean hasPermission(final AuthenticationInfo authInfo) |
| | | { |
| | | boolean ret = true; |
| | | Entry e = authzEntry; |
| | | |
| | | // If the authz entry is null, use the entry associated with the NULL DN. |
| | | if (e == null) |
| | | if (!hasProxyAccess(authInfo.getAuthenticationEntry(), authzEntry, bindOp)) |
| | | { |
| | | setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName())); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Determines whether the access control configuration permits the |
| | | * authenticated user to assume the given authorization identity, i.e. holds |
| | | * the "proxy" access control right for the target entry. A {@code null} |
| | | * authorization entry maps to the entry associated with the NULL DN (the |
| | | * anonymous / root authorization identity). This is the shared SASL |
| | | * proxy-scope check used by DIGEST-MD5 / GSSAPI and by SASL PLAIN. |
| | | * |
| | | * @param authEntry |
| | | * The authenticated user entry (the proxy user). |
| | | * @param authzEntry |
| | | * The entry to be assumed, or {@code null} for the anonymous / root |
| | | * authorization identity. |
| | | * @param operation |
| | | * The operation being processed. |
| | | * @return {@code true} if the authenticated user may assume the authorization |
| | | * identity. |
| | | */ |
| | | static boolean hasProxyAccess(final Entry authEntry, final Entry authzEntry, |
| | | final Operation operation) |
| | | { |
| | | Entry proxiedEntry = authzEntry; |
| | | if (proxiedEntry == null) |
| | | { |
| | | // A null authorization entry maps to the entry associated with the NULL DN. |
| | | try |
| | | { |
| | | e = DirectoryServer.getEntry(DN.rootDN()); |
| | | proxiedEntry = DirectoryServer.getEntry(DN.rootDN()); |
| | | } |
| | | catch (final DirectoryException ex) |
| | | catch (final DirectoryException e) |
| | | { |
| | | logger.traceException(e); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | if (!AccessControlConfigManager.getInstance().getAccessControlHandler() |
| | | .mayProxy(authInfo.getAuthenticationEntry(), e, bindOp)) |
| | | { |
| | | setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName())); |
| | | ret = false; |
| | | } |
| | | |
| | | return ret; |
| | | return AccessControlConfigManager.getInstance().getAccessControlHandler() |
| | | .mayProxy(authEntry, proxiedEntry, operation); |
| | | } |
| | | |
| | | |
| | |
| | | /** |
| | | * JDK 10+ JMX environment property scoping a JEP 290 deserialization |
| | | * filter to the credentials object passed during {@code newClient()}. |
| | | * Using the credentials-scoped filter (instead of the connector-wide |
| | | * {@code jmx.remote.rmi.server.serial.filter.pattern}) avoids breaking |
| | | * legitimate JMX traffic such as MBean invocations and notifications, |
| | | * which may legitimately carry non-String types. |
| | | * It is the tightest of the two filters configured below (it accepts only |
| | | * the two-element {@code String[]} used for SASL/PLAIN authentication). |
| | | * <p> |
| | | * Note: this property is mutually exclusive with |
| | | * {@code jmx.remote.rmi.server.credential.types}; specifying both makes |
| | |
| | | |
| | | |
| | | /** |
| | | * JDK 10+ JMX environment property installing a JEP 290 deserialization |
| | | * filter for the whole RMI connection, in particular the arguments of MBean |
| | | * operations ({@code invoke}) and attribute values ({@code setAttribute}). |
| | | * Unlike {@link #JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN}, which |
| | | * only guards the credentials object exchanged during authentication, this |
| | | * filter closes the post-authentication deserialization surface: without it, |
| | | * an authenticated client could drive native deserialization of arbitrary |
| | | * object graphs through MBean operation arguments |
| | | * (advisory GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495). |
| | | */ |
| | | static final String JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN = |
| | | "jmx.remote.rmi.server.serial.filter.pattern"; |
| | | |
| | | |
| | | /** |
| | | * Allowlist of the JDK and JMX management types OpenDJ legitimately receives |
| | | * as MBean operation arguments / attribute values, rejecting everything else |
| | | * via the trailing {@code !*}. OpenDJ exposes read-only configuration and |
| | | * monitoring attributes and no MBean operations, so the only types that need |
| | | * to cross this filter are strings, primitive wrappers and the standard |
| | | * {@code javax.management} request types ({@code ObjectName}, |
| | | * {@code Attribute}, {@code AttributeList}, {@code QueryExp}, ...). |
| | | */ |
| | | private static final String JMX_OPERATION_SERIAL_FILTER = |
| | | "maxdepth=20;" |
| | | + "java.lang.*;java.math.BigInteger;java.math.BigDecimal;" |
| | | + "java.util.*;" |
| | | + "javax.management.*;javax.management.openmbean.*;" |
| | | + "!*"; |
| | | |
| | | |
| | | /** |
| | | * The MBean server used to handle JMX interaction. |
| | | */ |
| | | private MBeanServer mbs; |
| | |
| | | |
| | | static void configureJmxDeserializationProtection(Map<String, Object> env) |
| | | { |
| | | // Scope the JEP 290 deserialization filter to the credentials object |
| | | // only, so legitimate JMX RMI traffic (MBean operations, notifications, |
| | | // etc.) is not affected by the restrictive allowlist. |
| | | // Tightly constrain the credentials object exchanged during authentication |
| | | // (CVE-2026-46495): only a two-element String[] is accepted. |
| | | // |
| | | // Do NOT also set "jmx.remote.rmi.server.credential.types": the JDK |
| | | // rejects an environment that defines both properties, which would |
| | | // prevent the RMI connector from starting. |
| | | env.put(JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN, |
| | | JMX_CREDENTIAL_SERIAL_FILTER); |
| | | |
| | | // Additionally constrain the rest of the RMI connection -- in particular |
| | | // MBean operation arguments and attribute values, which are deserialized |
| | | // after authentication -- to a small allowlist of JDK and JMX management |
| | | // types. This closes the post-authentication deserialization surface left |
| | | // open by the credentials-only filter |
| | | // (GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495). |
| | | env.put(JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN, |
| | | JMX_OPERATION_SERIAL_FILTER); |
| | | } |
| | | |
| | | /** |
| | |
| | | * |
| | | * Copyright 2006-2010 Sun Microsystems, Inc. |
| | | * Portions Copyright 2014-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC |
| | | */ |
| | | package org.opends.server.protocols.ldap; |
| | | |
| | |
| | | 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); |
| | |
| | | * |
| | | * Copyright 2006-2009 Sun Microsystems, Inc. |
| | | * Portions Copyright 2013-2015 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC |
| | | */ |
| | | package org.opends.server.types; |
| | | |
| | |
| | | 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.*; |
| | | |
| | | |
| | |
| | | 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 |
| | | { |
| | |
| | | { |
| | | 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: |
| | |
| | | * 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; |
| | |
| | | // could also be an absolute true/false filter |
| | | while (reader.hasNextElement()) |
| | | { |
| | | filterComponents.add(LDAPFilter.decode(reader)); |
| | | filterComponents.add(decode(reader, depth + 1)); |
| | | } |
| | | reader.readEndSequence(); |
| | | } |
| | |
| | | * 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) |
| | |
| | | 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 |
| | |
| | | private final List<LDAPReplicationDomain> domains = new ArrayList<>(); |
| | | private final Map<ReplicaId, ReplicationBroker> brokers = new HashMap<>(); |
| | | |
| | | /** Time of the last CSN built by {@link #generateCSNs(int, ReplicaId)}, to keep batches monotonic. */ |
| | | private long lastGeneratedCsnTime; |
| | | |
| | | @BeforeClass |
| | | @Override |
| | | public void setUp() throws Exception |
| | |
| | | |
| | | private CSN[] generateCSNs(int numberOfCsns, ReplicaId replicaId) |
| | | { |
| | | long startTime = TimeThread.getTime(); |
| | | // TimeThread only refreshes its cached time every 200 ms, so two batches generated within one |
| | | // tick would collide and be filtered out by the changelog as breaking the key ordering. |
| | | // Start after the previous batch to keep CSNs monotonically increasing, like CSNGenerator does. |
| | | long startTime = Math.max(TimeThread.getTime(), lastGeneratedCsnTime + 1); |
| | | |
| | | CSN[] csns = new CSN[numberOfCsns]; |
| | | for (int i = 0; i < numberOfCsns; i++) |
| | |
| | | // seqNum must be greater than 0, so start at 1 |
| | | csns[i] = new CSN(startTime + i, i + 1, replicaId.getServerId()); |
| | | } |
| | | lastGeneratedCsnTime = csns[numberOfCsns - 1].getTime(); |
| | | return csns; |
| | | } |
| | | |
| | |
| | | * Header, with the fields enclosed by brackets [] replaced by your own identifying |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2023-2024 3A Systems, LLC. |
| | | * Copyright 2023-2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | |
| | | container.start(); |
| | | } |
| | | try(Connection con= DriverManager.getConnection(createBackendCfg().getDBDirectory())){ |
| | | TestCase.dropStaleTrees(con); |
| | | } catch (Exception e) { |
| | | throw new SkipException("run before test: docker run --rm -it -p 5432:5432 -e POSTGRES_DB=database_name -e POSTGRES_PASSWORD=password --name postgres postgres"); |
| | | } |
| | |
| | | * Header, with the fields enclosed by brackets [] replaced by your own identifying |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2024-2025 3A Systems, LLC. |
| | | * Copyright 2024-2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.opendj.server.config.server.JDBCBackendCfg; |
| | | import org.opends.server.backends.pluggable.PluggableBackendImplTestCase; |
| | | import org.opends.server.backends.pluggable.spi.AccessMode; |
| | | import org.opends.server.backends.pluggable.spi.Cursor; |
| | | import org.opends.server.backends.pluggable.spi.ReadOperation; |
| | | import org.opends.server.backends.pluggable.spi.ReadableTransaction; |
| | | import org.opends.server.backends.pluggable.spi.TreeName; |
| | | import org.opends.server.backends.pluggable.spi.WriteOperation; |
| | | import org.opends.server.backends.pluggable.spi.WriteableTransaction; |
| | | import org.testcontainers.DockerClientFactory; |
| | | import org.testcontainers.containers.JdbcDatabaseContainer; |
| | | import org.testng.SkipException; |
| | | import org.testng.annotations.AfterClass; |
| | | import org.testng.annotations.BeforeClass; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import java.sql.Connection; |
| | | import java.sql.DriverManager; |
| | | import java.sql.ResultSet; |
| | | import java.sql.SQLException; |
| | | import java.sql.Statement; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.NoSuchElementException; |
| | | |
| | | import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; |
| | | import static org.mockito.Mockito.when; |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertFalse; |
| | | import static org.testng.Assert.assertNull; |
| | | import static org.testng.Assert.assertTrue; |
| | | import static org.testng.Assert.fail; |
| | | |
| | | |
| | | |
| | |
| | | throw new SkipException(getContainerDockerCommand()); |
| | | } |
| | | } |
| | | try(Connection ignored = DriverManager.getConnection(createBackendCfg().getDBDirectory())){ |
| | | |
| | | try(Connection con = DriverManager.getConnection(createBackendCfg().getDBDirectory())){ |
| | | dropStaleTrees(con); |
| | | } catch (Exception e) { |
| | | throw new SkipException(getContainerDockerCommand()); |
| | | } |
| | | super.setUp(); |
| | | } |
| | | |
| | | /** |
| | | * Backend test classes sharing one database map the same tree names to the same tables, |
| | | * so a previous run may leave trees behind — including entries encrypted with a lost cipher key. |
| | | */ |
| | | static void dropStaleTrees(Connection con) throws SQLException { |
| | | final List<String> stale = new ArrayList<>(); |
| | | try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) { |
| | | while (rs.next()) { |
| | | final String name = rs.getString("TABLE_NAME"); |
| | | if (name.toLowerCase().startsWith("opendj_")) { |
| | | stale.add(name); |
| | | } |
| | | } |
| | | } |
| | | try (final Statement st = con.createStatement()) { |
| | | for (final String name : stale) { |
| | | st.execute("drop table " + name); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | protected Backend createBackend() { |
| | | return new Backend(); |
| | |
| | | protected abstract String getBackendId(); |
| | | |
| | | protected abstract String getJdbcUrl(); |
| | | |
| | | private static ByteString key(int i) { |
| | | return ByteString.valueOfUtf8(String.format("key%02d", i)); |
| | | } |
| | | |
| | | private static ByteString value(int i) { |
| | | return ByteString.valueOfUtf8("value" + i); |
| | | } |
| | | |
| | | /** Cursor operations must keep working when the tree spans several "fetchsize" batches. */ |
| | | @Test |
| | | public void testCursorCrossesFetchSizeBatches() throws Exception { |
| | | System.setProperty("org.openidentityplatform.opendj.jdbc.fetchsize", "2"); |
| | | final Storage storage = new Storage(createBackendCfg(), null); |
| | | final TreeName tree = new TreeName("testCursorBatch", "tree"); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | for (int i = 0; i < 7; i++) { |
| | | txn.put(tree, key(i), value(i)); |
| | | } |
| | | } |
| | | }); |
| | | storage.read(new ReadOperation<Void>() { |
| | | @Override |
| | | public Void run(ReadableTransaction txn) throws Exception { |
| | | try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) { |
| | | for (int i = 0; i < 7; i++) { |
| | | assertTrue(cursor.next(), "next() at " + i); |
| | | assertEquals(cursor.getKey(), key(i)); |
| | | assertEquals(cursor.getValue(), value(i)); |
| | | } |
| | | assertFalse(cursor.next()); |
| | | assertFalse(cursor.isDefined()); |
| | | try { |
| | | cursor.getKey(); |
| | | fail("getKey() on undefined cursor must fail"); |
| | | } catch (NoSuchElementException expected) {} |
| | | |
| | | assertTrue(cursor.positionToKeyOrNext(key(3))); |
| | | assertEquals(cursor.getKey(), key(3)); |
| | | assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("key031"))); |
| | | assertEquals(cursor.getKey(), key(4)); |
| | | assertTrue(cursor.next()); |
| | | assertEquals(cursor.getKey(), key(5)); |
| | | assertTrue(cursor.next()); |
| | | assertEquals(cursor.getKey(), key(6)); |
| | | assertFalse(cursor.next()); |
| | | assertFalse(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("z"))); |
| | | assertFalse(cursor.isDefined()); |
| | | |
| | | assertTrue(cursor.positionToKey(key(5))); |
| | | assertEquals(cursor.getValue(), value(5)); |
| | | assertTrue(cursor.next()); |
| | | assertEquals(cursor.getKey(), key(6)); |
| | | assertFalse(cursor.positionToKey(ByteString.valueOfUtf8("key99"))); |
| | | assertFalse(cursor.isDefined()); |
| | | |
| | | assertTrue(cursor.positionToIndex(0)); |
| | | assertEquals(cursor.getKey(), key(0)); |
| | | assertTrue(cursor.positionToIndex(5)); |
| | | assertEquals(cursor.getKey(), key(5)); |
| | | assertTrue(cursor.next()); |
| | | assertEquals(cursor.getKey(), key(6)); |
| | | assertFalse(cursor.positionToIndex(7)); |
| | | assertFalse(cursor.positionToIndex(-1)); |
| | | |
| | | assertTrue(cursor.positionToLastKey()); |
| | | assertEquals(cursor.getKey(), key(6)); |
| | | assertFalse(cursor.next()); |
| | | |
| | | assertTrue(cursor.positionToKey(key(0))); |
| | | try { |
| | | cursor.delete(); |
| | | fail("delete() on read-only cursor must fail"); |
| | | } catch (UnsupportedOperationException expected) {} |
| | | } |
| | | return null; |
| | | } |
| | | }); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) { |
| | | assertTrue(cursor.positionToKey(key(3))); |
| | | cursor.delete(); |
| | | assertTrue(cursor.next()); |
| | | assertEquals(cursor.getKey(), key(4)); |
| | | } |
| | | assertNull(txn.read(tree, key(3))); |
| | | assertEquals(txn.getRecordCount(tree), 6); |
| | | } |
| | | }); |
| | | } finally { |
| | | System.clearProperty("org.openidentityplatform.opendj.jdbc.fetchsize"); |
| | | try { |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.deleteTree(tree); |
| | | } |
| | | }); |
| | | } catch (Exception ignored) {} |
| | | storage.close(); |
| | | } |
| | | } |
| | | } |
| | |
| | | "objectClasses: ( 16.32.256.1.1.1.12.4.6 NAME 'examplePerson' DESC 'Extension to person' SUP inetOrgPerson STRUCTURAL MUST ( exampleIdentifier ) MAY ( exampleEmails $ userAccountControl ) X-SCHEMA-FILE '999-user.ldif' )", |
| | | "" |
| | | ); |
| | | assertEquals(resultCode, 0); |
| | | // 20 = attributeOrValueExists: another backend test class already added these definitions |
| | | // to the schema of the shared in-JVM server |
| | | assertTrue(resultCode == 0 || resultCode == 20, "unexpected result code " + resultCode); |
| | | TestCaseUtils.addEntries( |
| | | Resources.readLines(Resources.getResource("issue496.ldif"), StandardCharsets.UTF_8).toArray(new String[]{}) |
| | | ); |
| New file |
| | |
| | | /* |
| | | * 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.controls; |
| | | |
| | | import static org.opends.server.protocols.internal.InternalClientConnection.*; |
| | | import static org.opends.server.protocols.internal.Requests.*; |
| | | import static org.testng.Assert.*; |
| | | |
| | | import org.forgerock.opendj.ldap.ResultCode; |
| | | import org.forgerock.opendj.ldap.SearchScope; |
| | | import org.opends.server.TestCaseUtils; |
| | | import org.opends.server.protocols.internal.InternalSearchOperation; |
| | | import org.opends.server.protocols.internal.SearchRequest; |
| | | import org.testng.annotations.BeforeClass; |
| | | import org.testng.annotations.Test; |
| | | |
| | | /** |
| | | * Regression test for GHSA-q4wx-wj4j-4657 / OPENDJ-003 — unbounded array |
| | | * allocation via VLV beforeCount/afterCount/offset (CWE-789 / CWE-770 / CWE-190). |
| | | * <p> |
| | | * A VLV-by-offset request carries attacker-controlled before/after counts. The |
| | | * server computed {@code count = 1 + beforeCount + afterCount} and allocated |
| | | * {@code new long[count]} without clamping it to the actual list size. With |
| | | * {@code afterCount = Integer.MAX_VALUE} the sum overflowed to a negative int, |
| | | * yielding a {@code NegativeArraySizeException} (or, with large non-overflowing |
| | | * values, a multi-gigabyte allocation / {@code OutOfMemoryError}) from a single |
| | | * search request. The fix computes the count with long arithmetic and clamps it |
| | | * to the number of entries actually available, so the oversized window is now |
| | | * bounded instead of faulting the search handler. |
| | | * <p> |
| | | * Note: this exercises the default in-memory sort path |
| | | * ({@code EntryContainer.sortByOffset}), which requires no VLV index — the same |
| | | * unclamped allocation also existed in {@code VLVIndex.readRange}. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | public class VLVOffsetAllocationDoSTest extends ControlsTestCase |
| | | { |
| | | @BeforeClass |
| | | public void startServer() throws Exception |
| | | { |
| | | TestCaseUtils.startServer(); |
| | | } |
| | | |
| | | private void populateDB() throws Exception |
| | | { |
| | | TestCaseUtils.clearBackend("userRoot", "dc=example,dc=com"); |
| | | TestCaseUtils.addEntries( |
| | | "dn: uid=albert.zimmerman,dc=example,dc=com", |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "uid: albert.zimmerman", |
| | | "givenName: Albert", |
| | | "sn: Zimmerman", |
| | | "cn: Albert Zimmerman", |
| | | "", |
| | | "dn: uid=aaron.zimmerman,dc=example,dc=com", |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "uid: aaron.zimmerman", |
| | | "givenName: Aaron", |
| | | "sn: Zimmerman", |
| | | "cn: Aaron Zimmerman", |
| | | "", |
| | | "dn: uid=mary.jones,dc=example,dc=com", |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "uid: mary.jones", |
| | | "givenName: Mary", |
| | | "sn: Jones", |
| | | "cn: Mary Jones"); |
| | | } |
| | | |
| | | /** |
| | | * A single VLV-by-offset search with {@code afterCount = Integer.MAX_VALUE} |
| | | * (which previously overflowed {@code 1 + beforeCount + afterCount} into a |
| | | * negative array size) must now be clamped to the list size and return the |
| | | * same bounded page as a normal request — not fault the search handler with a |
| | | * {@code NegativeArraySizeException} / {@code OTHER}. |
| | | */ |
| | | @Test |
| | | public void offsetAllocationOverflowIsClamped() throws Exception |
| | | { |
| | | populateDB(); |
| | | |
| | | // Baseline: a normal, bounded window. |
| | | SearchRequest ok = newSearchRequest("dc=example,dc=com", SearchScope.WHOLE_SUBTREE, "(objectClass=person)") |
| | | .addControl(new ServerSideSortRequestControl("givenName")) |
| | | .addControl(new VLVRequestControl(0, 3, 1, 0)); |
| | | InternalSearchOperation okOp = getRootConnection().processSearch(ok); |
| | | assertEquals(okOp.getResultCode(), ResultCode.SUCCESS); |
| | | |
| | | // Oversized window: afterCount = Integer.MAX_VALUE. Before the fix this |
| | | // overflowed to new long[-2147483648]; a larger non-overflowing count would |
| | | // instead force a multi-gigabyte allocation (OutOfMemoryError). |
| | | SearchRequest bad = newSearchRequest("dc=example,dc=com", SearchScope.WHOLE_SUBTREE, "(objectClass=person)") |
| | | .addControl(new ServerSideSortRequestControl("givenName")) |
| | | .addControl(new VLVRequestControl(0, Integer.MAX_VALUE, 1, 0)); |
| | | InternalSearchOperation badOp = getRootConnection().processSearch(bad); |
| | | |
| | | assertEquals(badOp.getResultCode(), ResultCode.SUCCESS, |
| | | "oversized VLV afterCount must be clamped to the list size, not fault the search handler " |
| | | + "(GHSA-q4wx-wj4j-4657): " + badOp.getErrorMessage()); |
| | | // The page is bounded by the number of entries that actually exist (offset 1 |
| | | // onwards), i.e. the same result as the bounded baseline request. |
| | | assertEquals(badOp.getSearchEntries().size(), okOp.getSearchEntries().size()); |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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. |
| | | * <p> |
| | | * 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. |
| | | * <p> |
| | | * This test exercises the surface end-to-end. It: |
| | | * <ol> |
| | | * <li>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);</li> |
| | | * <li>authenticates over JMX/RMI as a user holding {@code JMX_READ};</li> |
| | | * <li>invokes an operation passing a custom {@link Serializable} "canary" as an |
| | | * argument.</li> |
| | | * </ol> |
| | | * 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<String, Object> 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; |
| | | } |
| | | } |
| | | } |
| | |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | | |
| | | import javax.management.Attribute; |
| | | import javax.management.ObjectName; |
| | | |
| | | import org.opends.server.DirectoryServerTestCase; |
| | | import org.testng.annotations.DataProvider; |
| | |
| | | |
| | | assertEquals(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN), |
| | | "maxdepth=3;maxarray=2;java.lang.String;!*"); |
| | | // The connector-wide filter must NOT be set, so legitimate JMX traffic |
| | | // (MBean operations, notifications) is not affected by the allowlist. |
| | | assertNull(env.get("jmx.remote.rmi.server.serial.filter.pattern")); |
| | | // The connector-wide filter must also be set so that post-authentication |
| | | // traffic (MBean operation arguments, attribute values) is constrained to a |
| | | // safe allowlist instead of being deserialized without any filter |
| | | // (incomplete fix of CVE-2026-46495). |
| | | assertNotNull(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN)); |
| | | // "jmx.remote.rmi.server.credential.types" is mutually exclusive with the |
| | | // credentials filter pattern: setting both prevents the connector from |
| | | // starting, so only the filter pattern must be configured. |
| | | assertNull(env.get("jmx.remote.rmi.server.credential.types")); |
| | | } |
| | | |
| | | /** |
| | | * Verifies the connector-wide operation filter allows the JDK / JMX |
| | | * management types OpenDJ legitimately receives, while rejecting arbitrary |
| | | * (potentially gadget) classes. |
| | | */ |
| | | @Test |
| | | public void operationSerialFilterAllowsManagementTypesAndRejectsOthers() throws Exception |
| | | { |
| | | Map<String, Object> env = new HashMap<>(); |
| | | RmiConnector.configureJmxDeserializationProtection(env); |
| | | String filterPattern = (String) env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN); |
| | | |
| | | // Legitimate JMX management argument types must pass. |
| | | assertEquals(readWithFilter("an attribute name", filterPattern), "an attribute name"); |
| | | assertEquals(readWithFilter(new String[] { "a", "b" }, filterPattern), new String[] { "a", "b" }); |
| | | assertEquals(readWithFilter(new ObjectName("org.opends.server:type=test"), filterPattern), |
| | | new ObjectName("org.opends.server:type=test")); |
| | | assertEquals(readWithFilter(new Attribute("ds-cfg-listen-port", 1689), filterPattern), |
| | | new Attribute("ds-cfg-listen-port", 1689)); |
| | | |
| | | // Arbitrary application types must be rejected before readObject() runs. |
| | | assertRejectedByFilter(new UnexpectedArgument(), filterPattern); |
| | | } |
| | | |
| | | /** An arbitrary serializable type standing in for a gadget argument. */ |
| | | private static final class UnexpectedArgument implements java.io.Serializable |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | } |
| | | |
| | | /** Verifies the configured filter allows only the expected credential payload. */ |
| | | @Test |
| | | public void serialFilterAllowsOnlyTwoElementStringArray() throws Exception |
| | |
| | | "givenName: PWReset", |
| | | "sn: Target", |
| | | "uid: pwreset.target", |
| | | "userPassword: password"); |
| | | "userPassword: password", |
| | | "", |
| | | "dn: cn=Proxy Only User,o=test", |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "cn: Proxy Only User", |
| | | "givenName: Proxy", |
| | | "sn: User", |
| | | "uid: proxy.only", |
| | | "userPassword: password", |
| | | "ds-privilege-name: proxied-auth", |
| | | "", |
| | | "dn: cn=Proxy ACI User,o=test", |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "cn: Proxy ACI User", |
| | | "givenName: Proxy", |
| | | "sn: ACI User", |
| | | "uid: proxy.aci", |
| | | "userPassword: password", |
| | | "ds-privilege-name: proxied-auth"); |
| | | |
| | | TestCaseUtils.applyModifications(false, |
| | | "dn: o=test", |
| | |
| | | "userdn=\"ldap:///cn=Unprivileged Root,cn=Root DNs,cn=config\";)", |
| | | "aci: (version 3.0; acl \"Privileged User\"; allow (proxy) " + |
| | | "userdn=\"ldap:///cn=Privileged User,o=test\";)", |
| | | "aci: (version 3.0; acl \"Proxy ACI User\"; allow (proxy) " + |
| | | "userdn=\"ldap:///cn=Proxy ACI User,o=test\";)", |
| | | "aci: (targetattr=\"*\")(version 3.0; acl \"PWReset Target\"; " + |
| | | "allow (all) userdn=\"ldap:///cn=PWReset Target,o=test\";)"); |
| | | |
| | |
| | | |
| | | |
| | | /** |
| | | * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that |
| | | * holds the PROXIED_AUTH privilege but is not granted the "proxy" access |
| | | * control right for the target must be denied when specifying an alternate |
| | | * authorization ID through SASL PLAIN using the "dn:" syntax, matching the |
| | | * behaviour of the proxied authorization control and DIGEST-MD5. |
| | | * |
| | | * @throws Exception If an unexpected problem occurs. |
| | | */ |
| | | @Test |
| | | public void testPLAINDifferentAuthzIDNoProxyAciDeniedDNColon() |
| | | throws Exception |
| | | { |
| | | // Control: the proxy user can authenticate and act as itself, proving its |
| | | // entry and password are valid so the denial below is an authorization |
| | | // failure and not a setup or authentication problem. |
| | | String[] selfArgs = |
| | | { |
| | | "--noPropertiesFile", |
| | | "-h", "127.0.0.1", |
| | | "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), |
| | | "-o", "mech=PLAIN", |
| | | "-o", "authid=dn:cn=Proxy Only User,o=test", |
| | | "-o", "authzid=dn:cn=Proxy Only User,o=test", |
| | | "-w", "password", |
| | | "-b", "", |
| | | "-s", "base", |
| | | "(objectClass=*)" |
| | | }; |
| | | |
| | | assertEquals(runSearchWithSystemErr(selfArgs), 0); |
| | | |
| | | // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be |
| | | // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI. |
| | | String[] args = |
| | | { |
| | | "--noPropertiesFile", |
| | | "-h", "127.0.0.1", |
| | | "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), |
| | | "-o", "mech=PLAIN", |
| | | "-o", "authid=dn:cn=Proxy Only User,o=test", |
| | | "-o", "authzid=dn:cn=Unprivileged User,o=test", |
| | | "-w", "password", |
| | | "-b", "o=test", |
| | | "-s", "base", |
| | | "(objectClass=*)" |
| | | }; |
| | | |
| | | assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue()); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that |
| | | * holds the PROXIED_AUTH privilege but is not granted the "proxy" access |
| | | * control right for the target must be denied when specifying an alternate |
| | | * authorization ID through SASL PLAIN using the "u:" syntax, matching the |
| | | * behaviour of the proxied authorization control and DIGEST-MD5. |
| | | * |
| | | * @throws Exception If an unexpected problem occurs. |
| | | */ |
| | | @Test |
| | | public void testPLAINDifferentAuthzIDNoProxyAciDeniedUColon() |
| | | throws Exception |
| | | { |
| | | // Control: the proxy user can authenticate and act as itself, proving its |
| | | // entry and password are valid so the denial below is an authorization |
| | | // failure and not a setup or authentication problem. |
| | | String[] selfArgs = |
| | | { |
| | | "--noPropertiesFile", |
| | | "-h", "127.0.0.1", |
| | | "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), |
| | | "-o", "mech=PLAIN", |
| | | "-o", "authid=u:proxy.only", |
| | | "-o", "authzid=u:proxy.only", |
| | | "-w", "password", |
| | | "-b", "", |
| | | "-s", "base", |
| | | "(objectClass=*)" |
| | | }; |
| | | |
| | | assertEquals(runSearchWithSystemErr(selfArgs), 0); |
| | | |
| | | // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be |
| | | // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI. |
| | | String[] args = |
| | | { |
| | | "--noPropertiesFile", |
| | | "-h", "127.0.0.1", |
| | | "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), |
| | | "-o", "mech=PLAIN", |
| | | "-o", "authid=u:proxy.only", |
| | | "-o", "authzid=u:unprivileged.user", |
| | | "-w", "password", |
| | | "-b", "o=test", |
| | | "-s", "base", |
| | | "(objectClass=*)" |
| | | }; |
| | | |
| | | assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue()); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that |
| | | * holds the PROXIED_AUTH privilege AND is granted the "proxy" access control |
| | | * right for the target through an ACI, but does NOT hold the bypass-acl |
| | | * privilege, must be allowed to assume an alternate authorization ID through |
| | | * SASL PLAIN. This confirms the added mayProxy check does not over-deny |
| | | * legitimate ACI-granted proxying (the bypass-acl proxy users exercised by |
| | | * the other success tests short-circuit the ACI evaluation). |
| | | * |
| | | * @throws Exception If an unexpected problem occurs. |
| | | */ |
| | | @Test |
| | | public void testPLAINDifferentAuthzIDWithProxyAciSucceedsDNColon() |
| | | throws Exception |
| | | { |
| | | String[] args = |
| | | { |
| | | "--noPropertiesFile", |
| | | "-h", "127.0.0.1", |
| | | "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), |
| | | "-o", "mech=PLAIN", |
| | | "-o", "authid=dn:cn=Proxy ACI User,o=test", |
| | | "-o", "authzid=dn:cn=Unprivileged User,o=test", |
| | | "-w", "password", |
| | | "-b", "o=test", |
| | | "-s", "base", |
| | | "(objectClass=*)" |
| | | }; |
| | | |
| | | assertEquals(runSearchWithSystemErr(args), 0); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Tests the ability to disable a privilege so that an operation which will |
| | | * fail for a user without an appropriate privilege will succeed if that |
| | | * privilege is disabled. |
| New file |
| | |
| | | /* |
| | | * 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; |
| | | } |
| | | } |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | |
| | | <artifactId>opendj-server-msad-plugin</artifactId> |
| | |
| | | <parent> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | </parent> |
| | | <artifactId>opendj-server</artifactId> |
| | | <name>OpenDJ Server NG</name> |
| | |
| | | <modelVersion>4.0.0</modelVersion> |
| | | <groupId>org.openidentityplatform.opendj</groupId> |
| | | <artifactId>opendj-parent</artifactId> |
| | | <version>5.1.2-SNAPSHOT</version> |
| | | <version>5.2.0-SNAPSHOT</version> |
| | | <packaging>pom</packaging> |
| | | |
| | | <name>OpenDJ Directory Services Project</name> |
| | |
| | | <product.locales>ca_ES,es,de,fr,ja,ko,pl,zh_CN,zh_TW</product.locales> |
| | | <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
| | | <localized.jars.classifier>i18n</localized.jars.classifier> |
| | | <commons.version>3.1.2-SNAPSHOT</commons.version> |
| | | <commons.version>3.1.2</commons.version> |
| | | <freemarker.version>2.3.34</freemarker.version> |
| | | <metrics-core.version>4.2.30</metrics-core.version> |
| | | <bc.fips.version>2.1.2</bc.fips.version> |