From d6224f9e6922177431316872451e380c25010046 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Wed, 3 Jun 2026 18:03:22 +0000 Subject: [PATCH 1/3] Extend forbidden api for java serialization from build-time only to also enforce at runtime Signed-off-by: Craig Perkins --- .../org/opensearch/bootstrap/Bootstrap.java | 22 +++++ .../bootstrap/BootstrapSerialFilterTests.java | 82 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java diff --git a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java index 70e365025fe07..d4fdf82802457 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,25 @@ public void run() { }); } + /** + * Installs a process-wide serial filter factory 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 the factory will respect. + */ + static void initializeSerialFilter() { + try { + ObjectInputFilter.Config.setSerialFilterFactory((current, requested) -> requested != null ? requested : REJECT_ALL_FILTER); + } catch (IllegalStateException e) { + // Factory already set (e.g., in tests where multiple initializations may occur) + LogManager.getLogger(Bootstrap.class).debug("Serial filter factory 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 +203,8 @@ static void initializeProbes() { private void setup(boolean addShutdownHook, Environment environment) throws BootstrapException { Settings settings = environment.settings(); + initializeSerialFilter(); + try { spawner.spawnNativeControllers(environment, true); } catch (IOException e) { 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..38112b1218dbb --- /dev/null +++ b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java @@ -0,0 +1,82 @@ +/* + * 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.test.OpenSearchTestCase; + +import java.io.ObjectInputFilter; + +public class BootstrapSerialFilterTests extends OpenSearchTestCase { + + public void testRejectAllFilterRejectsClasses() { + ObjectInputFilter.FilterInfo info = filterInfo(String.class); + assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + } + + public void testRejectAllFilterRejectsAnyClass() { + ObjectInputFilter.FilterInfo info = filterInfo(Runtime.class); + assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + } + + public void testRejectAllFilterUndecidedForNullClass() { + // null serialClass = stream metadata check (depth, bytes, refs), not a class resolution + ObjectInputFilter.FilterInfo info = filterInfo(null); + assertEquals(ObjectInputFilter.Status.UNDECIDED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + } + + public void testFactoryReturnsRejectAllWhenNoFilterRequested() { + ObjectInputFilter result = applyFactory(null, null); + assertSame(Bootstrap.REJECT_ALL_FILTER, result); + } + + public void testFactoryDelegatesToRequestedFilter() { + ObjectInputFilter customFilter = info -> ObjectInputFilter.Status.ALLOWED; + ObjectInputFilter result = applyFactory(null, customFilter); + assertSame(customFilter, result); + } + + public void testFactoryIgnoresCurrentFilterWhenRequestedIsSet() { + ObjectInputFilter current = info -> ObjectInputFilter.Status.REJECTED; + ObjectInputFilter requested = info -> ObjectInputFilter.Status.ALLOWED; + ObjectInputFilter result = applyFactory(current, requested); + assertSame(requested, result); + } + + /** + * Exercises the exact lambda logic from Bootstrap.initializeSerialFilter(). + * This is the same function passed to setSerialFilterFactory. + */ + private static ObjectInputFilter applyFactory(ObjectInputFilter current, ObjectInputFilter requested) { + return requested != null ? requested : Bootstrap.REJECT_ALL_FILTER; + } + + 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; + } + }; + } +} From 2e6eb102a5a68ec6cfa56230925cc535e9bfe800 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Fri, 5 Jun 2026 19:32:33 +0000 Subject: [PATCH 2/3] Add more tests Signed-off-by: Craig Perkins --- .../org/opensearch/bootstrap/Bootstrap.java | 10 +- .../bootstrap/BootstrapSerialFilterTests.java | 118 ++++++++++++++---- 2 files changed, 99 insertions(+), 29 deletions(-) diff --git a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java index d4fdf82802457..bc5cc59b65ef4 100644 --- a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java +++ b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java @@ -118,17 +118,17 @@ public void run() { } /** - * Installs a process-wide serial filter factory that rejects all Java deserialization by default. + * 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 the factory will respect. + * which overrides the JVM-wide filter for that stream. */ static void initializeSerialFilter() { try { - ObjectInputFilter.Config.setSerialFilterFactory((current, requested) -> requested != null ? requested : REJECT_ALL_FILTER); + ObjectInputFilter.Config.setSerialFilter(REJECT_ALL_FILTER); } catch (IllegalStateException e) { - // Factory already set (e.g., in tests where multiple initializations may occur) - LogManager.getLogger(Bootstrap.class).debug("Serial filter factory already initialized", e); + // Filter already set (e.g., via -Djdk.serialFilter system property or in tests) + LogManager.getLogger(Bootstrap.class).debug("Serial filter already initialized", e); } } diff --git a/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java index 38112b1218dbb..80c69336eeb68 100644 --- a/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java +++ b/server/src/test/java/org/opensearch/bootstrap/BootstrapSerialFilterTests.java @@ -8,54 +8,116 @@ 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() { - ObjectInputFilter.FilterInfo info = filterInfo(String.class); - assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(filterInfo(String.class))); } public void testRejectAllFilterRejectsAnyClass() { - ObjectInputFilter.FilterInfo info = filterInfo(Runtime.class); - assertEquals(ObjectInputFilter.Status.REJECTED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + 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 - ObjectInputFilter.FilterInfo info = filterInfo(null); - assertEquals(ObjectInputFilter.Status.UNDECIDED, Bootstrap.REJECT_ALL_FILTER.checkInput(info)); + assertEquals(ObjectInputFilter.Status.UNDECIDED, Bootstrap.REJECT_ALL_FILTER.checkInput(filterInfo(null))); } - public void testFactoryReturnsRejectAllWhenNoFilterRequested() { - ObjectInputFilter result = applyFactory(null, null); - assertSame(Bootstrap.REJECT_ALL_FILTER, result); - } + // --- End-to-end tests showing actual runtime behavior --- - public void testFactoryDelegatesToRequestedFilter() { - ObjectInputFilter customFilter = info -> ObjectInputFilter.Status.ALLOWED; - ObjectInputFilter result = applyFactory(null, customFilter); - assertSame(customFilter, result); + /** + * 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")); + } } - public void testFactoryIgnoresCurrentFilterWhenRequestedIsSet() { - ObjectInputFilter current = info -> ObjectInputFilter.Status.REJECTED; - ObjectInputFilter requested = info -> ObjectInputFilter.Status.ALLOWED; - ObjectInputFilter result = applyFactory(current, requested); - assertSame(requested, result); + /** + * 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); + } } /** - * Exercises the exact lambda logic from Bootstrap.initializeSerialFilter(). - * This is the same function passed to setSerialFilterFactory. + * 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. */ - private static ObjectInputFilter applyFactory(ObjectInputFilter current, ObjectInputFilter requested) { - return requested != null ? requested : Bootstrap.REJECT_ALL_FILTER; + 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() { @@ -79,4 +141,12 @@ public long streamBytes() { } }; } + + private static byte[] serialize(Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + } + return baos.toByteArray(); + } } From 876ec7970b74c8bbdf7d3073464d414f79b50388 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Tue, 9 Jun 2026 02:00:09 +0000 Subject: [PATCH 3/3] Put new logic behind feature flag disabled by default for bwc Signed-off-by: Craig Perkins --- .../src/main/java/org/opensearch/bootstrap/Bootstrap.java | 6 +++++- .../java/org/opensearch/bootstrap/BootstrapSettings.java | 2 ++ .../org/opensearch/common/settings/ClusterSettings.java | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java index bc5cc59b65ef4..6c0190ef55fc8 100644 --- a/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java +++ b/server/src/main/java/org/opensearch/bootstrap/Bootstrap.java @@ -122,6 +122,8 @@ public void run() { * 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 { @@ -203,7 +205,9 @@ static void initializeProbes() { private void setup(boolean addShutdownHook, Environment environment) throws BootstrapException { Settings settings = environment.settings(); - initializeSerialFilter(); + if (BootstrapSettings.SERIAL_FILTER_SETTING.get(settings)) { + initializeSerialFilter(); + } try { spawner.spawnNativeControllers(environment, true); 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 df4909155d7e9..0b067623218e0 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,