Skip to content

Commit 7c6a78d

Browse files
committed
[#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.
1 parent cde5990 commit 7c6a78d

4 files changed

Lines changed: 435 additions & 1 deletion

File tree

core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import java.io.IOException;
4242
import java.io.InputStream;
43+
import java.io.ObjectInputFilter;
4344
import java.io.ObjectInputStream;
4445
import java.io.Serial;
4546
import java.io.Serializable;
@@ -91,6 +92,38 @@ protected ClassLoader doGetClassLoader() {
9192
}
9293
};
9394

95+
/**
96+
* Default <a href="https://openjdk.org/jeps/290">JEP-290</a> filter pattern applied to the
97+
* {@link #getSerializer() serializer}'s {@code ObjectInputStream} when deserializing the RememberMe cookie
98+
* payload, an untrusted, client-supplied value (see {@link #getRememberedPrincipals(SubjectContext)}).
99+
* <p/>
100+
* This default is deliberately a <em>resource-limit-only</em> filter: it bounds the object graph depth,
101+
* array size, back-reference count, and total stream size that {@code readObject()} will process, but it
102+
* does not restrict <em>which</em> classes may be deserialized. It is intended as defense-in-depth against
103+
* oversized or deeply nested (denial-of-service shaped) payloads reaching this deserialization sink. It is
104+
* <em>not</em> protection against remote-code-execution gadget chains: typical serialization gadget chains
105+
* (for example the Apache Commons Collections family) are shallow and small, so they stay well within these
106+
* limits and are <em>not</em> rejected by them. Stopping such chains requires a class-based allow-list,
107+
* which cannot be a safe default here because principal types are entirely application-defined (custom
108+
* {@code Serializable} principal classes are common) and a default allow-list would break existing
109+
* deployments. Applications that need that stronger, class-based defense (for example to reduce the blast
110+
* radius of a leaked or static cipher key, the classic Shiro-550 / CVE-2016-4437 scenario) can configure
111+
* one in a single line; see {@link #getSerializer()}. The depth limit is set generously (well above the
112+
* depth of realistic principal object graphs) so that well-formed principal data is not rejected.
113+
*
114+
* @since 3.1
115+
*/
116+
private static final String DEFAULT_OBJECT_INPUT_FILTER_PATTERN =
117+
"maxdepth=30;maxarray=100000;maxrefs=10000;maxbytes=10000000";
118+
119+
/**
120+
* Default {@link ObjectInputFilter} instance built from {@link #DEFAULT_OBJECT_INPUT_FILTER_PATTERN}.
121+
*
122+
* @since 3.1
123+
*/
124+
private static final ObjectInputFilter DEFAULT_OBJECT_INPUT_FILTER =
125+
ObjectInputFilter.Config.createFilter(DEFAULT_OBJECT_INPUT_FILTER_PATTERN);
126+
94127
/**
95128
* Serializer to use for converting PrincipalCollection instances to/from byte arrays
96129
*/
@@ -119,9 +152,17 @@ protected ObjectInputStream createObjectInputStream(InputStream inputStream) thr
119152
/**
120153
* Default constructor that initializes a {@link DefaultSerializer} as the {@link #getSerializer() serializer} and
121154
* an {@link AesCipherService} as the {@link #getCipherService() cipherService}.
155+
* <p/>
156+
* As defense-in-depth against the {@link #getRememberedPrincipals(SubjectContext)} deserialization path
157+
* operating on untrusted, client-supplied input, the default serializer is also pre-configured with the
158+
* {@link #DEFAULT_OBJECT_INPUT_FILTER_PATTERN conservative resource-limit ObjectInputFilter} described above.
122159
*/
160+
@SuppressWarnings("unchecked")
123161
public AbstractRememberMeManager() {
124162
setCipherKey(((AesCipherService) cipherService).generateNewKey().getEncoded());
163+
if (serializer instanceof DefaultSerializer) {
164+
((DefaultSerializer<RememberedIdentity>) serializer).setObjectInputFilter(DEFAULT_OBJECT_INPUT_FILTER);
165+
}
125166
}
126167

