GEODE-10676: Refactor locator request read path (#8111)
diff --git a/geode-core/src/integrationTest/java/org/apache/geode/distributed/internal/tcpserver/TcpServerRequestFormatIntegrationTest.java b/geode-core/src/integrationTest/java/org/apache/geode/distributed/internal/tcpserver/TcpServerRequestFormatIntegrationTest.java
new file mode 100644
index 0000000..32dbeac
--- /dev/null
+++ b/geode-core/src/integrationTest/java/org/apache/geode/distributed/internal/tcpserver/TcpServerRequestFormatIntegrationTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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
+ *
+ * http://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.geode.distributed.internal.tcpserver;
+
+import static org.apache.geode.test.awaitility.GeodeAwaitility.await;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import org.apache.geode.distributed.internal.LocatorRequestDeserializer;
+import org.apache.geode.internal.HeapDataOutputStream;
+import org.apache.geode.internal.InternalDataSerializer;
+import org.apache.geode.internal.serialization.DSFIDSerializer;
+import org.apache.geode.internal.serialization.KnownVersion;
+import org.apache.geode.logging.internal.executors.LoggingExecutors;
+import org.apache.geode.management.internal.configuration.messages.ClusterManagementServiceInfoRequest;
+import org.apache.geode.test.junit.categories.MembershipTest;
+
+/**
+ * Exercises the request-reading path of a locator's {@link TcpServer}, confirming that the type
+ * read
+ * off the request stream is constrained to the locator's request types rather than any type the
+ * stream names, while the real request types are still read.
+ */
+@Category({MembershipTest.class})
+public class TcpServerRequestFormatIntegrationTest {
+
+  private static final int TIMEOUT = 60 * 1000;
+
+  private InetAddress localhost;
+  private TcpServer server;
+  private int port;
+  private DSFIDSerializer serializer;
+  private final AtomicReference<Object> handledRequest = new AtomicReference<>();
+
+  @Before
+  public void setUp() throws IOException {
+    ProbeObject.reconstructed = false;
+    localhost = InetAddress.getLoopbackAddress();
+
+    // Clients encode requests with the general serializer, exactly as a real client does.
+    serializer = InternalDataSerializer.getDSFIDSerializer();
+
+    TcpHandler handler = new TcpHandler() {
+      @Override
+      public Object processRequest(Object request) {
+        handledRequest.set(request);
+        return null;
+      }
+
+      @Override
+      public void endRequest(Object request, long startTime) {}
+
+      @Override
+      public void endResponse(Object request, long startTime) {}
+
+      @Override
+      public void shutDown() {}
+
+      @Override
+      public void init(TcpServer tcpServer) {}
+    };
+
+    // The server reads requests the way a locator does: through the locator request deserializer.
+    server = new TcpServer(0, localhost, handler,
+        "test locator thread",
+        (socket, input, firstByte) -> false,
+        () -> 0,
+        () -> LoggingExecutors.newCachedThreadPool("test thread", true),
+        null,
+        serializer.getObjectSerializer(),
+        new LocatorRequestDeserializer(),
+        "blahblahblah",
+        "blahblahblah");
+    server.start();
+    port = server.getPort();
+    assertThat(port).isGreaterThan(0);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (server != null) {
+      TcpClient tcpClient = new TcpClient(new TcpSocketCreatorImpl(),
+          serializer.getObjectSerializer(),
+          serializer.getObjectDeserializer(), TcpSocketFactory.DEFAULT);
+      try {
+        tcpClient.stop(new HostAndPort(localhost.getHostAddress(), port));
+      } catch (Exception ignore) {
+        // best-effort stop
+      }
+      server.join(TIMEOUT);
+    }
+  }
+
+  /**
+   * A request naming a type the locator does not expect is rejected before that type is
+   * reconstructed. {@link ProbeObject} is not a locator request type, so its {@code readObject} is
+   * never run.
+   */
+  @Test
+  public void doesNotReconstructUnexpectedRequestType() throws Exception {
+    sendRequest(new ProbeObject());
+
+    assertThat(ProbeObject.reconstructed).isFalse();
+    assertThat(handledRequest.get()).isNull();
+  }
+
+  /**
+   * A request carried with an explicit class name that the locator does expect is still read. This
+   * covers the data-serialized form.
+   */
+  @Test
+  public void readsExpectedDataSerializableRequest() throws Exception {
+    @SuppressWarnings("deprecation")
+    InfoRequest request = new InfoRequest();
+    sendRequest(request);
+
+    await().untilAsserted(() -> assertThat(handledRequest.get()).isInstanceOf(InfoRequest.class));
+  }
+
+  /**
+   * A request carried in Java-serialized form whose class the locator expects is still read.
+   */
+  @Test
+  public void readsExpectedSerializableRequest() throws Exception {
+    sendRequest(new ClusterManagementServiceInfoRequest());
+
+    await().untilAsserted(
+        () -> assertThat(handledRequest.get())
+            .isInstanceOf(ClusterManagementServiceInfoRequest.class));
+  }
+
+  private void sendRequest(Object request) throws Exception {
+    byte[] encoded = encodeRequest(request);
+    try (Socket socket = new Socket(localhost, port)) {
+      socket.setSoTimeout(TIMEOUT);
+      DataOutputStream out = new DataOutputStream(socket.getOutputStream());
+      out.write(encoded);
+      out.flush();
+      // Drain whatever the server sends (or until it closes) so the request is fully processed.
+      DataInputStream in = new DataInputStream(socket.getInputStream());
+      try {
+        while (in.read() != -1) {
+          // consume
+        }
+      } catch (IOException ignore) {
+        // server closed the socket
+      }
+    }
+  }
+
+  /**
+   * Frames a request the way {@code TcpClient} does: the gossip version int (whose leading byte is
+   * the gossip marker), the serialization version ordinal, then the serialized request object.
+   */
+  private byte[] encodeRequest(Object request) throws IOException {
+    HeapDataOutputStream body = new HeapDataOutputStream(KnownVersion.CURRENT);
+    serializer.getObjectSerializer().writeObject(request, body);
+
+    HeapDataOutputStream out = new HeapDataOutputStream(KnownVersion.CURRENT);
+    out.writeInt(TcpServer.GOSSIPVERSION);
+    out.writeShort(KnownVersion.CURRENT_ORDINAL);
+    out.write(body.toByteArray());
+    return out.toByteArray();
+  }
+
+  /**
+   * Test-only type that records whether it was reconstructed. Not a locator request type.
+   */
+  public static class ProbeObject implements Serializable {
+    static boolean reconstructed;
+
+    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+      in.defaultReadObject();
+      reconstructed = true;
+    }
+  }
+}
diff --git a/geode-core/src/main/java/org/apache/geode/distributed/internal/InternalLocator.java b/geode-core/src/main/java/org/apache/geode/distributed/internal/InternalLocator.java
index 228617c..6373fef 100644
--- a/geode-core/src/main/java/org/apache/geode/distributed/internal/InternalLocator.java
+++ b/geode-core/src/main/java/org/apache/geode/distributed/internal/InternalLocator.java
@@ -582,6 +582,7 @@
               .setFallbackHandler(handler)
               .setLocatorsAreCoordinators(shouldLocatorsBeCoordinators())
               .setLocatorStats(locatorStats)
