Skip to content

Commit a1a8e3b

Browse files
committed
GHSA-xjw4-4w2v-rp6g Unsafe Java deserialization of persisted scheduler state in Quartz job store
1 parent 56c29e7 commit a1a8e3b

2 files changed

Lines changed: 261 additions & 8 deletions

File tree

openidm-quartz-fragment/src/main/java/org/forgerock/openidm/quartz/impl/RepoJobStoreUtils.java

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,118 @@
2828

2929
import java.io.ByteArrayInputStream;
3030
import java.io.ByteArrayOutputStream;
31+
import java.io.ObjectInputFilter;
3132
import java.io.ObjectInputStream;
3233
import java.io.ObjectOutputStream;
3334
import java.io.Serializable;
3435

3536
import org.forgerock.util.encode.Base64;
3637
import org.quartz.JobPersistenceException;
38+
import org.slf4j.Logger;
39+
import org.slf4j.LoggerFactory;
3740

3841
public class RepoJobStoreUtils {
3942

43+
private static final Logger logger = LoggerFactory.getLogger(RepoJobStoreUtils.class);
44+
45+
/**
46+
* Package name prefixes that are considered safe to deserialize from the
47+
* scheduler repository. The persisted objects are always Quartz
48+
* jobs/triggers/calendars whose state is composed of Quartz types and a
49+
* bounded set of JDK types (collections, dates, time zones, numbers and
50+
* strings) originating from the schedule configuration. A persisted
51+
* {@code JobDetail} also carries its job implementation class, so the
52+
* package containing OpenIDM's scheduler jobs is allowed. Restricting
53+
* deserialization to these types prevents arbitrary gadget-chain classes
54+
* on the classpath from being instantiated (CWE-502).
55+
*/
56+
private static final String[] ALLOWED_PREFIXES = {
57+
"org.quartz.",
58+
"org.forgerock.openidm.quartz.impl.",
59+
"java.lang.",
60+
"java.util.",
61+
"java.math.",
62+
"java.time.",
63+
// concrete TimeZone implementation returned by TimeZone.getTimeZone()
64+
"sun.util.calendar."
65+
};
66+
67+
/**
68+
* Subpackages of the allowed prefixes that never appear in legitimate
69+
* scheduler state and are rejected outright to keep the reachable type
70+
* graph small — notably {@code java.lang.invoke.SerializedLambda}, whose
71+
* {@code readResolve()} reflectively invokes methods on its capturing
72+
* class, the {@code java.lang.reflect} proxy/reflection types, and Quartz's
73+
* utility jobs that can execute commands or invoke remote services from
74+
* values in a {@code JobDataMap}.
75+
*/
76+
private static final String[] REJECTED_PREFIXES = {
77+
"java.lang.invoke.",
78+
"java.lang.reflect.",
79+
"org.quartz.jobs."
80+
};
81+
82+
/**
83+
* An {@link ObjectInputFilter} that only permits the Quartz and JDK types
84+
* that legitimately appear in serialized scheduler state, rejecting any
85+
* other class before it can be resolved or instantiated.
86+
*/
87+
private static final ObjectInputFilter SCHEDULER_FILTER = RepoJobStoreUtils::checkInput;
88+
89+
/**
90+
* Resource limits applied alongside the type allowlist so that a crafted
91+
* payload cannot exhaust memory or the stack during deserialization (for
92+
* example by declaring an oversized array or a deeply nested graph). These
93+
* bounds are far above any legitimate serialized job/trigger/calendar while
94+
* still preventing trivial denial of service (JEP 290).
95+
*/
96+
private static final ObjectInputFilter LIMIT_FILTER = ObjectInputFilter.Config.createFilter(
97+
"maxbytes=10000000;maxarray=1000000;maxrefs=1000000;maxdepth=100");
98+
99+
/**
100+
* The type allowlist combined with the resource limits. Reused across
101+
* invocations; the per-stream JVM-wide filter (if any) is merged in at
102+
* deserialization time.
103+
*/
104+
private static final ObjectInputFilter BASE_FILTER =
105+
ObjectInputFilter.merge(SCHEDULER_FILTER, LIMIT_FILTER);
106+
107+
private static ObjectInputFilter.Status checkInput(ObjectInputFilter.FilterInfo info) {
108+
Class<?> clazz = info.serialClass();
109+
if (clazz == null) {
110+
// Not a class check (e.g. array length / reference count limits);
111+
// leave the decision to any subsequent checks.
112+
return ObjectInputFilter.Status.UNDECIDED;
113+
}
114+
// Unwrap array types down to their base component type.
115+
while (clazz.isArray()) {
116+
clazz = clazz.getComponentType();
117+
}
118+
if (clazz.isPrimitive()) {
119+
return ObjectInputFilter.Status.ALLOWED;
120+
}
121+
final String name = clazz.getName();
122+
if (isAllowed(name)) {
123+
return ObjectInputFilter.Status.ALLOWED;
124+
}
125+
logger.warn("Rejected deserialization of class {} from the scheduler repository", name);
126+
return ObjectInputFilter.Status.REJECTED;
127+
}
128+
129+
private static boolean isAllowed(String name) {
130+
for (String prefix : REJECTED_PREFIXES) {
131+
if (name.startsWith(prefix)) {
132+
return false;
133+
}
134+
}
135+
for (String prefix : ALLOWED_PREFIXES) {
136+
if (name.startsWith(prefix)) {
137+
return true;
138+
}
139+
}
140+
return false;
141+
}
142+
40143
/**
41144
* Converts a serializable object into a String.
42145
*
@@ -52,8 +155,8 @@ public static String serialize(Serializable object) throws JobPersistenceExcepti
52155
oos.close();
53156
return Base64.encode(baos.toByteArray());
54157
} catch (Exception e) {
55-
e.printStackTrace();
56-
throw new JobPersistenceException(e.getMessage());
158+
logger.warn("Failed to serialize scheduler state", e);
159+
throw new JobPersistenceException(e.getMessage(), e);
57160
}
58161
}
59162

@@ -70,13 +173,22 @@ public static Object deserialize(String str) throws JobPersistenceException {
70173
if (bytes == null) {
71174
bytes = new byte[0];
72175
}
73-
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
74-
Object o = ois.readObject();
75-
ois.close();
76-
return o;
176+
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
177+
// Restrict deserialization to the expected Quartz/JDK types and
178+
// bound the resources it may consume, so that a crafted payload
179+
// cannot trigger a gadget-chain remote code execution (CWE-502)
180+
// or exhaust memory. Setting a stream filter replaces the
181+
// JVM-wide jdk.serialFilter for this stream (JEP 415), so any
182+
// operator-configured filter is merged in rather than dropped.
183+
final ObjectInputFilter jvmWide = ois.getObjectInputFilter();
184+
ois.setObjectInputFilter(jvmWide == null
185+
? BASE_FILTER
186+
: ObjectInputFilter.merge(BASE_FILTER, jvmWide));
187+
return ois.readObject();
188+
}
77189
} catch (Exception e) {
78-
e.printStackTrace();
79-
throw new JobPersistenceException(e.getMessage());
190+
logger.warn("Failed to deserialize scheduler state", e);
191+
throw new JobPersistenceException(e.getMessage(), e);
80192
}
81193
}
82194
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* The contents of this file are subject to the terms of the Common Development and
3+
* Distribution License (the License). You may not use this file except in compliance with the
4+
* License.
5+
*
6+
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7+
* specific language governing permission and limitations under the License.
8+
*
9+
* When distributing Covered Software, include this CDDL Header Notice in each file and include
10+
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11+
* Header, with the fields enclosed by brackets [] replaced by your own identifying
12+
* information: "Portions copyright [year] [name of copyright owner]".
13+
*
14+
* Copyright 2026 3A Systems, LLC.
15+
*/
16+
package org.forgerock.openidm.quartz.impl;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
20+
21+
import java.io.File;
22+
import java.io.InvalidClassException;
23+
import java.io.Serializable;
24+
import java.util.Calendar;
25+
import java.util.Date;
26+
import java.util.GregorianCalendar;
27+
import java.util.TimeZone;
28+
29+
import org.quartz.JobDataMap;
30+
import org.quartz.JobDetail;
31+
import org.quartz.JobPersistenceException;
32+
import org.quartz.SimpleTrigger;
33+
import org.quartz.impl.calendar.AnnualCalendar;
34+
import org.testng.annotations.Test;
35+
36+
/**
37+
* Tests the serialize/deserialize round trip in {@link RepoJobStoreUtils},
38+
* ensuring the deserialization filter (GHSA-xjw4-4w2v-rp6g, CWE-502) accepts
39+
* everything the scheduler legitimately persists — including the OpenIDM job
40+
* implementation class carried by {@link JobDetail} — while rejecting foreign
41+
* classes and oversized payloads.
42+
*/
43+
public class RepoJobStoreUtilsTest {
44+
45+
@Test
46+
public void shouldRoundTripJobDetailWithSchedulerServiceJobClass() throws Exception {
47+
JobDetail job = new JobDetail("job", "group", SchedulerServiceJob.class);
48+
JobDataMap data = job.getJobDataMap();
49+
data.put("invokeService", "org.forgerock.openidm.script");
50+
data.put("count", 42L);
51+
data.put("enabled", Boolean.TRUE);
52+
data.put("createdAt", new Date(1234567890000L));
53+
54+
JobDetail restored = (JobDetail) RepoJobStoreUtils.deserialize(RepoJobStoreUtils.serialize(job));
55+
56+
assertThat(restored.getName()).isEqualTo("job");
57+
assertThat(restored.getGroup()).isEqualTo("group");
58+
assertThat(restored.getJobClass()).isEqualTo(SchedulerServiceJob.class);
59+
assertThat(restored.getJobDataMap().getString("invokeService")).isEqualTo("org.forgerock.openidm.script");
60+
assertThat(restored.getJobDataMap().getLong("count")).isEqualTo(42L);
61+
assertThat(restored.getJobDataMap().getBoolean("enabled")).isTrue();
62+
assertThat(restored.getJobDataMap().get("createdAt")).isEqualTo(new Date(1234567890000L));
63+
}
64+
65+
@Test
66+
public void shouldRoundTripJobDetailWithStatefulSchedulerServiceJobClass() throws Exception {
67+
JobDetail job = new JobDetail("job", "group", StatefulSchedulerServiceJob.class);
68+
69+
JobDetail restored = (JobDetail) RepoJobStoreUtils.deserialize(RepoJobStoreUtils.serialize(job));
70+
71+
assertThat(restored.getJobClass()).isEqualTo(StatefulSchedulerServiceJob.class);
72+
}
73+
74+
@Test
75+
public void shouldRoundTripSimpleTrigger() throws Exception {
76+
Date startTime = new Date(1234567890000L);
77+
Date endTime = new Date(1234567990000L);
78+
SimpleTrigger trigger = new SimpleTrigger("trigger", "group", startTime, endTime, 5, 1000L);
79+
80+
SimpleTrigger restored = (SimpleTrigger) RepoJobStoreUtils.deserialize(RepoJobStoreUtils.serialize(trigger));
81+
82+
assertThat(restored.getName()).isEqualTo("trigger");
83+
assertThat(restored.getGroup()).isEqualTo("group");
84+
assertThat(restored.getStartTime()).isEqualTo(startTime);
85+
assertThat(restored.getEndTime()).isEqualTo(endTime);
86+
assertThat(restored.getRepeatCount()).isEqualTo(5);
87+
assertThat(restored.getRepeatInterval()).isEqualTo(1000L);
88+
}
89+
90+
@Test
91+
public void shouldRoundTripAnnualCalendarWithTimeZone() throws Exception {
92+
AnnualCalendar calendar = new AnnualCalendar();
93+
calendar.setTimeZone(TimeZone.getTimeZone("America/New_York"));
94+
Calendar excluded = new GregorianCalendar(2026, Calendar.JANUARY, 1);
95+
calendar.setDayExcluded(excluded, true);
96+
97+
AnnualCalendar restored = (AnnualCalendar) RepoJobStoreUtils.deserialize(RepoJobStoreUtils.serialize(calendar));
98+
99+
assertThat(restored.getTimeZone().getID()).isEqualTo("America/New_York");
100+
assertThat(restored.isDayExcluded(excluded)).isTrue();
101+
}
102+
103+
@Test
104+
public void shouldRejectForeignSerializable() throws Exception {
105+
String serialized = RepoJobStoreUtils.serialize(new File("foreign"));
106+
107+
assertThatThrownBy(() -> RepoJobStoreUtils.deserialize(serialized))
108+
.isInstanceOf(JobPersistenceException.class)
109+
.hasCauseInstanceOf(InvalidClassException.class);
110+
}
111+
112+
@Test
113+
public void shouldRejectSerializedLambda() throws Exception {
114+
Runnable lambda = (Runnable & Serializable) () -> { };
115+
String serialized = RepoJobStoreUtils.serialize((Serializable) lambda);
116+
117+
assertThatThrownBy(() -> RepoJobStoreUtils.deserialize(serialized))
118+
.isInstanceOf(JobPersistenceException.class)
119+
.hasCauseInstanceOf(InvalidClassException.class);
120+
}
121+
122+
@Test
123+
public void shouldRejectQuartzNativeJob() throws Exception {
124+
JobDetail job = new JobDetail("job", "group", org.quartz.jobs.NativeJob.class);
125+
String serialized = RepoJobStoreUtils.serialize(job);
126+
127+
assertThatThrownBy(() -> RepoJobStoreUtils.deserialize(serialized))
128+
.isInstanceOf(JobPersistenceException.class)
129+
.hasCauseInstanceOf(InvalidClassException.class);
130+
}
131+
132+
@Test
133+
public void shouldRejectOversizedArray() throws Exception {
134+
String serialized = RepoJobStoreUtils.serialize(new byte[2_000_000]);
135+
136+
assertThatThrownBy(() -> RepoJobStoreUtils.deserialize(serialized))
137+
.isInstanceOf(JobPersistenceException.class)
138+
.hasCauseInstanceOf(InvalidClassException.class);
139+
}
140+
141+
}

0 commit comments

Comments
 (0)