This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 28c2fcbb7 Range.readObject() does not null-check comparator, minimum,
or maximum. (#1675)
28c2fcbb7 is described below
commit 28c2fcbb73e2247ec1a3846e4e55f0ed4986b9c9
Author: Gary Gregory <[email protected]>
AuthorDate: Sun May 24 18:20:04 2026 -0400
Range.readObject() does not null-check comparator, minimum, or maximum.
(#1675)
---
src/main/java/org/apache/commons/lang3/Range.java | 9 ++
.../apache/commons/lang3/RangeReadObjectTest.java | 101 +++++++++++++++++++++
2 files changed, 110 insertions(+)
diff --git a/src/main/java/org/apache/commons/lang3/Range.java
b/src/main/java/org/apache/commons/lang3/Range.java
index 2c1743956..e0fca54d1 100644
--- a/src/main/java/org/apache/commons/lang3/Range.java
+++ b/src/main/java/org/apache/commons/lang3/Range.java
@@ -550,6 +550,15 @@ private void readObject(final ObjectInputStream in) throws
IOException, ClassNot
if (hashCode != hash(minimum, maximum)) {
throw new InvalidObjectException("Range hashCode does not match
minimum/maximum.");
}
+ if (maximum == null) {
+ throw new InvalidObjectException("maximum null");
+ }
+ if (minimum == null) {
+ throw new InvalidObjectException("minimum null");
+ }
+ if (comparator == null) {
+ throw new InvalidObjectException("comparator null");
+ }
}
/**
diff --git a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
index 866270b59..be2866ff2 100644
--- a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
+++ b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
@@ -21,7 +21,14 @@
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.io.InvalidObjectException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Objects;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.junit.jupiter.api.Test;
@@ -31,6 +38,59 @@
*/
class RangeReadObjectTest {
+ /**
+ * Standin class used only to drive {@link
ObjectOutputStream#writeObject(Object)} into emitting a stream that matches the
wire format of {@link Range} but
+ * with caller-controlled field values. The class name and
serialVersionUID are spoofed in the stream below via a custom {@link
ObjectOutputStream} subclass
+ * so the stream reads back as a {@code Range}.
+ */
+ private static final class RangeForge implements Serializable {
+
+ private static final long serialVersionUID = 2L; // matches
Range.serialVersionUID
+ private final Object comparator;
+ private final int hashCode;
+ private final Object maximum;
+ private final Object minimum;
+
+ RangeForge(final Object comparator, final Object minimum, final Object
maximum, final int hashCode) {
+ this.comparator = comparator;
+ this.minimum = minimum;
+ this.maximum = maximum;
+ this.hashCode = hashCode;
+ }
+ }
+
+ private static Object deserialize(final byte[] bytes) throws IOException,
ClassNotFoundException {
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(bytes))) {
+ return ois.readObject();
+ }
+ }
+
+ /**
+ * Serializes a {@link RangeForge} but rewrites the class descriptor name
to "org.apache.commons.lang3.Range" so the resulting bytes deserialize as a
+ * {@link Range}. Because the field set, types, order, and
serialVersionUID all match, default deserialization assigns each forged value
to the
+ * corresponding Range field via reflection (bypassing the constructor).
+ */
+ private static byte[] forgeRangeStream(final Object comparator, final
Object minimum, final Object maximum, final int hashCode) throws IOException {
+ // Build the legitimate-shape bytes via RangeForge, then rewrite the
embedded class name.
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos) {
+
+ @Override
+ protected void writeClassDescriptor(final
java.io.ObjectStreamClass desc) throws IOException {
+ if (desc.getName().equals(RangeForge.class.getName())) {
+ // Emit a descriptor whose name is Range but whose field
layout still matches RangeForge.
+ final java.io.ObjectStreamClass spoofed =
java.io.ObjectStreamClass.lookup(Range.class);
+ super.writeClassDescriptor(spoofed);
+ } else {
+ super.writeClassDescriptor(desc);
+ }
+ }
+ }) {
+ oos.writeObject(new RangeForge(comparator, minimum, maximum,
hashCode));
+ }
+ return baos.toByteArray();
+ }
+
@Test
void testBadHashCodeRejected() throws Exception {
final Range<Integer> range = Range.of(1, 100);
@@ -44,6 +104,47 @@ void testBadHashCodeRejected() throws Exception {
assertEquals("java.io.InvalidObjectException: Range hashCode does not
match minimum/maximum.", ex.getMessage());
}
+ /**
+ * Forged stream with {@code comparator == null}; F-004 hashCode check
passes because we set hashCode canonically; {@code contains()} then NPEs on
+ * {@code comparator.compare(...)}.
+ */
+ @Test
+ void testComparatorNullViaForgedStream() throws Exception {
+ final Integer min = Integer.valueOf(1);
+ final Integer max = Integer.valueOf(10);
+ final int canonicalHash = Objects.hash(min, max);
+ final byte[] forged = forgeRangeStream(null, min, max, canonicalHash);
+ assertThrows(InvalidObjectException.class, () -> deserialize(forged));
+ }
+
+ /**
+ * Forged stream with {@code maximum == null}; symmetric to F-061b.
+ */
+ @Test
+ void testMaximumNullViaForgedStream() throws Exception {
+ final Integer min = Integer.valueOf(1);
+ final int canonicalHash = Objects.hash(min, (Object) null);
+ final Object comparator = Range.of(Integer.valueOf(1),
Integer.valueOf(2)).getComparator();
+ final byte[] forged = forgeRangeStream(comparator, min, null,
canonicalHash);
+ assertThrows(InvalidObjectException.class, () -> deserialize(forged));
+ }
+
+ /**
+ * Forged stream with {@code minimum == null}; {@code Objects.hash(null,
max)} is a valid int, so the F-004 check passes. {@code contains()} NPEs
+ * because {@code comparator.compare(element, null)} unboxes null (or, for
ComparableComparator, calls {@code element.compareTo(null)} which is an
+ * NPE-by-contract).
+ */
+ @Test
+ void testMinimumNullViaForgedStream() throws Exception {
+ final Integer max = Integer.valueOf(10);
+ final int canonicalHash = Objects.hash((Object) null, max);
+ // comparator must be non-null here so we isolate the minimum-null gap.
+ // We use ComparableComparator.INSTANCE via deserialization round-trip
of a real Range.
+ final Object comparator = Range.of(Integer.valueOf(1),
Integer.valueOf(2)).getComparator();
+ final byte[] forged = forgeRangeStream(comparator, null, max,
canonicalHash);
+ assertThrows(InvalidObjectException.class, () -> deserialize(forged));
+ }
+
@Test
void testRoundTripPreservesCorrectHashCode() throws Exception {
final Range<String> range = Range.of("apple", "mango");