127168
/**
@@ -140,7 +181,16 @@ public AbstractRememberMeManager(Supplier<byte[]> keySupplier) {
140181
* persistent remember me storage.
141182
* <p/>
142183
* Unless overridden by the {@link #setSerializer} method, the default instance is a
143-
* {@link org.apache.shiro.lang.io.DefaultSerializer}.
184+
* {@link org.apache.shiro.lang.io.DefaultSerializer} pre-configured with the conservative resource-limit
185+
* <a href="https://openjdk.org/jeps/290">JEP-290</a> {@link ObjectInputFilter} described at
186+
* {@link #DEFAULT_OBJECT_INPUT_FILTER_PATTERN}. Applications that know the exact set of principal classes
187+
* they store (for example, a single {@code SimplePrincipalCollection} of {@code String}s) are encouraged to
188+
* replace it with a stricter, class-based allow-list, built with
189+
* {@link ObjectInputFilter.Config#createFilter(String)}:
190+
* <pre>
191+
* ((DefaultSerializer&lt;?&gt;) rememberMeManager.getSerializer())
192+
* .setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.example.MyPrincipal;!*"));
193+
* </pre>
144194
*
145195
* @return the {@code Serializer} used to serialize and deserialize {@link PrincipalCollection} instances for
146196
* persistent remember me storage.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.shiro.mgt;
20+
21+
import org.apache.shiro.lang.io.DefaultSerializer;
22+
import org.apache.shiro.lang.io.Serializer;
23+
import org.apache.shiro.subject.PrincipalCollection;
24+
import org.apache.shiro.subject.SimplePrincipalCollection;
25+
import org.apache.shiro.subject.Subject;
26+
import org.apache.shiro.subject.SubjectContext;
27+
import org.apache.shiro.subject.support.DefaultSubjectContext;
28+
import org.junit.jupiter.api.Test;
29+
30+
import java.io.ByteArrayOutputStream;
31+
import java.io.IOException;
32+
import java.io.InvalidClassException;
33+
import java.io.ObjectInputFilter;
34+
import java.io.ObjectOutputStream;
35+
import java.io.Serializable;
36+
import java.util.ArrayList;
37+
import java.util.List;
38+
39+
import static org.assertj.core.api.Assertions.assertThat;
40+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
41+
42+
/**
43+
* Test cases proving {@link AbstractRememberMeManager}'s RememberMe cookie deserialization path is
44+
* protected by a <a href="https://openjdk.org/jeps/290">JEP-290</a> {@link ObjectInputFilter} by default,
45+
* without breaking legitimate {@link PrincipalCollection} round-tripping.
46+
*/
47+
class AbstractRememberMeManagerObjectInputFilterTest {
48+
49+
/** Deeper than the default filter's {@code maxdepth=30}, to trigger a resource-limit rejection. */
50+
private static final int DEEP_CHAIN_LENGTH = 60;
51+
52+
@Test
53+
void testLegitimatePrincipalsRoundTripUnderDefaultFilter() {
54+
InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
55+
PrincipalCollection principals = new SimplePrincipalCollection("joecool", "myRealm");
56+
57+
rmm.rememberIdentity(null, principals);
58+
PrincipalCollection remembered = rmm.getRememberedPrincipals(new DefaultSubjectContext());
59+
60+
assertThat(remembered).isNotNull();
61+
assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
62+
}
63+
64+
@Test
65+
void testDefaultFilterRejectsOversizedPayloadBeforeFullConstruction() {
66+
// The default filter (see AbstractRememberMeManager.DEFAULT_OBJECT_INPUT_FILTER_PATTERN) bounds the
67+
// object graph depth/array size/reference count/byte count of the deserialized payload. This is
68+
// denial-of-service-shaped-payload hardening: it does not restrict classes and does not stop RCE
69+
// gadget chains (which are shallow and small). Feed an oversized/deeply nested payload that exceeds
70+
// maxdepth, encrypted the way a real RememberMe cookie is, and confirm getRememberedPrincipals() fails
71+
// closed with a JEP-290 filter rejection (InvalidClassException) before the graph is materialized. The
72+
// InvalidClassException cause is the discriminating assertion: without the filter this same payload
73+
// deserializes fully and fails only later with an unrelated ClassCastException, so asserting the cause
74+
// is what proves the filter itself fired.
75+
InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
76+
77+
List<Object> deepChain = new ArrayList<>();
78+
List<Object> cursor = deepChain;
79+
for (int i = 0; i < DEEP_CHAIN_LENGTH; i++) {
80+
List<Object> next = new ArrayList<>();
81+
cursor.add(next);
82+
cursor = next;
83+
}
84+
85+
byte[] serialized = plainJdkSerialize(deepChain);
86+
byte[] encrypted = rmm.encryptForTest(serialized);
87+
rmm.injectRawSerializedIdentity(encrypted);
88+
89+
assertThatThrownBy(() -> rmm.getRememberedPrincipals(new DefaultSubjectContext()))
90+
.isInstanceOf(RuntimeException.class)
91+
.hasCauseInstanceOf(InvalidClassException.class);
92+
// onRememberedPrincipalFailure must have run its "forget" cleanup path.
93+
assertThat(rmm.forgetCount).isEqualTo(1);
94+
}
95+
96+
@Test
97+
@SuppressWarnings("unchecked")
98+
void testCustomStricterAllowListFilterCanBeConfigured() {
99+
// Documented override path (see AbstractRememberMeManager#getSerializer javadoc): cast the default
100+
// DefaultSerializer and replace its filter with a strict class allow-list. Only SimplePrincipalCollection,
101+
// AbstractRememberMeManager.RememberedIdentity, and JDK collection/primitive/java.time plumbing are let
102+
// through. Note: java.time types (e.g. Instant) don't serialize themselves directly - they writeReplace()
103+
// to an internal java.time serialization proxy class, which is what actually appears in the stream.
104+
InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
105+
((DefaultSerializer<AbstractRememberMeManager.RememberedIdentity>) rmm.getSerializer())
106+
.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
107+
"org.apache.shiro.subject.SimplePrincipalCollection;"
108+
+ "org.apache.shiro.mgt.AbstractRememberMeManager$RememberedIdentity;"
109+
+ "java.time.*;java.util.*;java.lang.*;!*"));
110+
111+
PrincipalCollection principals = new SimplePrincipalCollection("joecool", "myRealm");
112+
rmm.rememberIdentity(null, principals);
113+
PrincipalCollection remembered = rmm.getRememberedPrincipals(new DefaultSubjectContext());
114+
assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
115+
116+
// A disallowed class must now be rejected outright (not merely resource-limited).
117+
byte[] disallowed = plainJdkSerialize(new NotAllowlisted());
118+
rmm.injectRawSerializedIdentity(rmm.encryptForTest(disallowed));
119+
120+
assertThatThrownBy(() -> rmm.getRememberedPrincipals(new DefaultSubjectContext()))
121+
.isInstanceOf(RuntimeException.class);
122+
}
123+
124+
@Test
125+
void testCustomSerializerIsUnaffectedByDefaultFilterMachinery() {
126+
// A caller-supplied Serializer implementation (not a DefaultSerializer) must keep working exactly as
127+
// before this feature existed - AbstractRememberMeManager only touches the filter on its own default
128+
// DefaultSerializer instance, never on a replaced Serializer.
129+
InMemoryRememberMeManager rmm = new InMemoryRememberMeManager();
130+
rmm.setSerializer(new Serializer<AbstractRememberMeManager.RememberedIdentity>() {
131+
@Override
132+
public byte[] serialize(AbstractRememberMeManager.RememberedIdentity o) {
133+
return plainJdkSerialize(o);
134+
}
135+
136+
@Override
137+
@SuppressWarnings("unchecked")
138+
public AbstractRememberMeManager.RememberedIdentity deserialize(byte[] serialized) {
139+
try (var ois = new java.io.ObjectInputStream(new java.io.ByteArrayInputStream(serialized))) {
140+
return (AbstractRememberMeManager.RememberedIdentity) ois.readObject();
141+
} catch (IOException | ClassNotFoundException e) {
142+
throw new RuntimeException(e);
143+
}
144+
}
145+
});
146+
147+
PrincipalCollection principals = new SimplePrincipalCollection("joecool", "myRealm");
148+
rmm.rememberIdentity(null, principals);
149+
PrincipalCollection remembered = rmm.getRememberedPrincipals(new DefaultSubjectContext());
150+
151+
assertThat(remembered.getPrimaryPrincipal()).isEqualTo("joecool");
152+
}
153+
154+
private static byte[] plainJdkSerialize(Object o) {
155+
try {
156+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
157+
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
158+
oos.writeObject(o);
159+
}
160+
return baos.toByteArray();
161+
} catch (IOException e) {
162+
throw new RuntimeException(e);
163+
}
164+
}
165+
166+
public static class NotAllowlisted implements Serializable {
167+
private static final long serialVersionUID = 1L;
168+
}
169+
170+
/**
171+
* Minimal in-memory RememberMeManager test double: stores the "persisted" (encrypted+serialized) bytes
172+
* in a field instead of a cookie, and tracks how many times identity was forgotten.
173+
*/
174+
private static final class InMemoryRememberMeManager extends AbstractRememberMeManager {
175+
private byte[] stored;
176+
private int forgetCount;
177+
178+
@Override
179+
protected void forgetIdentity(Subject subject) {
180+
stored = null;
181+
forgetCount++;
182+
}
183+
184+
public void forgetIdentity(SubjectContext subjectContext) {
185+
stored = null;
186+
forgetCount++;
187+
}
188+
189+
@Override
190+
protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
191+
this.stored = serialized;
192+
}
193+
194+
@Override
195+
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
196+
return stored;
197+
}
198+
199+
void injectRawSerializedIdentity(byte[] raw) {
200+
this.stored = raw;
201+
}
202+
203+
byte[] encryptForTest(byte[] plain) {
204+
return encrypt(plain);
205+
}
206+
}
207+
}

