Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/java/org/apache/commons/lang3/Range.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

/**
Expand Down
101 changes: 101 additions & 0 deletions src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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");
Expand Down
Loading