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
+ * 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