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 81804c19c EventListenerSupport.readObject(ObjectInputStream) should 
validate constructor invariants (#1695).
81804c19c is described below

commit 81804c19c0f67d2cd1abef3220da0a741872679a
Author: Gary Gregory <[email protected]>
AuthorDate: Mon Jun 8 17:00:22 2026 -0400

    EventListenerSupport.readObject(ObjectInputStream) should validate 
constructor invariants (#1695).
---
 .../commons/lang3/event/EventListenerSupport.java  |   8 +-
 .../event/EventListenerSupportReadObjectTest.java  | 238 +++++++++++++++++++++
 2 files changed, 243 insertions(+), 3 deletions(-)

diff --git 
a/src/main/java/org/apache/commons/lang3/event/EventListenerSupport.java 
b/src/main/java/org/apache/commons/lang3/event/EventListenerSupport.java
index 8eee97144..f3639b25c 100644
--- a/src/main/java/org/apache/commons/lang3/event/EventListenerSupport.java
+++ b/src/main/java/org/apache/commons/lang3/event/EventListenerSupport.java
@@ -32,6 +32,7 @@
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.SerializationUtils;
 import org.apache.commons.lang3.Validate;
 import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.commons.lang3.function.FailableConsumer;
@@ -313,13 +314,14 @@ private void initializeTransientFields(final Class<L> 
listenerInterface, final C
     /**
      * Deserializes the next object into this instance.
      *
-     * @param objectInputStream the input stream
-     * @throws IOException if an IO error occurs
-     * @throws ClassNotFoundException if the class cannot be resolved
+     * @param objectInputStream the input stream.
+     * @throws IOException if an IO error occurs.
+     * @throws ClassNotFoundException if the class cannot be resolved.
      */
     private void readObject(final ObjectInputStream objectInputStream) throws 
IOException, ClassNotFoundException {
         @SuppressWarnings("unchecked") // Will throw CCE here if not correct
         final L[] srcListeners = (L[]) objectInputStream.readObject();
+        SerializationUtils.requireNonNull(srcListeners, "srcListeners"); // 
fail-fast with a better message
         this.listeners = new CopyOnWriteArrayList<>(srcListeners);
         final Class<L> listenerInterface = 
ArrayUtils.getComponentType(srcListeners);
         initializeTransientFields(listenerInterface, 
Thread.currentThread().getContextClassLoader());
diff --git 
a/src/test/java/org/apache/commons/lang3/event/EventListenerSupportReadObjectTest.java
 
b/src/test/java/org/apache/commons/lang3/event/EventListenerSupportReadObjectTest.java
new file mode 100644
index 000000000..c071ac770
--- /dev/null
+++ 
b/src/test/java/org/apache/commons/lang3/event/EventListenerSupportReadObjectTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.event;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyVetoException;
+import java.beans.VetoableChangeListener;
+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.ObjectStreamClass;
+import java.io.Serializable;
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link EventListenerSupport#readObject(ObjectInputStream)}.
+ *
+ * <p>
+ * {@code EventListenerSupport} uses a completely custom wire format: {@link 
EventListenerSupport#writeObject(ObjectOutputStream)} serializes the listeners 
as a
+ * single {@code L[]} object, and {@link 
EventListenerSupport#readObject(ObjectInputStream)} reads it back. The tests 
below cover:
+ * </p>
+ * <ul>
+ * <li>Happy-path round-trip with no listeners.</li>
+ * <li>Happy-path round-trip with serializable listeners – listeners 
survive.</li>
+ * <li>Non-serializable listeners are silently dropped during serialization, 
so {@code readObject} sees a shorter array.</li>
+ * <li>After deserialization the transient {@code proxy} field is rebuilt and 
{@code fire()} dispatches events.</li>
+ * <li>A forged stream that supplies {@code null} instead of the listener 
array is rejected by
+ * {@link org.apache.commons.lang3.SerializationUtils#requireNonNull} with 
{@link InvalidObjectException}.</li>
+ * </ul>
+ */
+class EventListenerSupportReadObjectTest extends AbstractLangTest {
+
+    /**
+     * A simple {@link Serializable} {@link VetoableChangeListener} that 
counts how many times it has been called.
+     */
+    private static final class CountingListener implements 
VetoableChangeListener, Serializable {
+
+        private static final long serialVersionUID = 1L;
+        final AtomicInteger callCount = new AtomicInteger();
+
+        @Override
+        public void vetoableChange(final PropertyChangeEvent evt) throws 
PropertyVetoException {
+            callCount.incrementAndGet();
+        }
+    }
+
+    /**
+     * A forge helper whose {@code writeObject} emits {@code null} instead of 
a listener array. The {@code serialVersionUID} matches
+     * {@link EventListenerSupport#serialVersionUID} and the class descriptor 
is replaced in the stream by a custom {@link ObjectOutputStream} so that
+     * {@link ObjectInputStream} interprets the bytes as an {@link 
EventListenerSupport}.
+     */
+    private static final class EventListenerSupportForge implements 
Serializable {
+
+        /** Must match {@link EventListenerSupport}'s {@code 
serialVersionUID}. */
+        private static final long serialVersionUID = 3593265990380473632L;
+
+        /**
+         * Writes {@code null} in place of the listener array, simulating a 
malicious or corrupt stream.
+         */
+        private void writeObject(final ObjectOutputStream oos) throws 
IOException {
+            oos.writeObject(null);
+        }
+    }
+
+    /**
+     * A {@link VetoableChangeListener} that is intentionally <em>not</em> 
{@link Serializable}.
+     */
+    private static final class NonSerializableListener implements 
VetoableChangeListener {
+
+        @Override
+        public void vetoableChange(final PropertyChangeEvent evt) throws 
PropertyVetoException {
+            // no-op
+        }
+    }
+
+    /**
+     * Deserializes the first object from {@code bytes}.
+     */
+    @SuppressWarnings("unchecked")
+    private static <T> T deserialize(final byte[] bytes) throws IOException, 
ClassNotFoundException {
+        try (ObjectInputStream ois = new ObjectInputStream(new 
ByteArrayInputStream(bytes))) {
+            return (T) ois.readObject();
+        }
+    }
+
+    /**
+     * Serializes a {@link EventListenerSupportForge} but rewrites its class 
descriptor so that the resulting stream is treated as an
+     * {@link EventListenerSupport} during deserialization.
+     */
+    private static byte[] forgeNullListenerArrayStream() throws IOException {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(baos) {
+
+            @Override
+            protected void writeClassDescriptor(final ObjectStreamClass desc) 
throws IOException {
+                if 
(desc.getName().equals(EventListenerSupportForge.class.getName())) {
+                    // Replace the forge descriptor with the real 
EventListenerSupport descriptor.
+                    
super.writeClassDescriptor(ObjectStreamClass.lookup(EventListenerSupport.class));
+                } else {
+                    super.writeClassDescriptor(desc);
+                }
+            }
+        }) {
+            oos.writeObject(new EventListenerSupportForge());
+        }
+        return baos.toByteArray();
+    }
+
+    /**
+     * Serializes {@code support} to a byte array using normal Java 
serialization.
+     */
+    private static byte[] serialize(final EventListenerSupport<?> support) 
throws IOException {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+            oos.writeObject(support);
+        }
+        return baos.toByteArray();
+    }
+
+    /**
+     * Non-{@link Serializable} listeners are silently dropped by {@link 
EventListenerSupport#writeObject(ObjectOutputStream)}; after deserialization 
only the
+     * serializable subset is available.
+     */
+    @Test
+    void testReadObjectDropsNonSerializableListeners() throws IOException, 
ClassNotFoundException {
+        final EventListenerSupport<VetoableChangeListener> original = 
EventListenerSupport.create(VetoableChangeListener.class);
+        final CountingListener serializable = new CountingListener();
+        original.addListener(serializable);
+        original.addListener(new NonSerializableListener()); // must be 
silently dropped
+        final EventListenerSupport<VetoableChangeListener> restored = 
deserialize(serialize(original));
+        assertEquals(1, restored.getListenerCount(), "Only the serializable 
listener must survive; the non-serializable one must be dropped");
+        assertEquals(CountingListener.class, 
restored.getListeners()[0].getClass());
+    }
+
+    /**
+     * An empty {@link EventListenerSupport} (no registered listeners) 
survives a serialization round-trip; after deserialization the support object is
+     * non-null, reports zero listeners, and {@code fire()} returns a 
functional proxy.
+     */
+    @Test
+    void testReadObjectEmptyListeners() throws IOException, 
ClassNotFoundException, PropertyVetoException {
+        final EventListenerSupport<VetoableChangeListener> original = 
EventListenerSupport.create(VetoableChangeListener.class);
+        final EventListenerSupport<VetoableChangeListener> restored = 
deserialize(serialize(original));
+        assertNotNull(restored);
+        assertEquals(0, restored.getListenerCount());
+        // The transient proxy must have been rebuilt; calling fire() on an 
empty support must not throw.
+        assertNotNull(restored.fire());
+        restored.fire().vetoableChange(new PropertyChangeEvent(new Date(), 
"Day", 0, 1));
+    }
+
+    /**
+     * After deserialization the transient {@code proxy} returned by {@code 
fire()} dispatches events to all restored listeners.
+     */
+    @Test
+    void testReadObjectFireDispatchesAfterDeserializing() throws IOException, 
ClassNotFoundException, PropertyVetoException {
+        final EventListenerSupport<VetoableChangeListener> original = 
EventListenerSupport.create(VetoableChangeListener.class);
+        original.addListener(new CountingListener());
+        original.addListener(new CountingListener());
+        final EventListenerSupport<VetoableChangeListener> restored = 
deserialize(serialize(original));
+        final PropertyChangeEvent evt = new PropertyChangeEvent(new Date(), 
"Prop", "old", "new");
+        restored.fire().vetoableChange(evt);
+        for (final VetoableChangeListener l : restored.getListeners()) {
+            assertEquals(1, ((CountingListener) l).callCount.get());
+        }
+    }
+
+    /**
+     * A forged stream that provides {@code null} as the listener array is 
rejected by
+     * {@link 
org.apache.commons.lang3.SerializationUtils#requireNonNull(Object, String)} 
inside {@code readObject}, which throws
+     * {@link InvalidObjectException}.
+     */
+    @Test
+    void testReadObjectNullListenerArrayRejected() throws IOException {
+        final byte[] forgedBytes = forgeNullListenerArrayStream();
+        assertThrows(InvalidObjectException.class, () -> 
deserialize(forgedBytes));
+    }
+
+    /**
+     * {@code readObject} restores the full list of listeners that were {@link 
Serializable} at serialization time.
+     */
+    @Test
+    void testReadObjectPreservesSerializableListeners() throws IOException, 
ClassNotFoundException, PropertyVetoException {
+        final EventListenerSupport<VetoableChangeListener> original = 
EventListenerSupport.create(VetoableChangeListener.class);
+        final CountingListener listener1 = new CountingListener();
+        final CountingListener listener2 = new CountingListener();
+        original.addListener(listener1);
+        original.addListener(listener2);
+        final EventListenerSupport<VetoableChangeListener> restored = 
deserialize(serialize(original));
+        assertEquals(2, restored.getListenerCount());
+        // Exercise the restored proxy to confirm event dispatching works.
+        restored.fire().vetoableChange(new PropertyChangeEvent(new Date(), 
"Day", 0, 1));
+        // Each restored listener should have been called exactly once.
+        final VetoableChangeListener[] listeners = restored.getListeners();
+        assertEquals(VetoableChangeListener.class, 
listeners.getClass().getComponentType());
+        for (final VetoableChangeListener l : listeners) {
+            assertEquals(1, ((CountingListener) l).callCount.get());
+        }
+    }
+
+    /**
+     * A new listener added after deserialization receives subsequent events, 
confirming that {@code readObject} fully initialized the internal state.
+     */
+    @Test
+    void testReadObjectSupportsAddListenerAfterDeserializing() throws 
IOException, ClassNotFoundException, PropertyVetoException {
+        final EventListenerSupport<VetoableChangeListener> original = 
EventListenerSupport.create(VetoableChangeListener.class);
+        final EventListenerSupport<VetoableChangeListener> restored = 
deserialize(serialize(original));
+        final CountingListener newListener = new CountingListener();
+        restored.addListener(newListener);
+        assertEquals(1, restored.getListenerCount());
+        restored.fire().vetoableChange(new PropertyChangeEvent(new Date(), 
"Prop", "old", "new"));
+        assertEquals(1, newListener.callCount.get());
+    }
+}

Reply via email to