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;
+ }
+ }
+}