This is an automated email from the ASF dual-hosted git repository.

steinarb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git


The following commit(s) were added to refs/heads/main by this push:
     new c2270e7ad [#2831] - Add optional JEP-290 ObjectInputFilter support to 
DefaultSerializer, with a conservative resource-limit default on RememberMe 
deserialization (#2832)
c2270e7ad is described below

commit c2270e7ad6c9c34fb1d2db9d86033d9af3f100e7
Author: Nexory <[email protected]>
AuthorDate: Wed Jul 29 16:42:54 2026 +0200

    [#2831] - Add optional JEP-290 ObjectInputFilter support to 
DefaultSerializer, with a conservative resource-limit default on RememberMe 
deserialization (#2832)
    
    * [#2831] - Add optional JEP-290 ObjectInputFilter support to 
DefaultSerializer
    
    Add an optional ObjectInputFilter 
(getObjectInputFilter/setObjectInputFilter,
    null by default) to DefaultSerializer, applied in deserialize() when set, 
and
    pre-configure AbstractRememberMeManager's default serializer with a 
conservative
    resource-limit-only filter 
(maxdepth=30;maxarray=100000;maxrefs=10000;maxbytes=10000000).
    Document the one-line class-based allow-list override on getSerializer(). 
The null
    default means no behavior change for existing callers.
    
    * Address review: pull ObjectInputFilter API up into Serializer, @since 
3.0.1
    
    Move getObjectInputFilter/setObjectInputFilter onto the Serializer interface
    as default methods (getter returns null, setter is a no-op); 
DefaultSerializer
    overrides them. AbstractRememberMeManager now calls
    serializer.setObjectInputFilter(...) directly, removing the instanceof 
check,
    the cast, and @SuppressWarnings. The documented override example and the 
test
    drop their casts too. Change all @since 3.1 occurrences to 3.0.1.
    
    * Potential fix for pull request finding
    
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    
    ---------
    
    Co-authored-by: Lenny Primak <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../shiro/mgt/AbstractRememberMeManager.java       |  49 ++++-
 ...ractRememberMeManagerObjectInputFilterTest.java | 207 +++++++++++++++++++++
 .../apache/shiro/lang/io/DefaultSerializer.java    |  52 ++++++
 .../java/org/apache/shiro/lang/io/Serializer.java  |  30 +++
 .../shiro/lang/io/DefaultSerializerTest.java       | 127 +++++++++++++
 5 files changed, 464 insertions(+), 1 deletion(-)

diff --git 
a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java 
b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
index d08eedafd..575d6480f 100644
--- a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
+++ b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
@@ -40,6 +40,7 @@ import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.ObjectInputFilter;
 import java.io.ObjectInputStream;
 import java.io.Serial;
 import java.io.Serializable;
@@ -91,6 +92,38 @@ public abstract class AbstractRememberMeManager implements 
RememberMeManager {
         }
     };
 
+    /**
+     * Default <a href="https://openjdk.org/jeps/290";>JEP-290</a> filter 
pattern applied to the
+     * {@link #getSerializer() serializer}'s {@code ObjectInputStream} when 
deserializing the RememberMe cookie
+     * payload, an untrusted, client-supplied value (see {@link 
#getRememberedPrincipals(SubjectContext)}).
+     * <p/>
+     * This default is deliberately a <em>resource-limit-only</em> filter: it 
bounds the object graph depth,
+     * array size, back-reference count, and total stream size that {@code 
readObject()} will process, but it
+     * does not restrict <em>which</em> classes may be deserialized. It is 
intended as defense-in-depth against
+     * oversized or deeply nested (denial-of-service shaped) payloads reaching 
this deserialization sink. It is
+     * <em>not</em> protection against remote-code-execution gadget chains: 
typical serialization gadget chains
+     * (for example the Apache Commons Collections family) are shallow and 
small, so they stay well within these
+     * limits and are <em>not</em> rejected by them. Stopping such chains 
requires a class-based allow-list,
+     * which cannot be a safe default here because principal types are 
entirely application-defined (custom
+     * {@code Serializable} principal classes are common) and a default 
allow-list would break existing
+     * deployments. Applications that need that stronger, class-based defense 
(for example to reduce the blast
+     * radius of a leaked or static cipher key, the classic Shiro-550 / 
CVE-2016-4437 scenario) can configure
+     * one in a single line; see {@link #getSerializer()}. The depth limit is 
set generously (well above the
+     * depth of realistic principal object graphs) so that well-formed 
principal data is not rejected.
+     *
+     * @since 3.0.1
+     */
+    private static final String DEFAULT_OBJECT_INPUT_FILTER_PATTERN =
+            "maxdepth=30;maxarray=100000;maxrefs=10000;maxbytes=10000000";
+
+    /**
+     * Default {@link ObjectInputFilter} instance built from {@link 
#DEFAULT_OBJECT_INPUT_FILTER_PATTERN}.
+     *
+     * @since 3.0.1
+     */
+    private static final ObjectInputFilter DEFAULT_OBJECT_INPUT_FILTER =
+            
ObjectInputFilter.Config.createFilter(DEFAULT_OBJECT_INPUT_FILTER_PATTERN);
+
     /**
      * Serializer to use for converting PrincipalCollection instances to/from 
byte arrays
      */
@@ -119,9 +152,14 @@ public abstract class AbstractRememberMeManager implements 
RememberMeManager {
     /**
      * Default constructor that initializes a {@link DefaultSerializer} as the 
{@link #getSerializer() serializer} and
      * an {@link AesCipherService} as the {@link #getCipherService() 
cipherService}.
+     * <p/>
+     * As defense-in-depth against the {@link 
#getRememberedPrincipals(SubjectContext)} deserialization path
+     * operating on untrusted, client-supplied input, the default serializer 
is also pre-configured with the
+     * {@link #DEFAULT_OBJECT_INPUT_FILTER_PATTERN conservative resource-limit 
ObjectInputFilter} described above.
      */
     public AbstractRememberMeManager() {
         setCipherKey(((AesCipherService) 
cipherService).generateNewKey().getEncoded());
+        serializer.setObjectInputFilter(DEFAULT_OBJECT_INPUT_FILTER);
     }
 
     /**
@@ -140,7 +178,16 @@ public abstract class AbstractRememberMeManager implements 
RememberMeManager {
      * persistent remember me storage.
      * <p/>
      * Unless overridden by the {@link #setSerializer} method, the default 
instance is a
-     * {@link org.apache.shiro.lang.io.DefaultSerializer}.
+     * {@link org.apache.shiro.lang.io.DefaultSerializer} pre-configured with 
the conservative resource-limit
+     * <a href="https://openjdk.org/jeps/290";>JEP-290</a> {@link 
ObjectInputFilter} described at
+     * {@link #DEFAULT_OBJECT_INPUT_FILTER_PATTERN}. Applications that know 
the exact set of principal classes
+     * they store (for example, a single {@code SimplePrincipalCollection} of 
{@code String}s) are encouraged to
+     * replace it with a stricter, class-based allow-list, built with
+     * {@link ObjectInputFilter.Config#createFilter(String)}:
+     * <pre>
+     * rememberMeManager.getSerializer()
+     *         
.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.example.MyPrincipal;!*"));
+     * </pre>
      *
      * @return the {@code Serializer} used to serialize and deserialize {@link 
PrincipalCollection} instances for
      * persistent remember me storage.
diff --git 
a/core/src/test/java/org/apache/shiro/mgt/AbstractRememberMeManagerObjectInputFilterTest.java
 
b/core/src/test/java/org/apache/shiro/mgt/AbstractRememberMeManagerObjectInputFilterTest.java
new file mode 100644
index 000000000..f286487e3
--- /dev/null
+++ 
b/core/src/test/java/org/apache/shiro/mgt/AbstractRememberMeManagerObjectInputFilterTest.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.shiro.mgt;
+
+import org.apache.shiro.lang.io.Serializer;
+import org.apache.shiro.subject.PrincipalCollection;
+import org.apache.shiro.subject.SimplePrincipalCollection;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.subject.SubjectContext;
+import org.apache.shiro.subject.support.DefaultSubjectContext;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InvalidClassException;
+import java.io.ObjectInputFilter;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Test cases proving {@link AbstractRememberMeManager}'s RememberMe cookie 
deserialization path is
+ * protected by a <a href="https://openjdk.org/jeps/290";>JEP-290</a> {@link 
ObjectInputFilter} by default,
+ * without breaking legitimate {@link PrincipalCollection} round-tripping.
+ */
+class AbstractRememberMeManagerObjectInputFilterTest {
+
+    /** Deeper than the default filter's {@code maxdepth=30}, to trigger a 
resource-limit rejection. */
+    private static final int DEEP_CHAIN_LENGTH = 60;
+
+    @Test
+    void testLegitimatePrincipalsRoundTripUnderDefaultFilter() {
+        InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
+        PrincipalCollection principals = new 
SimplePrincipalCollection("joecool", "myRealm");
+
+        rmm.rememberIdentity(null, principals);
+        PrincipalCollection remembered = rmm.getRememberedPrincipals(new 
DefaultSubjectContext());
+
+        assertThat(remembered).isNotNull();
+        assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
+    }
+
+    @Test
+    void testDefaultFilterRejectsOversizedPayloadBeforeFullConstruction() {
+        // The default filter (see 
AbstractRememberMeManager.DEFAULT_OBJECT_INPUT_FILTER_PATTERN) bounds the
+        // object graph depth/array size/reference count/byte count of the 
deserialized payload. This is
+        // denial-of-service-shaped-payload hardening: it does not restrict 
classes and does not stop RCE
+        // gadget chains (which are shallow and small). Feed an 
oversized/deeply nested payload that exceeds
+        // maxdepth, encrypted the way a real RememberMe cookie is, and 
confirm getRememberedPrincipals() fails
+        // closed with a JEP-290 filter rejection (InvalidClassException) 
before the graph is materialized. The
+        // InvalidClassException cause is the discriminating assertion: 
without the filter this same payload
+        // deserializes fully and fails only later with an unrelated 
ClassCastException, so asserting the cause
+        // is what proves the filter itself fired.
+        InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
+
+        List<Object> deepChain = new ArrayList<>();
+        List<Object> cursor = deepChain;
+        for (int i = 0; i < DEEP_CHAIN_LENGTH; i++) {
+            List<Object> next = new ArrayList<>();
+            cursor.add(next);
+            cursor = next;
+        }
+
+        byte[] serialized = plainJdkSerialize(deepChain);
+        byte[] encrypted = rmm.encryptForTest(serialized);
+        rmm.injectRawSerializedIdentity(encrypted);
+
+        assertThatThrownBy(() -> rmm.getRememberedPrincipals(new 
DefaultSubjectContext()))
+                .isInstanceOf(RuntimeException.class)
+                .hasCauseInstanceOf(InvalidClassException.class);
+        // onRememberedPrincipalFailure must have run its "forget" cleanup 
path.
+        assertThat(rmm.forgetCount).isEqualTo(1);
+    }
+
+    @Test
+    void testCustomStricterAllowListFilterCanBeConfigured() {
+        // Documented override path (see 
AbstractRememberMeManager#getSerializer javadoc): replace the default
+        // serializer's filter with a strict class allow-list. Only 
SimplePrincipalCollection,
+        // AbstractRememberMeManager.RememberedIdentity, and JDK 
collection/primitive/java.time plumbing are let
+        // through. Note: java.time types (e.g. Instant) don't serialize 
themselves directly - they writeReplace()
+        // to an internal java.time serialization proxy class, which is what 
actually appears in the stream.
+        InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
+        rmm.getSerializer()
+                .setObjectInputFilter(ObjectInputFilter.Config.createFilter(
+                        "org.apache.shiro.subject.SimplePrincipalCollection;"
+                                + 
"org.apache.shiro.mgt.AbstractRememberMeManager$RememberedIdentity;"
+                                + "java.time.*;java.util.*;java.lang.*;!*"));
+
+        PrincipalCollection principals = new 
SimplePrincipalCollection("joecool", "myRealm");
+        rmm.rememberIdentity(null, principals);
+        PrincipalCollection remembered = rmm.getRememberedPrincipals(new 
DefaultSubjectContext());
+        assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
+
+        // A disallowed class must now be rejected outright (not merely 
resource-limited).
+        byte[] disallowed = plainJdkSerialize(new NotAllowlisted());
+        rmm.injectRawSerializedIdentity(rmm.encryptForTest(disallowed));
+
+        assertThatThrownBy(() -> rmm.getRememberedPrincipals(new 
DefaultSubjectContext()))
+                .isInstanceOf(RuntimeException.class)
+                .hasCauseInstanceOf(InvalidClassException.class);
+        assertThat(rmm.forgetCount).isEqualTo(1);
+    }
+
+    @Test
+    void testCustomSerializerIsUnaffectedByDefaultFilterMachinery() {
+        // A caller-supplied Serializer implementation (not a 
DefaultSerializer) must keep working exactly as
+        // before this feature existed - AbstractRememberMeManager only 
touches the filter on its own default
+        // DefaultSerializer instance, never on a replaced Serializer.
+        InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
+        rmm.setSerializer(new 
Serializer<AbstractRememberMeManager.RememberedIdentity>() {
+            @Override
+            public byte[] 
serialize(AbstractRememberMeManager.RememberedIdentity o) {
+                return plainJdkSerialize(o);
+            }
+
+            @Override
+            @SuppressWarnings("unchecked")
+            public AbstractRememberMeManager.RememberedIdentity 
deserialize(byte[] serialized) {
+                try (var ois = new java.io.ObjectInputStream(new 
java.io.ByteArrayInputStream(serialized))) {
+                    return (AbstractRememberMeManager.RememberedIdentity) 
ois.readObject();
+                } catch (IOException | ClassNotFoundException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        });
+
+        PrincipalCollection principals = new 
SimplePrincipalCollection("joecool", "myRealm");
+        rmm.rememberIdentity(null, principals);
+        PrincipalCollection remembered = rmm.getRememberedPrincipals(new 
DefaultSubjectContext());
+
+        assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
+    }
+
+    private static byte[] plainJdkSerialize(Object o) {
+        try {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+                oos.writeObject(o);
+            }
+            return baos.toByteArray();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static class NotAllowlisted implements Serializable {
+        private static final long serialVersionUID = 1L;
+    }
+
+    /**
+     * Minimal in-memory RememberMeManager test double: stores the "persisted" 
(encrypted+serialized) bytes
+     * in a field instead of a cookie, and tracks how many times identity was 
forgotten.
+     */
+    private static final class InMemoryRememberMeManager extends 
AbstractRememberMeManager {
+        private byte[] stored;
+        private int forgetCount;
+
+        @Override
+        protected void forgetIdentity(Subject subject) {
+            stored = null;
+            forgetCount++;
+        }
+
+        public void forgetIdentity(SubjectContext subjectContext) {
+            stored = null;
+            forgetCount++;
+        }
+
+        @Override
+        protected void rememberSerializedIdentity(Subject subject, byte[] 
serialized) {
+            this.stored = serialized;
+        }
+
+        @Override
+        protected byte[] getRememberedSerializedIdentity(SubjectContext 
subjectContext) {
+            return stored;
+        }
+
+        void injectRawSerializedIdentity(byte[] raw) {
+            this.stored = raw;
+        }
+
+        byte[] encryptForTest(byte[] plain) {
+            return encrypt(plain);
+        }
+    }
+}
diff --git a/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java 
b/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
index ba6d89041..9f2683e3d 100644
--- a/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
+++ b/lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java
@@ -24,6 +24,7 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.ObjectInputFilter;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 
@@ -34,6 +35,18 @@ import java.io.ObjectOutputStream;
  * @since 0.9
  */
 public class DefaultSerializer<T> implements Serializer<T> {
+
+    /**
+     * Optional <a href="https://openjdk.org/jeps/290";>JEP-290</a> filter 
applied to the
+     * {@link ObjectInputStream} used by {@link #deserialize(byte[])}.
+     * <p/>
+     * {@code null} by default, meaning no filter is applied and behavior is 
unchanged from prior releases -
+     * existing callers of this class are not affected unless they opt in via 
{@link #setObjectInputFilter}.
+     *
+     * @since 3.0.1
+     */
+    private ObjectInputFilter objectInputFilter;
+
     /**
      * This implementation serializes the Object by using an {@link 
ObjectOutputStream} backed by a
      * {@link ByteArrayOutputStream}.  The {@code ByteArrayOutputStream}'s 
backing byte array is returned.
@@ -81,6 +94,9 @@ public class DefaultSerializer<T> implements Serializer<T> {
         BufferedInputStream bis = new BufferedInputStream(bais);
         try {
             ObjectInputStream ois = createObjectInputStream(bis);
+            if (objectInputFilter != null) {
+                ois.setObjectInputFilter(objectInputFilter);
+            }
             @SuppressWarnings({"unchecked"})
             T deserialized = (T) ois.readObject();
             ois.close();
@@ -94,4 +110,40 @@ public class DefaultSerializer<T> implements Serializer<T> {
     protected ObjectInputStream createObjectInputStream(InputStream 
inputStream) throws IOException {
         return new ClassResolvingObjectInputStream(inputStream);
     }
+
+    /**
+     * Returns the <a href="https://openjdk.org/jeps/290";>JEP-290</a> {@link 
ObjectInputFilter} applied to the
+     * {@link ObjectInputStream} used by {@link #deserialize(byte[])}, or 
{@code null} if none is configured.
+     *
+     * @return the configured {@code ObjectInputFilter}, or {@code null} if 
none is configured.
+     * @since 3.0.1
+     */
+    @Override
+    public ObjectInputFilter getObjectInputFilter() {
+        return objectInputFilter;
+    }
+
+    /**
+     * Sets a <a href="https://openjdk.org/jeps/290";>JEP-290</a> {@link 
ObjectInputFilter} to apply to the
+     * {@link ObjectInputStream} used by {@link #deserialize(byte[])}, 
providing defense-in-depth against
+     * malicious serialized payloads (for example, a class or resource-limit 
allow-list) in addition to any
+     * validation the caller performs on the deserialized result.
+     * <p/>
+     * The filter is consulted by the JVM for every class resolved while 
reading the stream, before that
+     * class is instantiated - a rejecting filter causes {@code deserialize} 
to fail (wrapped in a
+     * {@link SerializationException}) instead of constructing the disallowed 
object. See
+     * {@link ObjectInputFilter.Config#createFilter(String)} for a convenient 
way to build a pattern-based
+     * filter combining class allow/deny lists with depth, reference, and 
byte-count limits.
+     * <p/>
+     * The default is {@code null} (no filter), matching this class's behavior 
prior to this option being
+     * introduced. Callers handling untrusted input, such as {@link 
org.apache.shiro.mgt.AbstractRememberMeManager
+     * AbstractRememberMeManager}'s RememberMe cookie deserialization, are 
encouraged to configure one.
+     *
+     * @param objectInputFilter the filter to apply, or {@code null} to 
disable filtering (the default).
+     * @since 3.0.1
+     */
+    @Override
+    public void setObjectInputFilter(ObjectInputFilter objectInputFilter) {
+        this.objectInputFilter = objectInputFilter;
+    }
 }
diff --git a/lang/src/main/java/org/apache/shiro/lang/io/Serializer.java 
b/lang/src/main/java/org/apache/shiro/lang/io/Serializer.java
index 6e23ef9e0..4987a6c1a 100644
--- a/lang/src/main/java/org/apache/shiro/lang/io/Serializer.java
+++ b/lang/src/main/java/org/apache/shiro/lang/io/Serializer.java
@@ -18,6 +18,8 @@
  */
 package org.apache.shiro.lang.io;
 
+import java.io.ObjectInputFilter;
+
 /**
  * A <code>Serializer</code> converts objects to raw binary data and vice 
versa, enabling persistent storage
  * of objects to files, HTTP cookies, or other mechanism.
@@ -50,4 +52,32 @@ public interface Serializer<T> {
      * @throws SerializationException if an error occurs converting the raw 
byte[] array back into an Object.
      */
     T deserialize(byte[] serialized) throws SerializationException;
+
+    /**
+     * Returns the optional <a href="https://openjdk.org/jeps/290";>JEP-290</a> 
{@link ObjectInputFilter} this
+     * serializer applies while deserializing, or {@code null} if none is 
configured (the default).
+     * <p/>
+     * Serializers that do not perform Java object deserialization may ignore 
this; the default implementation
+     * returns {@code null}.
+     *
+     * @return the configured {@code ObjectInputFilter}, or {@code null} if 
none is configured.
+     * @since 3.0.1
+     */
+    default ObjectInputFilter getObjectInputFilter() {
+        return null;
+    }
+
+    /**
+     * Sets an optional <a href="https://openjdk.org/jeps/290";>JEP-290</a> 
{@link ObjectInputFilter} to apply while
+     * deserializing, providing defense-in-depth against malicious serialized 
payloads (for example a class or
+     * resource-limit allow-list) in addition to any validation the caller 
performs on the deserialized result.
+     * <p/>
+     * Serializers that do not perform Java object deserialization may ignore 
this; the default implementation is a
+     * no-op. {@link DefaultSerializer} applies the filter to the {@link 
java.io.ObjectInputStream} it uses.
+     *
+     * @param objectInputFilter the filter to apply, or {@code null} to 
disable filtering (the default).
+     * @since 3.0.1
+     */
+    default void setObjectInputFilter(ObjectInputFilter objectInputFilter) {
+    }
 }
diff --git 
a/lang/src/test/java/org/apache/shiro/lang/io/DefaultSerializerTest.java 
b/lang/src/test/java/org/apache/shiro/lang/io/DefaultSerializerTest.java
new file mode 100644
index 000000000..644ab729d
--- /dev/null
+++ b/lang/src/test/java/org/apache/shiro/lang/io/DefaultSerializerTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.shiro.lang.io;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InvalidClassException;
+import java.io.ObjectInputFilter;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Test cases for {@link DefaultSerializer}, in particular its optional
+ * <a href="https://openjdk.org/jeps/290";>JEP-290</a> {@link 
ObjectInputFilter} support.
+ */
+class DefaultSerializerTest {
+
+    /**
+     * Stand-in for an attacker-controlled "gadget" class. It is not itself a 
gadget chain - it simply proves,
+     * via a side effect in {@code readObject}, whether the JVM fully 
constructed an arbitrary Serializable
+     * class reachable on the classpath. A real gadget chain (e.g. from a 
library on the classpath) would
+     * trigger through the exact same {@link 
DefaultSerializer#deserialize(byte[])} sink.
+     */
+    public static class GadgetMarker implements Serializable {
+        private static final long serialVersionUID = 1L;
+
+        private transient boolean fired;
+
+        private void readObject(ObjectInputStream in) throws IOException, 
ClassNotFoundException {
+            in.defaultReadObject();
+            fired = true;
+        }
+
+        boolean isFired() {
+            return fired;
+        }
+    }
+
+    @Test
+    void testDeserializeWithNoFilterConfiguredIsUnchanged() {
+        // Default behavior (no ObjectInputFilter configured) must remain 
exactly as before this feature
+        // was added: any Serializable class on the classpath is deserialized 
without restriction.
+        DefaultSerializer<GadgetMarker> serializer = new DefaultSerializer<>();
+        assertThat(serializer.getObjectInputFilter()).isNull();
+
+        byte[] bytes = serializer.serialize(new GadgetMarker());
+        GadgetMarker result = serializer.deserialize(bytes);
+
+        assertThat(result.isFired()).isTrue();
+    }
+
+    @Test
+    void testDeserializeWithAllowListFilterRejectsDisallowedClass() {
+        // A strict class allow-list filter must reject GadgetMarker before it 
is constructed.
+        ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
+                
"org.apache.shiro.lang.io.DefaultSerializerTest$AllowedPayload;java.lang.String;!*");
+
+        DefaultSerializer<Object> serializer = new DefaultSerializer<>();
+        serializer.setObjectInputFilter(filter);
+        assertThat(serializer.getObjectInputFilter()).isSameAs(filter);
+
+        byte[] bytes = serializer.serialize(new GadgetMarker());
+
+        assertThatThrownBy(() -> serializer.deserialize(bytes))
+                .isInstanceOf(SerializationException.class)
+                .hasCauseInstanceOf(InvalidClassException.class);
+    }
+
+    @Test
+    void testDeserializeWithAllowListFilterPermitsAllowedClass() {
+        // The same filter must still allow round-tripping of the class(es) it 
permits.
+        ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
+                
"org.apache.shiro.lang.io.DefaultSerializerTest$AllowedPayload;java.lang.String;!*");
+
+        DefaultSerializer<AllowedPayload> serializer = new 
DefaultSerializer<>();
+        serializer.setObjectInputFilter(filter);
+
+        AllowedPayload original = new AllowedPayload("shiro");
+        byte[] bytes = serializer.serialize(original);
+        AllowedPayload result = serializer.deserialize(bytes);
+
+        assertThat(result.value).isEqualTo("shiro");
+    }
+
+    @Test
+    void testSetObjectInputFilterNullRestoresUnfilteredBehavior() {
+        DefaultSerializer<GadgetMarker> serializer = new DefaultSerializer<>();
+        
serializer.setObjectInputFilter(ObjectInputFilter.Config.createFilter("!*"));
+        serializer.setObjectInputFilter(null);
+
+        assertThat(serializer.getObjectInputFilter()).isNull();
+
+        byte[] bytes = serializer.serialize(new GadgetMarker());
+        GadgetMarker result = serializer.deserialize(bytes);
+
+        assertThat(result.isFired()).isTrue();
+    }
+
+    public static class AllowedPayload implements Serializable {
+        private static final long serialVersionUID = 1L;
+        final String value;
+
+        AllowedPayload(String value) {
+            this.value = value;
+        }
+    }
+}

Reply via email to