+              .setRequestObjectDeserializer(new LocatorRequestDeserializer())
               .create();
     } catch (MembershipConfigurationException | UnknownHostException e) {
       throw new GemFireConfigException(e.getMessage());
diff --git a/geode-core/src/main/java/org/apache/geode/distributed/internal/LocatorRequestDeserializer.java b/geode-core/src/main/java/org/apache/geode/distributed/internal/LocatorRequestDeserializer.java
new file mode 100644
index 0000000..7e4c3f7
--- /dev/null
+++ b/geode-core/src/main/java/org/apache/geode/distributed/internal/LocatorRequestDeserializer.java
@@ -0,0 +1,66 @@
+/*
+ * 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
+ *
+ * http://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.geode.distributed.internal;
+
+import java.io.DataInput;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.geode.annotations.Immutable;
+import org.apache.geode.distributed.internal.tcpserver.InfoRequest;
+import org.apache.geode.distributed.internal.tcpserver.ShutdownRequest;
+import org.apache.geode.distributed.internal.tcpserver.VersionRequest;
+import org.apache.geode.internal.InternalDataSerializer;
+import org.apache.geode.internal.serialization.ObjectDeserializer;
+import org.apache.geode.management.internal.configuration.messages.ClusterManagementServiceInfoRequest;
+import org.apache.geode.management.internal.configuration.messages.SharedConfigurationStatusRequest;
+
+/**
+ * Reads the request a locator receives on its server port. Requests carried as a
+ * {@link org.apache.geode.internal.serialization.DataSerializableFixedID} name a registered fixed
+ * id and are read normally. The remaining request types name their class on the wire; those are
+ * read only for the classes listed here, which is the closed set of locator requests not carried as
+ * a fixed id. A request naming any other class is rejected before that class is loaded.
+ *
+ * <p>
+ * When a new locator request type that is not a {@code DataSerializableFixedID} is added, its class
+ * name must be added to {@link #EXPECTED_REQUEST_CLASS_NAMES}.
+ */
+public class LocatorRequestDeserializer implements ObjectDeserializer {
+
+  @Immutable
+  private static final Set<String> EXPECTED_REQUEST_CLASS_NAMES =
+      Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+          InfoRequest.class.getName(),
+          ShutdownRequest.class.getName(),
+          VersionRequest.class.getName(),
+          SharedConfigurationStatusRequest.class.getName(),
+          ClusterManagementServiceInfoRequest.class.getName())));
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public <T> T readObject(DataInput input) throws IOException, ClassNotFoundException {
+    return (T) InternalDataSerializer.readRequestObject(input,
+        EXPECTED_REQUEST_CLASS_NAMES::contains);
+  }
+
+  @Override
+  public void invokeFromData(Object ds, DataInput in) throws IOException, ClassNotFoundException {
+    InternalDataSerializer.invokeFromData(ds, in);
+  }
+}
diff --git a/geode-core/src/main/java/org/apache/geode/internal/InternalDataSerializer.java b/geode-core/src/main/java/org/apache/geode/internal/InternalDataSerializer.java
index d71ea97..69e51e4 100644
--- a/geode-core/src/main/java/org/apache/geode/internal/InternalDataSerializer.java
+++ b/geode-core/src/main/java/org/apache/geode/internal/InternalDataSerializer.java
@@ -23,6 +23,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.InvalidClassException;
 import java.io.NotSerializableException;
 import java.io.ObjectInput;
 import java.io.ObjectInputStream;
