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 166ceb752 Harden Range.readObject() to reject bad cached hash code
(#1633)
166ceb752 is described below
commit 166ceb75286894fab753e29fdce3af4d33d1439a
Author: Gary Gregory <[email protected]>
AuthorDate: Tue May 5 13:08:59 2026 -0400
Harden Range.readObject() to reject bad cached hash code (#1633)
* A serialized Range can't store a bad cached hashCode.
---
src/main/java/org/apache/commons/lang3/Range.java | 25 +++++++++-
.../apache/commons/lang3/RangeReadObjectTest.java | 54 ++++++++++++++++++++++
.../commons/lang3/SerializationUtilsTest.java | 23 +++++++++
3 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/src/main/java/org/apache/commons/lang3/Range.java
b/src/main/java/org/apache/commons/lang3/Range.java
index 14ee8a21b..5200b4ee5 100644
--- a/src/main/java/org/apache/commons/lang3/Range.java
+++ b/src/main/java/org/apache/commons/lang3/Range.java
@@ -16,6 +16,9 @@
*/
package org.apache.commons.lang3;
+import java.io.IOException;
+import java.io.InvalidObjectException;
+import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Comparator;
import java.util.Objects;
@@ -106,6 +109,10 @@ public static <T> Range<T> between(final T fromInclusive,
final T toInclusive, f
return new Range<>(fromInclusive, toInclusive, comparator);
}
+ private static int hash(final Object value1, final Object value2) {
+ return Objects.hash(value1, value2);
+ }
+
/**
* Creates a range using the specified element as both the minimum
* and maximum in this range.
@@ -235,7 +242,7 @@ public static <T> Range<T> of(final T fromInclusive, final
T toInclusive, final
this.minimum = element2;
this.maximum = element1;
}
- this.hashCode = Objects.hash(minimum, maximum);
+ this.hashCode = hash(minimum, maximum);
}
/**
@@ -527,6 +534,22 @@ public boolean isStartedBy(final T element) {
return comparator.compare(element, minimum) == 0;
}
+ /**
+ * See {@link Serializable}.
+ *
+ * @param in See {@link Serializable}.
+ * @throws IOException See {@link Serializable}.
+ * @throws ClassNotFoundException See {@link Serializable}.
+ */
+ private void readObject(final ObjectInputStream in) throws IOException,
ClassNotFoundException {
+ in.defaultReadObject();
+ // Reject streams whose cached hashCode does not match the canonical
hash of the deserialized minimum/maximum: a crafted stream cannot supply a
forged
+ // value.
+ if (hashCode != hash(minimum, maximum)) {
+ throw new InvalidObjectException("Range hashCode does not match
minimum/maximum.");
+ }
+ }
+
/**
* Gets the range as a {@link String}.
*
diff --git a/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
new file mode 100644
index 000000000..9976e94a1
--- /dev/null
+++ b/src/test/java/org/apache/commons/lang3/RangeReadObjectTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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
+ *
+ * https://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.commons.lang3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.InvalidObjectException;
+
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that a serialized Range can't store a bad cached hashCode.
+ */
+class RangeReadObjectTest {
+
+ @Test
+ void testBadHashCodeRejected() throws Exception {
+ final Range<Integer> range = Range.of(1, 100);
+ final byte[] bytes = SerializationUtils.serialize(range);
+ // Locate the legitimate hashCode int in the serialized stream and
overwrite it.
+ final int hashCode = (Integer) FieldUtils.readDeclaredField(range,
"hashCode", true);
+ final byte[] edited = SerializationUtilsTest.replaceLastInt(bytes,
hashCode, 0xDEADBEEF);
+ final SerializationException ex =
assertThrows(SerializationException.class, () ->
SerializationUtils.deserialize(edited),
+ "Bad hashCode in stream must be rejected with
InvalidObjectException");
+ assertInstanceOf(InvalidObjectException.class, ex.getCause());
+ assertEquals("java.io.InvalidObjectException: Range hashCode does not
match minimum/maximum.", ex.getMessage());
+ }
+
+ @Test
+ void testRoundTripPreservesCorrectHashCode() throws Exception {
+ final Range<String> range = Range.of("apple", "mango");
+ final Range<String> roundtrip = SerializationUtils.roundtrip(range);
+ assertEquals(range.hashCode(), roundtrip.hashCode(), "Round-trip
serialization must preserve the correct hashCode");
+ assertEquals(range, roundtrip);
+ }
+}
diff --git a/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
b/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
index 93e8a277d..6fe4d24ce 100644
--- a/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/SerializationUtilsTest.java
@@ -27,6 +27,7 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -65,8 +66,29 @@ class SerializationUtilsTest extends AbstractLangTest {
static final String CLASS_NOT_FOUND_MESSAGE =
"ClassNotFoundSerialization.readObject fake exception";
protected static final String SERIALIZE_IO_EXCEPTION_MESSAGE = "Anonymous
OutputStream I/O exception";
+ static byte[] intToBytes(final int v) {
+ return new byte[] { (byte) (v >>> 24), (byte) (v >>> 16), (byte) (v
>>> 8), (byte) v };
+ }
+ static byte[] replaceLastInt(final byte[] src, final int from, final int
to) {
+ final byte[] fromB = intToBytes(from);
+ final byte[] toB = intToBytes(to);
+ final byte[] out = src.clone();
+ for (int i = out.length - 4; i >= 0; i--) {
+ if (out[i] == fromB[0] && out[i + 1] == fromB[1] && out[i + 2] ==
fromB[2] && out[i + 3] == fromB[3]) {
+ out[i] = toB[0];
+ out[i + 1] = toB[1];
+ out[i + 2] = toB[2];
+ out[i + 3] = toB[3];
+ return out;
+ }
+ }
+ fail("No legitimate int in stream, serialization must keep hashCode in
default field set");
+ return null;
+ }
private String iString;
+
private Integer iInteger;
+
private HashMap<Object, Object> iMap;
@BeforeEach
@@ -112,6 +134,7 @@ void testCloneUnserializable() {
assertThrows(SerializationException.class, () ->
SerializationUtils.clone(iMap));
}
+ @SuppressWarnings("deprecation")
@Test
void testConstructor() {
assertNotNull(new SerializationUtils());