diff --git a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java index 70e365025fe07..6c0190ef55fc8 100644 --- a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java +++ b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java @@ -70,6 +70,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.ObjectInputFilter; import java.io.PrintStream; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; @@ -116,6 +117,27 @@ public void run() { }); } + /** + * Installs a process-wide serial filter that rejects all Java deserialization by default. + * Plugins that legitimately require Java serialization (e.g., security plugin's user attribute caching) + * can opt in by calling {@code ObjectInputStream.setObjectInputFilter()} on their specific stream, + * which overrides the JVM-wide filter for that stream. + *

+ * Gated behind the {@code bootstrap.serial_filter} setting (disabled by default). + */ + static void initializeSerialFilter() { + try { + ObjectInputFilter.Config.setSerialFilter(REJECT_ALL_FILTER); + } catch (IllegalStateException e) { + // Filter already set (e.g., via -Djdk.serialFilter system property or in tests) + LogManager.getLogger(Bootstrap.class).debug("Serial filter already initialized", e); + } + } + + static final ObjectInputFilter REJECT_ALL_FILTER = filterInfo -> filterInfo.serialClass() == null + ? ObjectInputFilter.Status.UNDECIDED + : ObjectInputFilter.Status.REJECTED; + /** initialize native resources */ public static void initializeNatives(Path tmpFile, boolean mlockAll, boolean systemCallFilter, boolean ctrlHandler) { final Logger logger = LogManager.getLogger(Bootstrap.class); @@ -183,6 +205,10 @@ static void initializeProbes() { private void setup(boolean addShutdownHook, Environment environment) throws BootstrapException { Settings settings = environment.settings(); + if (BootstrapSettings.SERIAL_FILTER_SETTING.get(settings)) { + initializeSerialFilter(); + } + try { spawner.spawnNativeControllers(environment, true); } catch (IOException e) { diff --git a/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java b/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java index 911bc92c433f1..665bcf87362de 100644 --- a/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java +++ b/server/src/main/java/org/opensearch/bootstrap/BootstrapSettings.java @@ -59,4 +59,6 @@ private BootstrapSettings() {} ); public static final Setting CTRLHANDLER_SETTING = Setting.boolSetting("bootstrap.ctrlhandler", true, Property.NodeScope); + public static final Setting SERIAL_FILTER_SETTING = Setting.boolSetting("bootstrap.serial_filter", false, Property.NodeScope); + } diff --git a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java index 6f8547c1eecdb..28b25c5e8a253 100644 --- a/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java +++ b/server/src/main/java/org/opensearch/common/settings/ClusterSettings.java @@ -643,6 +643,7 @@ public void apply(Settings value, Settings current, Settings previous) { BootstrapSettings.MEMORY_LOCK_SETTING, BootstrapSettings.SYSTEM_CALL_FILTER_SETTING, BootstrapSettings.CTRLHANDLER_SETTING, + BootstrapSettings.SERIAL_FILTER_SETTING, KeyStoreWrapper.SEED_SETTING, IndexingMemoryController.INDEX_BUFFER_SIZE_SETTING, IndexingMemoryController.MIN_INDEX_BUFFER_SIZE_SETTING, diff --git a/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java new file mode 100644 index 0000000000000..80c69336eeb68 --- /dev/null +++ b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java @@ -0,0 +1,152 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.bootstrap; + +import org.opensearch.common.SuppressForbidden; +import org.opensearch.test.OpenSearchTestCase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InvalidClassException; +import java.io.ObjectInputFilter; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Tests for the process-wide deserialization filter installed by {@link Bootstrap#initializeSerialFilter()}. + *

+ * The filter rejects all Java deserialization by default. Plugins that need deserialization + * (e.g., security plugin) opt in by calling {@code setObjectInputFilter()} on their stream, + * which overrides the JVM-wide filter for that stream. + *

+ * Note: String/primitive types use special serialization type codes (TC_STRING) that bypass + * ObjectInputFilter checks. These tests use ArrayList to exercise the filter on real object types. + */ +@SuppressForbidden(reason = "testing the runtime serialization filter that protects against java deserialization") +public class BootstrapSerialFilterTests extends OpenSearchTestCase { + + private static final boolean FILTER_INSTALLED; + + static { + // Install the JVM-wide filter. This can only be set once per JVM — if another test + // or the framework already set it, the end-to-end tests are skipped. + boolean installed = false; + try { + ObjectInputFilter.Config.setSerialFilter(Bootstrap.REJECT_ALL_FILTER); + installed = true; + } catch (IllegalStateException e) { + // Already set + } + FILTER_INSTALLED = installed; + } + + // --- Unit tests for the filter logic (always run) --- + + public void testRejectAllFilterRejectsClasses() { + assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(filterInfo(String.class))); + } + + public void testRejectAllFilterRejectsAnyClass() { + assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(filterInfo(Runtime.class))); + } + + public void testRejectAllFilterUndecidedForNullClass() { + // null serialClass = stream metadata check (depth, bytes, refs), not a class resolution + assertEquals(ObjectInputFilter.Status.UNDECIDED, Bootstrap.REJECT_ALL_FILTER.checkInput(filterInfo(null))); + } + + // --- End-to-end tests showing actual runtime behavior --- + + /** + * When a plugin uses ObjectInputStream without setting its own filter, + * deserialization fails with InvalidClassException at runtime. + * This is the protection against unexpected deserialization in plugins/dependencies. + */ + public void testDeserializationRejectedWithoutExplicitFilter() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + byte[] serialized = serialize(new ArrayList<>(List.of("a", "b"))); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + InvalidClassException e = expectThrows(InvalidClassException.class, ois::readObject); + assertTrue(e.getMessage().contains("REJECTED")); + } + } + + /** + * When a plugin explicitly sets its own filter (like security plugin's SafeObjectInputStream), + * the stream-level filter overrides the JVM-wide reject-all, and deserialization succeeds. + */ + public void testDeserializationAllowedWithExplicitFilter() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + byte[] serialized = serialize(new ArrayList<>(List.of("a", "b"))); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + // This is what security plugin does — sets a filter on the stream + ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("maxdepth=10")); + Object result = ois.readObject(); + assertEquals(List.of("a", "b"), result); + } + } + + /** + * Proves the stream-level filter is actually enforced — not just bypassing all checks. + * A maxdepth=2 filter allows a shallow ArrayList but rejects a deeply nested structure. + */ + public void testStreamFilterDepthConstraintIsEnforced() throws Exception { + assumeTrue("JVM-wide serial filter not installed in this JVM", FILTER_INSTALLED); + + // Create a deeply nested object: ArrayList -> ArrayList -> ArrayList (depth=3) + ArrayList deep = new ArrayList<>(); + deep.add(new ArrayList<>(List.of(new ArrayList<>(List.of("nested"))))); + + byte[] serialized = serialize(deep); + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + // maxdepth=2 should reject the depth=3 structure + ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("maxdepth=2")); + InvalidClassException e = expectThrows(InvalidClassException.class, ois::readObject); + assertTrue(e.getMessage().contains("REJECTED")); + } + } + + // --- Helpers --- + + private static ObjectInputFilter.FilterInfo filterInfo(Class clazz) { + return new ObjectInputFilter.FilterInfo() { + public Class serialClass() { + return clazz; + } + + public long arrayLength() { + return -1; + } + + public long depth() { + return 1; + } + + public long references() { + return 1; + } + + public long streamBytes() { + return 0; + } + }; + } + + private static byte[] serialize(Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + } + return baos.toByteArray(); + } +}