@@ -67,6 +68,7 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.Logger;
@@ -2371,6 +2373,141 @@
     }
   }
 
+  /**
+   * Reads an object from a stream that an unauthenticated peer may control, reconstructing only the
+   * types the caller expects. A {@link DataSerializableFixedID} (whose wire form names a registered
+   * fixed id rather than a class) is read normally. An object written with an explicit class name
+   * is
+   * reconstructed only when {@code classIsExpected} accepts that name; for data-serialized objects
+   * the name is checked before the class is loaded. Any other wire form, including a
+   * Java-serialized
+   * object of an unexpected type, is rejected with an {@link IOException}.
+   */
+  public static Object readRequestObject(final DataInput in,
+      final Predicate<String> classIsExpected) throws IOException, ClassNotFoundException {
+    checkIn(in);
+    byte header = in.readByte();
+    DSCODE headerDSCode = DscodeHelper.toDSCODE(header);
+    if (headerDSCode == null) {
+      throw new IOException("Unknown header byte: " + header);
+    }
+    switch (headerDSCode) {
+      case DS_FIXED_ID_BYTE:
+        return dsfidFactory.create(in.readByte(), in);
+      case DS_FIXED_ID_SHORT:
+        return dsfidFactory.create(in.readShort(), in);
+      case DS_FIXED_ID_INT:
+        return dsfidFactory.create(in.readInt(), in);
+      case DS_NO_FIXED_ID:
+      case DATA_SERIALIZABLE:
+        return readDataSerializable(in, classIsExpected);
+      case SERIALIZABLE:
+        return readSerializable(in, classIsExpected);
+      case NULL:
+      case NULL_STRING:
+        return null;
+      default:
+        throw new IOException("Unexpected request format: " + headerDSCode);
+    }
+  }
+
+  private static Object readDataSerializable(final DataInput in,
+      final Predicate<String> classIsExpected) throws IOException, ClassNotFoundException {
+    final Class<?> c = readExpectedClass(in, classIsExpected);
+    try {
+      Constructor<?> init = c.getConstructor();
+      init.setAccessible(true);
+      Object o = init.newInstance();
+
+      invokeFromData(o, in);
+
+      return o;
+    } catch (EOFException | SocketException ex) {
+      // client went away - ignore
+      throw ex;
+    } catch (Exception ex) {
+      throw new SerializationException(
+          String.format("Could not create an instance of %s .",
+              c.getName()),
+          ex);
+    }
+  }
+
+  /**
+   * Reads a class written by {@link DataSerializer#writeClass}, resolving it only when
+   * {@code classIsExpected} accepts its name. The name is checked before the class is loaded, so an
+   * unexpected name is rejected without running that class's static initializer. Primitive type
+   * codes carry no class name and are resolved directly.
+   */
+  private static Class<?> readExpectedClass(final DataInput in,
+      final Predicate<String> classIsExpected) throws IOException, ClassNotFoundException {
+    byte typeCode = in.readByte();
+    if (typeCode == DSCODE.CLASS.toByte()) {
+      String className = processIncomingClassName(readString(in));
+      if (!classIsExpected.test(className)) {
+        throw new IOException("Unexpected class name: " + className);
+      }
+      return getCachedClass(className);
+    }
+    return StaticSerialization.decodePrimitiveClass(typeCode);
+  }
+
+  private static Serializable readSerializable(final DataInput in,
+      final Predicate<String> classIsExpected) throws IOException, ClassNotFoundException {
+    InputStream stream;
+    if (in instanceof InputStream) {
+      stream = (InputStream) in;
+    } else {
+      stream = new InputStream() {
+        @Override
+        public int read() throws IOException {
+          try {
+            return in.readUnsignedByte();
+          } catch (EOFException ignored) {
+            return -1;
+          }
+        }
+      };
+    }
+
+    ObjectInput ois = new ExpectedClassObjectInputStream(stream, classIsExpected);
+
+    if (stream instanceof VersionedDataStream) {
+      KnownVersion v = ((VersionedDataStream) stream).getVersion();
+      if (KnownVersion.CURRENT != v && v != null) {
+        ois = new VersionedObjectInput(ois, v);
+      }
+    }
+
+    return (Serializable) ois.readObject();
+  }
+
+  /**
+   * A {@link DSObjectInputStream} that resolves only classes whose name is accepted by the supplied
+   * predicate. A class outside the accepted set is rejected in {@link #resolveClass}, before it is
+   * loaded, so neither its static initializer nor any instance of it runs.
+   */
+  private static class ExpectedClassObjectInputStream extends DSObjectInputStream {
+
+    private final Predicate<String> classIsExpected;
+
+    ExpectedClassObjectInputStream(InputStream stream, Predicate<String> classIsExpected)
+        throws IOException {
+      super(stream);
+      this.classIsExpected = classIsExpected;
+    }
+
+    @Override
+    protected Class<?> resolveClass(ObjectStreamClass desc)
+        throws IOException, ClassNotFoundException {
+      String className = processIncomingClassName(desc.getName());
+      if (!classIsExpected.test(className)) {
+        throw new InvalidClassException(className, "Unexpected class");
+      }
+      return super.resolveClass(desc);
+    }
+  }
+
   public static void writeArrayLength(int len, DataOutput out) throws IOException {
     if (len == -1) {
       out.writeByte(StaticSerialization.NULL_ARRAY);
diff --git a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/api/MembershipLocatorBuilder.java b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/api/MembershipLocatorBuilder.java
index 319427c..8945642 100644
--- a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/api/MembershipLocatorBuilder.java
+++ b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/api/MembershipLocatorBuilder.java
@@ -25,6 +25,7 @@
 import org.apache.geode.distributed.internal.tcpserver.TcpHandler;
 import org.apache.geode.distributed.internal.tcpserver.TcpSocketCreator;
 import org.apache.geode.internal.serialization.DSFIDSerializer;
+import org.apache.geode.internal.serialization.ObjectDeserializer;
 
 public interface MembershipLocatorBuilder<ID extends MemberIdentifier> {
   MembershipLocatorBuilder<ID> setPort(int port);
@@ -41,6 +42,14 @@
 
   MembershipLocatorBuilder<ID> setLocatorStats(MembershipLocatorStatistics locatorStats);
 
+  /**
+   * Sets the deserializer used to read requests received on the locator's server port. When unset,
+   * the serializer's default object deserializer is used. This does not affect the deserializer the
+   * locator uses for its own outgoing requests to other locators.
+   */
+  MembershipLocatorBuilder<ID> setRequestObjectDeserializer(
+      ObjectDeserializer requestObjectDeserializer);
+
   MembershipLocator<ID> create()
       throws UnknownHostException, MembershipConfigurationException;
 
diff --git a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/MembershipLocatorBuilderImpl.java b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/MembershipLocatorBuilderImpl.java
index 4483415..c62f85c 100644
--- a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/MembershipLocatorBuilderImpl.java
+++ b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/MembershipLocatorBuilderImpl.java
@@ -32,6 +32,7 @@
 import org.apache.geode.distributed.internal.tcpserver.TcpSocketCreator;
 import org.apache.geode.distributed.internal.tcpserver.TcpSocketCreatorImpl;
 import org.apache.geode.internal.serialization.DSFIDSerializer;
+import org.apache.geode.internal.serialization.ObjectDeserializer;
 
 public final class MembershipLocatorBuilderImpl<ID extends MemberIdentifier> implements
     MembershipLocatorBuilder<ID> {
@@ -46,6 +47,7 @@
   private final Path workingDirectory;
   private MembershipConfig config = new MembershipConfig() {};
   private final Supplier<ExecutorService> executorServiceSupplier;
+  private ObjectDeserializer requestObjectDeserializer;
 
   public MembershipLocatorBuilderImpl(
       final TcpSocketCreator socketCreator,
@@ -101,12 +103,22 @@
   }
 
   @Override
+  public MembershipLocatorBuilder<ID> setRequestObjectDeserializer(
+      ObjectDeserializer requestObjectDeserializer) {
+    this.requestObjectDeserializer = requestObjectDeserializer;
+    return this;
+  }
+
+  @Override
   public MembershipLocator<ID> create()
       throws UnknownHostException, MembershipConfigurationException {
+    ObjectDeserializer serverRequestDeserializer = requestObjectDeserializer == null
+        ? serializer.getObjectDeserializer()
+        : requestObjectDeserializer;
     return new MembershipLocatorImpl<>(port, bindAddress, protocolChecker,
         executorServiceSupplier,
         socketCreator, serializer.getObjectSerializer(), serializer.getObjectDeserializer(),
-        fallbackHandler,
+        serverRequestDeserializer, fallbackHandler,
         locatorsAreCoordinators, locatorStats, workingDirectory, config);
   }
 
diff --git a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/locator/MembershipLocatorImpl.java b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/locator/MembershipLocatorImpl.java
index 1edce0e..0381ea4 100644
--- a/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/locator/MembershipLocatorImpl.java
+++ b/geode-membership/src/main/java/org/apache/geode/distributed/internal/membership/gms/locator/MembershipLocatorImpl.java
@@ -68,6 +68,7 @@
       TcpSocketCreator socketCreator,
       ObjectSerializer objectSerializer,
       ObjectDeserializer objectDeserializer,
+      ObjectDeserializer serverRequestDeserializer,
       TcpHandler fallbackHandler,
       boolean locatorsAreCoordinators,
       MembershipLocatorStatistics locatorStats, Path workingDirectory,
@@ -87,7 +88,7 @@
         executorServiceSupplier,
         socketCreator,
         objectSerializer,
-        objectDeserializer,
+        serverRequestDeserializer,
         GeodeGlossary.GEMFIRE_PREFIX + "TcpServer.READ_TIMEOUT",
         GeodeGlossary.GEMFIRE_PREFIX + "TcpServer.BACKLOG");