lang/src/main/java/org/apache/shiro/lang/io/DefaultSerializer.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.io.ByteArrayOutputStream;
2525
import java.io.IOException;
2626
import java.io.InputStream;
27+
import java.io.ObjectInputFilter;
2728
import java.io.ObjectInputStream;
2829
import java.io.ObjectOutputStream;
2930

@@ -34,6 +35,18 @@
3435
* @since 0.9
3536
*/
3637
public class DefaultSerializer<T> implements Serializer<T> {
38+
39+
/**
40+
* Optional <a href="https://openjdk.org/jeps/290">JEP-290</a> filter applied to the
41+
* {@link ObjectInputStream} used by {@link #deserialize(byte[])}.
42+
* <p/>
43+
* {@code null} by default, meaning no filter is applied and behavior is unchanged from prior releases -
44+
* existing callers of this class are not affected unless they opt in via {@link #setObjectInputFilter}.
45+
*
46+
* @since 3.1
47+
*/
48+
private ObjectInputFilter objectInputFilter;
49+
3750
/**
3851
* This implementation serializes the Object by using an {@link ObjectOutputStream} backed by a
3952
* {@link ByteArrayOutputStream}. The {@code ByteArrayOutputStream}'s backing byte array is returned.
@@ -81,6 +94,9 @@ public T deserialize(byte[] serialized) throws SerializationException {
8194
BufferedInputStream bis = new BufferedInputStream(bais);
8295
try {
8396
ObjectInputStream ois = createObjectInputStream(bis);
97+
if (objectInputFilter != null) {
98+
ois.setObjectInputFilter(objectInputFilter);
99+
}
84100
@SuppressWarnings({"unchecked"})
85101
T deserialized = (T) ois.readObject();
86102
ois.close();
@@ -94,4 +110,38 @@ public T deserialize(byte[] serialized) throws SerializationException {
94110
protected ObjectInputStream createObjectInputStream(InputStream inputStream) throws IOException {
95111
return new ClassResolvingObjectInputStream(inputStream);
96112
}
113+
114+
/**
115+
* Returns the <a href="https://openjdk.org/jeps/290">JEP-290</a> {@link ObjectInputFilter} applied to the
116+
* {@link ObjectInputStream} used by {@link #deserialize(byte[])}, or {@code null} if none is configured.
117+
*
118+
* @return the configured {@code ObjectInputFilter}, or {@code null} if none is configured.
119+
* @since 3.1
120+
*/
121+
public ObjectInputFilter getObjectInputFilter() {
122+
return objectInputFilter;
123+
}
124+
125+
/**
126+
* Sets a <a href="https://openjdk.org/jeps/290">JEP-290</a> {@link ObjectInputFilter} to apply to the
127+
* {@link ObjectInputStream} used by {@link #deserialize(byte[])}, providing defense-in-depth against
128+
* malicious serialized payloads (for example, a class or resource-limit allow-list) in addition to any
129+
* validation the caller performs on the deserialized result.
130+
* <p/>
131+
* The filter is consulted by the JVM for every class resolved while reading the stream, before that
132+
* class is instantiated - a rejecting filter causes {@code deserialize} to fail (wrapped in a
133+
* {@link SerializationException}) instead of constructing the disallowed object. See
134+
* {@link ObjectInputFilter.Config#createFilter(String)} for a convenient way to build a pattern-based
135+
* filter combining class allow/deny lists with depth, reference, and byte-count limits.
136+
* <p/>
137+
* The default is {@code null} (no filter), matching this class's behavior prior to this option being
138+
* introduced. Callers handling untrusted input, such as {@link org.apache.shiro.mgt.AbstractRememberMeManager
139+
* AbstractRememberMeManager}'s RememberMe cookie deserialization, are encouraged to configure one.
140+
*
141+
* @param objectInputFilter the filter to apply, or {@code null} to disable filtering (the default).
142+
* @since 3.1
143+
*/
144+
public void setObjectInputFilter(ObjectInputFilter objectInputFilter) {
145+
this.objectInputFilter = objectInputFilter;
146+
}
97147
}

0 commit comments

Comments
 (0)