diff --git a/boms/geode-all-bom/src/test/resources/expected-pom.xml b/boms/geode-all-bom/src/test/resources/expected-pom.xml
index ca41c043f528..84d481c32760 100644
--- a/boms/geode-all-bom/src/test/resources/expected-pom.xml
+++ b/boms/geode-all-bom/src/test/resources/expected-pom.xml
@@ -195,7 +195,7 @@
io.micrometermicrometer-core
- 1.15.12
+ 1.16.7io.swagger.core.v3
@@ -475,22 +475,22 @@
com.fasterxml.jackson.corejackson-core
- 2.21.5
+ 2.21.6com.fasterxml.jackson.corejackson-databind
- 2.21.5
+ 2.21.6com.fasterxml.jackson.datatypejackson-datatype-joda
- 2.21.5
+ 2.21.6com.fasterxml.jackson.datatypejackson-datatype-jsr310
- 2.21.5
+ 2.21.6com.jayway.jsonpath
diff --git a/build-tools/geode-dependency-management/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy b/build-tools/geode-dependency-management/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
index 29e0d699c211..483208269cae 100644
--- a/build-tools/geode-dependency-management/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
+++ b/build-tools/geode-dependency-management/src/main/groovy/org/apache/geode/gradle/plugins/DependencyConstraints.groovy
@@ -48,16 +48,16 @@ class DependencyConstraints {
deps.put("jgroups.version", "3.6.20.Final")
deps.put("log4j.version", "2.25.5")
deps.put("log4j-slf4j2-impl.version", "2.23.1")
- deps.put("micrometer.version", "1.15.12")
+ deps.put("micrometer.version", "1.16.7")
deps.put("shiro.version", "3.0.0")
// GEODE-10583: Pin Bouncy Castle (transitive via shiro-crypto-hash) to a fixed version
deps.put("bouncycastle.version", "1.85")
deps.put("slf4j-api.version", "2.0.18")
deps.put("jakarta.transaction-api.version", "2.0.1")
deps.put("jboss-modules.version", "1.11.0.Final")
- deps.put("jackson.version", "2.21.5")
+ deps.put("jackson.version", "2.21.6")
deps.put("jackson.annotations.version", "2.21")
- deps.put("jackson.databind.version", "2.21.5")
+ deps.put("jackson.databind.version", "2.21.6")
// Spring Framework 6.x Migration
deps.put("springshell.version", "3.3.3")
deps.put("springframework.version", "6.1.21")
@@ -67,6 +67,9 @@ class DependencyConstraints {
deps.put("springldap.version", "3.2.7")
deps.put("springdoc.version", "2.6.0")
+ // Pin Reactor Core (transitive via spring-shell-core) to a fixed version
+ deps.put("reactor-core.version", "3.8.7")
+
// These version numbers are used in testing various versions of tomcat and are consumed explicitly
// in will be called explicitly in the relevant extensions module, and respective configurations
// in geode-assembly.gradle. Moreover, dependencyManagement does not seem to play nicely when
@@ -149,6 +152,8 @@ class DependencyConstraints {
api(group: 'io.github.resilience4j', name: 'resilience4j-retry', version: '1.7.1')
api(group: 'io.lettuce', name: 'lettuce-core', version: '6.1.8.RELEASE')
api(group: 'io.micrometer', name: 'micrometer-core', version: get('micrometer.version'))
+ // Pin Reactor Core (pulled in via spring-shell-core) to 3.8.7
+ api(group: 'io.projectreactor', name: 'reactor-core', version: get('reactor-core.version'))
api(group: 'io.swagger.core.v3', name: 'swagger-annotations', version: '2.2.22')
api(group: 'org.hdrhistogram', name: 'HdrHistogram', version: '2.2.2')
api(group: 'it.unimi.dsi', name: 'fastutil', version: get('fastutil.version'))
@@ -176,7 +181,7 @@ class DependencyConstraints {
api(group: 'org.apache.commons', name: 'commons-text', version: 1.9)
api(group: 'org.apache.derby', name: 'derby', version: '10.14.2.0')
// Apache HttpComponents 5.x - Modern HTTP client with HTTP/2 support
- api(group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.4.4')
+ api(group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.6.4')
api(group: 'org.apache.httpcomponents.core5', name: 'httpcore5', version: '5.4.3')
api(group: 'org.apache.httpcomponents.core5', name: 'httpcore5-h2', version: '5.4.3')
// Legacy HttpComponents 4.x (keep temporarily during migration, remove after complete)
diff --git a/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityIntegrationTest.java b/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityIntegrationTest.java
index 2a9bc83f35e1..7a0a97fe7242 100644
--- a/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityIntegrationTest.java
+++ b/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityIntegrationTest.java
@@ -107,6 +107,8 @@ public void testPostQuery() {
assertResponse(restClient.doPost("/queries?id=0&q=", "user", "user", ""))
.hasStatusCode(403);
assertResponse(restClient.doPost("/queries?id=0&q=", "dataRead", "dataRead", ""))
+ .hasStatusCode(403);
+ assertResponse(restClient.doPost("/queries?id=0&q=", "dataWrite", "dataWrite", ""))
.hasStatusCode(500);
}
@@ -127,6 +129,8 @@ public void testPutQuery() {
assertResponse(restClient.doPut("/queries/id", "user", "user", "{\"id\" : \"foo\"}"))
.hasStatusCode(403);
assertResponse(restClient.doPut("/queries/id", "dataRead", "dataRead", "{\"id\" : \"foo\"}"))
+ .hasStatusCode(403);
+ assertResponse(restClient.doPut("/queries/id", "dataWrite", "dataWrite", "{\"id\" : \"foo\"}"))
.hasStatusCode(404);
}
diff --git a/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityPostProcessorTest.java b/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityPostProcessorTest.java
index a30855ac3631..cd5a3fd73004 100644
--- a/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityPostProcessorTest.java
+++ b/geode-assembly/src/integrationTest/java/org/apache/geode/rest/internal/web/RestSecurityPostProcessorTest.java
@@ -158,7 +158,7 @@ public void namedQuery() throws Exception {
// Install the named query
assertResponse(
restClient.doPost("/queries?id=selectCustomer&q=" + URLEncoder.encode(namedQuery, "UTF-8"),
- "dataReader", "1234567", ""))
+ "dataUser", "1234567", ""))
.hasStatusCode(201);
// Verify the query has been installed
diff --git a/geode-assembly/src/integrationTest/resources/assembly_content.txt b/geode-assembly/src/integrationTest/resources/assembly_content.txt
index 862f8c53a3af..c2c6ae29523f 100644
--- a/geode-assembly/src/integrationTest/resources/assembly_content.txt
+++ b/geode-assembly/src/integrationTest/resources/assembly_content.txt
@@ -960,16 +960,16 @@ lib/geode-unsafe-0.0.0.jar
lib/geode-wan-0.0.0.jar
lib/gfsh-dependencies.jar
lib/hibernate-validator-8.0.2.Final.jar
-lib/httpclient5-5.4.4.jar
+lib/httpclient5-5.6.4.jar
lib/httpcore5-5.4.3.jar
lib/httpcore5-h2-5.4.3.jar
lib/istack-commons-runtime-4.1.1.jar
lib/jackson-annotations-2.21.jar
-lib/jackson-core-2.21.5.jar
-lib/jackson-databind-2.21.5.jar
-lib/jackson-dataformat-yaml-2.21.5.jar
-lib/jackson-datatype-joda-2.21.5.jar
-lib/jackson-datatype-jsr310-2.21.5.jar
+lib/jackson-core-2.21.6.jar
+lib/jackson-databind-2.21.6.jar
+lib/jackson-dataformat-yaml-2.21.6.jar
+lib/jackson-datatype-joda-2.21.6.jar
+lib/jackson-datatype-jsr310-2.21.6.jar
lib/jakarta.activation-api-2.1.3.jar
lib/jakarta.annotation-api-2.1.1.jar
lib/jakarta.el-api-5.0.0.jar
@@ -1013,6 +1013,7 @@ lib/jna-5.11.0.jar
lib/jna-platform-5.11.0.jar
lib/joda-time-2.12.7.jar
lib/jopt-simple-5.0.4.jar
+lib/jspecify-1.0.1.jar
lib/jul-to-slf4j-2.0.17.jar
lib/log4j-api-2.25.5.jar
lib/log4j-core-2.25.5.jar
@@ -1024,15 +1025,15 @@ lib/lucene-analysis-phonetic-9.12.3.jar
lib/lucene-core-9.12.3.jar
lib/lucene-queries-9.12.3.jar
lib/lucene-queryparser-9.12.3.jar
-lib/micrometer-commons-1.15.12.jar
-lib/micrometer-core-1.15.12.jar
-lib/micrometer-observation-1.15.12.jar
+lib/micrometer-commons-1.16.7.jar
+lib/micrometer-core-1.16.7.jar
+lib/micrometer-observation-1.16.7.jar
lib/mx4j-3.0.2.jar
lib/mx4j-remote-3.0.2.jar
lib/mx4j-tools-3.0.1.jar
lib/ra.jar
lib/reactive-streams-1.0.4.jar
-lib/reactor-core-3.6.10.jar
+lib/reactor-core-3.8.7.jar
lib/rmiio-2.1.2.jar
lib/shiro-cache-3.0.0.jar
lib/shiro-config-core-3.0.0.jar
diff --git a/geode-assembly/src/integrationTest/resources/expected_jars.txt b/geode-assembly/src/integrationTest/resources/expected_jars.txt
index facbde0d7f6b..37c04b61a0b8 100644
--- a/geode-assembly/src/integrationTest/resources/expected_jars.txt
+++ b/geode-assembly/src/integrationTest/resources/expected_jars.txt
@@ -79,6 +79,7 @@ joda-time
jopt-simple
json-path
json-smart
+jspecify
jul-to-slf4j
lang-tag
log4j-api
diff --git a/geode-assembly/src/integrationTest/resources/gfsh_dependency_classpath.txt b/geode-assembly/src/integrationTest/resources/gfsh_dependency_classpath.txt
index 68a5913622f0..17273bef851c 100644
--- a/geode-assembly/src/integrationTest/resources/gfsh_dependency_classpath.txt
+++ b/geode-assembly/src/integrationTest/resources/gfsh_dependency_classpath.txt
@@ -21,11 +21,11 @@ spring-shell-starter-3.3.3.jar
spring-web-6.1.21.jar
commons-lang3-3.18.0.jar
rmiio-2.1.2.jar
-jackson-datatype-jsr310-2.21.5.jar
-jackson-datatype-joda-2.21.5.jar
-jackson-core-2.21.5.jar
-jackson-dataformat-yaml-2.21.5.jar
-jackson-databind-2.21.5.jar
+jackson-datatype-jsr310-2.21.6.jar
+jackson-datatype-joda-2.21.6.jar
+jackson-core-2.21.6.jar
+jackson-dataformat-yaml-2.21.6.jar
+jackson-databind-2.21.6.jar
swagger-annotations-2.2.22.jar
jaxb-runtime-4.0.2.jar
jaxb-core-4.0.2.jar
@@ -60,7 +60,7 @@ lucene-analysis-common-9.12.3.jar
lucene-queryparser-9.12.3.jar
lucene-queries-9.12.3.jar
lucene-core-9.12.3.jar
-httpclient5-5.4.4.jar
+httpclient5-5.6.4.jar
httpcore5-h2-5.4.3.jar
httpcore5-5.4.3.jar
HikariCP-4.0.3.jar
@@ -76,7 +76,7 @@ commons-digester-2.1.jar
commons-io-2.19.0.jar
commons-logging-1.3.5.jar
classgraph-4.8.147.jar
-micrometer-core-1.15.12.jar
+micrometer-core-1.16.7.jar
HdrHistogram-2.2.2.jar
fastutil-8.5.8.jar
jakarta.resource-api-2.1.0.jar
@@ -124,12 +124,13 @@ jline-reader-3.26.3.jar
jline-style-3.26.3.jar
jline-terminal-3.26.3.jar
jline-native-3.26.3.jar
-micrometer-observation-1.15.12.jar
+micrometer-observation-1.16.7.jar
spring-jcl-6.1.21.jar
-micrometer-commons-1.15.12.jar
+micrometer-commons-1.16.7.jar
+jspecify-1.0.1.jar
LatencyUtils-2.0.3.jar
snakeyaml-2.5.jar
-reactor-core-3.6.10.jar
+reactor-core-3.8.7.jar
ST4-4.3.3.jar
txw2-4.0.2.jar
asm-commons-9.10.1.jar
diff --git a/geode-core/src/integrationTest/java/org/apache/geode/cache/client/internal/RegisterInterestPolicyPartIntegrationTest.java b/geode-core/src/integrationTest/java/org/apache/geode/cache/client/internal/RegisterInterestPolicyPartIntegrationTest.java
new file mode 100644
index 000000000000..639e0fd89deb
--- /dev/null
+++ b/geode-core/src/integrationTest/java/org/apache/geode/cache/client/internal/RegisterInterestPolicyPartIntegrationTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.cache.client.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import org.apache.geode.cache.DataPolicy;
+import org.apache.geode.cache.RegionShortcut;
+import org.apache.geode.cache.client.PoolFactory;
+import org.apache.geode.cache.client.PoolManager;
+import org.apache.geode.internal.cache.tier.InterestType;
+import org.apache.geode.internal.cache.tier.MessageType;
+import org.apache.geode.internal.cache.tier.sockets.ChunkedMessage;
+import org.apache.geode.internal.cache.tier.sockets.Message;
+import org.apache.geode.internal.serialization.KnownVersion;
+import org.apache.geode.test.junit.categories.ClientServerTest;
+import org.apache.geode.test.junit.rules.ServerStarterRule;
+
+/**
+ * Exercises, over a real client connection to a running server, how the register-interest command
+ * reads the message part that carries its interest result policy.
+ *
+ *
+ * A client op builds a register-interest request whose policy part carries a type other than the
+ * policy argument, and sends it. The helper type records whether an instance of it is created on
+ * the server while the part is read. The server must read the part only as its expected policy type
+ * and refuse a part carrying any other type.
+ */
+@Category({ClientServerTest.class})
+public class RegisterInterestPolicyPartIntegrationTest {
+
+ private static final String REGION_NAME = "region";
+
+ @Rule
+ public ServerStarterRule server =
+ new ServerStarterRule().withRegion(RegionShortcut.REPLICATE, REGION_NAME).withAutoStart();
+
+ private PoolImpl pool;
+
+ @Before
+ public void setUp() {
+ OtherPartType.reset();
+ final PoolFactory poolFactory = PoolManager.createFactory();
+ poolFactory.addServer("localhost", server.getPort());
+ poolFactory.setReadTimeout(10_000);
+ poolFactory.setMinConnections(1);
+ pool = (PoolImpl) poolFactory.create("testPool");
+ }
+
+ @After
+ public void tearDown() {
+ if (pool != null) {
+ pool.destroy();
+ }
+ }
+
+ @Test
+ public void serverDoesNotProduceAnotherTypeFromThePolicyPart() {
+ try {
+ pool.execute(new PolicyPartOfAnotherTypeOp(REGION_NAME));
+ } catch (final Exception ignored) {
+ // The request does not complete: the point of interest is which type the server produced
+ // while reading the part, which is recorded independently below.
+ }
+
+ assertThat(OtherPartType.instantiated)
+ .as("reading the policy part must not produce a type other than the policy on the server")
+ .isFalse();
+ }
+
+ /**
+ * A register-interest request whose policy part carries a type other than the policy argument.
+ * Sends the request and does not attempt to interpret the response.
+ */
+ private static class PolicyPartOfAnotherTypeOp extends AbstractOp {
+
+ PolicyPartOfAnotherTypeOp(final String region) {
+ super(MessageType.REGISTER_INTEREST, 7);
+ getMessage().addStringPart(region, true);
+ getMessage().addIntPart(InterestType.KEY.ordinal());
+ getMessage().addObjPart(new OtherPartType());
+ getMessage().addBytesPart(new byte[] {(byte) 0x00});
+ getMessage().addStringOrObjPart("key");
+ getMessage().addBytesPart(new byte[] {(byte) 0x00});
+ getMessage().addBytesPart(new byte[] {(byte) DataPolicy.REPLICATE.ordinal(), (byte) 0x01});
+ }
+
+ @Override
+ protected Message createResponseMessage() {
+ return new ChunkedMessage(1, KnownVersion.CURRENT);
+ }
+
+ @Override
+ protected Object processResponse(final Message msg) throws Exception {
+ // Drain the whole response so this op does not return until the server has finished
+ // handling the request.
+ final ChunkedMessage chunkedMessage = (ChunkedMessage) msg;
+ chunkedMessage.readHeader();
+ do {
+ chunkedMessage.receiveChunk();
+ } while (!chunkedMessage.isLastChunk());
+ return null;
+ }
+
+ @Override
+ protected boolean isErrorResponse(final MessageType msgType) {
+ return false;
+ }
+
+ @Override
+ protected long startAttempt(final ConnectionStats stats) {
+ return 0;
+ }
+
+ @Override
+ protected void endSendAttempt(final ConnectionStats stats, final long start) {}
+
+ @Override
+ protected void endAttempt(final ConnectionStats stats, final long start) {}
+ }
+
+ /**
+ * A serializable type other than the register-interest policy argument. It records whether an
+ * instance of it is created, so a test can tell which type a part produced.
+ */
+ public static class OtherPartType implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ static volatile boolean instantiated = false;
+
+ static void reset() {
+ instantiated = false;
+ }
+
+ private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ instantiated = true;
+ }
+ }
+}
diff --git a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/BaseCommand.java b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/BaseCommand.java
index 9000a5503c00..8ca2c7daea8e 100644
--- a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/BaseCommand.java
+++ b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/BaseCommand.java
@@ -82,6 +82,8 @@
import org.apache.geode.internal.offheap.OffHeapHelper;
import org.apache.geode.internal.security.SecurityService;
import org.apache.geode.internal.sequencelog.EntryLogger;
+import org.apache.geode.internal.serialization.DSCODE;
+import org.apache.geode.internal.serialization.DataSerializableFixedID;
import org.apache.geode.logging.internal.log4j.api.LogService;
import org.apache.geode.security.GemFireSecurityException;
import org.apache.geode.util.internal.GeodeGlossary;
@@ -92,6 +94,9 @@ public abstract class BaseCommand implements Command {
@Immutable
private static final byte[] OK_BYTES = new byte[] {0};
+ /** Length of the serialized form of an interest result policy: code, identifier, ordinal. */
+ private static final int INTEREST_RESULT_POLICY_FORM_LENGTH = 3;
+
public static final int MAXIMUM_CHUNK_SIZE =
Integer.getInteger("BridgeServer.MAXIMUM_CHUNK_SIZE", 100);
@@ -873,6 +878,39 @@ static Message readRequest(final @NotNull ServerConnection servConn) {
return requestMsg;
}
+ /**
+ * Reads the interest result policy carried by the given message part.
+ *
+ *
+ * The policy is written by the client in the fixed-identifier form of
+ * {@link InterestResultPolicy}. Only that form is accepted here, so the part is read as a policy
+ * and a part in any other form is refused.
+ *
+ * @param policyPart the message part holding the interest result policy
+ * @return the policy the part describes
+ * @throws IOException if the part is not in the expected form
+ */
+ protected static @NotNull InterestResultPolicy readInterestResultPolicy(
+ final @NotNull Part policyPart) throws IOException, ClassNotFoundException {
+ if (!hasInterestResultPolicyForm(policyPart)) {
+ throw new IOException("The interest result policy part is not in the expected form.");
+ }
+ return (InterestResultPolicy) policyPart.getObject();
+ }
+
+ private static boolean hasInterestResultPolicyForm(final @NotNull Part policyPart) {
+ if (!policyPart.isObject()) {
+ return false;
+ }
+ final byte[] serializedForm = policyPart.getSerializedForm();
+ return serializedForm != null
+ && serializedForm.length == INTEREST_RESULT_POLICY_FORM_LENGTH
+ && serializedForm[0] == DSCODE.DS_FIXED_ID_BYTE.toByte()
+ && serializedForm[1] == DataSerializableFixedID.INTEREST_RESULT_POLICY
+ && serializedForm[2] >= InterestResultPolicy.NONE.getOrdinal()
+ && serializedForm[2] <= InterestResultPolicy.KEYS_VALUES.getOrdinal();
+ }
+
protected static void fillAndSendRegisterInterestResponseChunks(
final @Nullable LocalRegion region,
final @NotNull Object riKey, final @NotNull InterestType interestType,
diff --git a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61.java b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61.java
index 86984deb1640..b8953abf6250 100644
--- a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61.java
+++ b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61.java
@@ -78,7 +78,7 @@ public void cmdExecute(final @NotNull Message clientMessage,
final InterestResultPolicy policy;
try {
- policy = (InterestResultPolicy) clientMessage.getPart(2).getObject();
+ policy = readInterestResultPolicy(clientMessage.getPart(2));
} catch (Exception e) {
writeChunkedException(clientMessage, e, serverConnection);
serverConnection.setAsTrue(RESPONDED);
@@ -113,6 +113,10 @@ public void cmdExecute(final @NotNull Message clientMessage,
Object key;
try {
final Part keyPart = clientMessage.getPart(4);
+ if (interestType == InterestType.REGULAR_EXPRESSION && keyPart.isObject()) {
+ throw new IOException(
+ "The key part of a regular expression request is not in the expected form.");
+ }
key = keyPart.getStringOrObject();
} catch (Exception e) {
writeChunkedException(clientMessage, e, serverConnection);
diff --git a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66.java b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66.java
index a64197eb80da..c4d06db5ad3a 100644
--- a/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66.java
+++ b/geode-core/src/main/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66.java
@@ -79,7 +79,7 @@ public void cmdExecute(final @NotNull Message clientMessage,
// Retrieve the InterestResultPolicy
final InterestResultPolicy policy;
try {
- policy = (InterestResultPolicy) clientMessage.getPart(1).getObject();
+ policy = readInterestResultPolicy(clientMessage.getPart(1));
} catch (Exception e) {
writeChunkedException(clientMessage, e, serverConnection);
serverConnection.setAsTrue(RESPONDED);
diff --git a/geode-core/src/main/java/org/apache/geode/metrics/internal/NoopMeterRegistry.java b/geode-core/src/main/java/org/apache/geode/metrics/internal/NoopMeterRegistry.java
index bd70d1d99240..b93f069a6bc4 100644
--- a/geode-core/src/main/java/org/apache/geode/metrics/internal/NoopMeterRegistry.java
+++ b/geode-core/src/main/java/org/apache/geode/metrics/internal/NoopMeterRegistry.java
@@ -39,13 +39,10 @@
import io.micrometer.core.instrument.noop.NoopLongTaskTimer;
import io.micrometer.core.instrument.noop.NoopMeter;
import io.micrometer.core.instrument.noop.NoopTimer;
-import io.micrometer.core.lang.NonNullApi;
-import io.micrometer.core.lang.Nullable;
import org.apache.geode.annotations.Immutable;
import org.apache.geode.annotations.VisibleForTesting;
-@NonNullApi
public class NoopMeterRegistry extends MeterRegistry {
@Immutable
@@ -71,7 +68,7 @@ private NoopMeterRegistry(Clock clock) {
}
@Override
- protected Gauge newGauge(Meter.Id id, @Nullable T obj, ToDoubleFunction valueFunction) {
+ protected Gauge newGauge(Meter.Id id, T obj, ToDoubleFunction valueFunction) {
return new NoopGauge(id);
}
diff --git a/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/RegisterInterestObjectPartTest.java b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/RegisterInterestObjectPartTest.java
new file mode 100644
index 000000000000..b439d0a6f44a
--- /dev/null
+++ b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/RegisterInterestObjectPartTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.internal.cache.tier.sockets;
+
+import static org.apache.geode.internal.cache.tier.sockets.BaseCommand.readInterestResultPolicy;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import org.apache.geode.cache.InterestResultPolicy;
+import org.apache.geode.internal.util.BlobHelper;
+import org.apache.geode.test.junit.categories.ClientServerTest;
+
+/**
+ * Verifies how the register-interest commands read the message {@link Part} that carries the
+ * interest result policy.
+ *
+ *
+ * The part is read as an {@link InterestResultPolicy}: it is accepted only in the form the client
+ * writes it in, and a part carrying any other type is refused and that type is not produced. The
+ * helper type below records whether an instance of it is created while a part is read.
+ */
+@Category({ClientServerTest.class})
+public class RegisterInterestObjectPartTest {
+
+ @Before
+ public void setUp() {
+ OtherPartType.reset();
+ }
+
+ @Test
+ public void policyPartOfAnotherTypeIsRefusedWithoutProducingThatType() throws Exception {
+ final byte[] objectPartBytes = BlobHelper.serializeToBlob(new OtherPartType());
+
+ final Part part = new Part();
+ part.setPartState(objectPartBytes, true);
+
+ assertThat(catchThrowable(() -> readInterestResultPolicy(part)))
+ .as("a policy part holding another type is refused")
+ .isInstanceOf(IOException.class);
+
+ assertThat(OtherPartType.instantiated)
+ .as("reading the policy part must not produce a type other than the policy")
+ .isFalse();
+ }
+
+ @Test
+ public void nonObjectPolicyPartIsRefused() {
+ final Part part = new Part();
+ part.setPartState(new byte[] {0x01, 0x25, 0x02}, false);
+
+ assertThat(catchThrowable(() -> readInterestResultPolicy(part)))
+ .as("a policy part that is not object typed is refused")
+ .isInstanceOf(IOException.class);
+ }
+
+ @Test
+ public void eachPolicyValueRoundTripsThroughThePart() throws Exception {
+ for (final InterestResultPolicy expected : new InterestResultPolicy[] {
+ InterestResultPolicy.NONE, InterestResultPolicy.KEYS, InterestResultPolicy.KEYS_VALUES}) {
+ final Part part = new Part();
+ part.setPartState(BlobHelper.serializeToBlob(expected), true);
+
+ assertThat(readInterestResultPolicy(part))
+ .as("policy %s survives a write and read of the policy part", expected)
+ .isSameAs(expected);
+ }
+ }
+
+ /**
+ * A serializable type other than the register-interest policy argument. It records whether an
+ * instance of it is created, so a test can tell which type a part produced.
+ */
+ public static class OtherPartType implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ static volatile boolean instantiated = false;
+
+ static void reset() {
+ instantiated = false;
+ }
+
+ private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ instantiated = true;
+ }
+ }
+}
diff --git a/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61Test.java b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61Test.java
index 6559f856cef5..cfec6dbf98fd 100644
--- a/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61Test.java
+++ b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterest61Test.java
@@ -33,6 +33,7 @@
import org.mockito.MockitoAnnotations;
import org.apache.geode.CancelCriterion;
+import org.apache.geode.cache.InterestResultPolicy;
import org.apache.geode.cache.operations.RegisterInterestOperationContext;
import org.apache.geode.internal.cache.InternalCache;
import org.apache.geode.internal.cache.LocalRegion;
@@ -46,6 +47,7 @@
import org.apache.geode.internal.security.AuthorizeRequest;
import org.apache.geode.internal.security.SecurityService;
import org.apache.geode.internal.serialization.KnownVersion;
+import org.apache.geode.internal.util.BlobHelper;
import org.apache.geode.security.NotAuthorizedException;
import org.apache.geode.security.ResourcePermission.Operation;
import org.apache.geode.security.ResourcePermission.Resource;
@@ -98,6 +100,9 @@ public void setUp() throws Exception {
when(cache.getRegion(isA(String.class))).thenReturn(uncheckedCast(mock(LocalRegion.class)));
when(cache.getCancelCriterion()).thenReturn(mock(CancelCriterion.class));
+ final Part policyPart = new Part();
+ policyPart.setPartState(BlobHelper.serializeToBlob(InterestResultPolicy.KEYS_VALUES), true);
+
when(durablePart.getObject()).thenReturn(DURABLE);
when(interestTypePart.getInt()).thenReturn(0);
@@ -107,7 +112,7 @@ public void setUp() throws Exception {
when(message.getNumberOfParts()).thenReturn(6);
when(message.getPart(eq(0))).thenReturn(regionNamePart);
when(message.getPart(eq(1))).thenReturn(interestTypePart);
- when(message.getPart(eq(2))).thenReturn(mock(Part.class));
+ when(message.getPart(eq(2))).thenReturn(policyPart);
when(message.getPart(eq(3))).thenReturn(durablePart);
when(message.getPart(eq(4))).thenReturn(keyPart);
when(message.getPart(eq(5))).thenReturn(notifyPart);
diff --git a/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66Test.java b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66Test.java
index a3a6a0f6f501..3d4f1fc34eb6 100644
--- a/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66Test.java
+++ b/geode-core/src/test/java/org/apache/geode/internal/cache/tier/sockets/command/RegisterInterestList66Test.java
@@ -35,6 +35,7 @@
import org.mockito.MockitoAnnotations;
import org.apache.geode.CancelCriterion;
+import org.apache.geode.cache.InterestResultPolicy;
import org.apache.geode.cache.operations.RegisterInterestOperationContext;
import org.apache.geode.internal.cache.InternalCache;
import org.apache.geode.internal.cache.LocalRegion;
@@ -47,6 +48,7 @@
import org.apache.geode.internal.security.AuthorizeRequest;
import org.apache.geode.internal.security.SecurityService;
import org.apache.geode.internal.serialization.KnownVersion;
+import org.apache.geode.internal.util.BlobHelper;
import org.apache.geode.security.NotAuthorizedException;
import org.apache.geode.security.ResourcePermission.Operation;
import org.apache.geode.security.ResourcePermission.Resource;
@@ -103,6 +105,9 @@ public void setUp() throws Exception {
when(cache.getRegion(isA(String.class))).thenReturn(uncheckedCast(mock(LocalRegion.class)));
when(cache.getCancelCriterion()).thenReturn(mock(CancelCriterion.class));
+ final Part policyPart = new Part();
+ policyPart.setPartState(BlobHelper.serializeToBlob(InterestResultPolicy.KEYS_VALUES), true);
+
when(durablePart.getObject()).thenReturn(DURABLE);
when(interestTypePart.getInt()).thenReturn(0);
@@ -111,7 +116,7 @@ public void setUp() throws Exception {
when(message.getNumberOfParts()).thenReturn(6);
when(message.getPart(eq(0))).thenReturn(regionNamePart);
- when(message.getPart(eq(1))).thenReturn(interestTypePart);
+ when(message.getPart(eq(1))).thenReturn(policyPart);
when(message.getPart(eq(2))).thenReturn(durablePart);
when(message.getPart(eq(3))).thenReturn(keyPart);
when(message.getPart(eq(4))).thenReturn(notifyPart);
diff --git a/geode-docs/security/implementing_authorization.html.md.erb b/geode-docs/security/implementing_authorization.html.md.erb
index 37dcb917394c..d30712d29370 100644
--- a/geode-docs/security/implementing_authorization.html.md.erb
+++ b/geode-docs/security/implementing_authorization.html.md.erb
@@ -154,7 +154,7 @@ This table classifies the permissions assigned for `gfsh` operations.
| execute function | Defaults to DATA:WRITE. Override `Function.getRequiredPermissions` to change the permission. |
| export cluster-configuration | CLUSTER:READ |
| export config | CLUSTER:READ |
-| export data | CLUSTER:READ |
+| export data | DATA:READ:RegionName and CLUSTER:WRITE |
| export logs | CLUSTER:READ |
| export offline-disk-store | CLUSTER:READ |
| export stack-traces | CLUSTER:READ |
diff --git a/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb b/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb
index 0fc1a76be7ad..4c360119ef7f 100644
--- a/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb
+++ b/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb
@@ -165,6 +165,22 @@ In this scenario, partitioned region data is exported simultaneously on all host
| ‑‑dir | Directory to which the exported data is to be written. Required if ‑‑parallel is true. Cannot be specified at the same time as ‑‑file.|
| ‑‑parallel | Export local data on each node to a directory on that machine. Available for partitioned regions only. |
+**Export locations:**
+
+The snapshot is written by the member named in `--member`, on that member's host. A member writes
+exports into its own working directory (and sub-directories of it). To export somewhere else, such
+as a mounted backup location, set the `gemfire.export.data.dirs` system property on the member to
+the additional directories, separated by the platform's path separator:
+
+``` pre
+-Dgemfire.export.data.dirs=/mnt/backup/geode:/var/exports/geode
+```
+
+A path containing a `..` segment is not accepted, and a path that resolves outside the configured
+directories is rejected by the member.
+
+**Required permission:** `DATA:READ` on the exported region, plus `CLUSTER:WRITE`.
+
**Example Commands:**
``` pre
diff --git a/geode-gfsh/src/distributedTest/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPermissionsDUnitTest.java b/geode-gfsh/src/distributedTest/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPermissionsDUnitTest.java
new file mode 100644
index 000000000000..92324e04b34b
--- /dev/null
+++ b/geode-gfsh/src/distributedTest/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPermissionsDUnitTest.java
@@ -0,0 +1,227 @@
+/*
+ * 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.management.internal.cli.commands;
+
+import static org.apache.geode.distributed.ConfigurationProperties.SECURITY_MANAGER;
+import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.geode.cache.RegionShortcut;
+import org.apache.geode.examples.SimpleSecurityManager;
+import org.apache.geode.internal.cache.InternalCache;
+import org.apache.geode.management.internal.security.ResourceConstants;
+import org.apache.geode.test.dunit.IgnoredException;
+import org.apache.geode.test.dunit.rules.ClusterStartupRule;
+import org.apache.geode.test.dunit.rules.MemberVM;
+import org.apache.geode.test.junit.categories.SecurityTest;
+import org.apache.geode.test.junit.rules.GfshCommandRule;
+
+/**
+ * Tests which principals may run {@code export data} in a secured cluster.
+ *
+ *
+ * {@link SimpleSecurityManager} authorizes a user for exactly those permissions whose string form
+ * starts with the user name, and treats a comma separated user name as a set of roles. So
+ * "dataRead" holds DATA:READ alone, while "dataRead,clusterWrite" is the operator {@code
+ * export data} requires.
+ */
+@Category(SecurityTest.class)
+public class ExportDataCommandPermissionsDUnitTest implements Serializable {
+
+ private static final String REGION_NAME = "testRegion";
+ private static final String READ_ONLY_USER = "dataRead";
+ private static final String EXPORT_OPERATOR = "dataRead,clusterWrite";
+
+ @ClassRule
+ public static ClusterStartupRule cluster = new ClusterStartupRule();
+
+ @Rule
+ public GfshCommandRule gfsh = new GfshCommandRule();
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static MemberVM locator;
+ private static MemberVM server;
+
+ /** The directory the server has been configured to permit exports into. */
+ private Path permittedDir;
+
+ /** Any other location on the server host. */
+ private Path otherDir;
+
+ @BeforeClass
+ public static void beforeClass() {
+ Properties locatorProps = new Properties();
+ locatorProps.setProperty(SECURITY_MANAGER, SimpleSecurityManager.class.getName());
+ locator = cluster.startLocatorVM(0, locatorProps);
+
+ Properties serverProps = new Properties();
+ serverProps.setProperty(ResourceConstants.USER_NAME, "clusterManage");
+ serverProps.setProperty(ResourceConstants.PASSWORD, "clusterManage");
+ server = cluster.startServerVM(1, serverProps, locator.getPort());
+
+ server.invoke(() -> {
+ InternalCache cache = ClusterStartupRule.getCache();
+ assertThat(cache).isNotNull();
+ cache.createRegionFactory(RegionShortcut.REPLICATE).create(REGION_NAME).put("key", "value");
+ });
+ }
+
+ @Before
+ public void configurePermittedExportDirectory() throws Exception {
+ // Refusing an export is logged at error level on the member; that is the expected outcome of
+ // most of these tests, not a symptom of one going wrong.
+ IgnoredException.addIgnoredException("Cannot export to");
+
+ permittedDir = temporaryFolder.newFolder("permitted").toPath();
+ otherDir = temporaryFolder.newFolder("other").toPath();
+
+ String permitted = permittedDir.toString();
+ server.invoke(() -> System.setProperty(EXPORT_DATA_DIRS_PROPERTY, permitted));
+ }
+
+ @After
+ public void clearPermittedExportDirectory() {
+ server.invoke(() -> System.clearProperty(EXPORT_DATA_DIRS_PROPERTY));
+ }
+
+ private void connectAs(String user) throws Exception {
+ gfsh.secureConnectAndVerify(locator.getPort(), GfshCommandRule.PortType.locator, user, user);
+ }
+
+ private String exportTo(String option, Path path) {
+ return "export data --member=" + server.getName() + " --region=" + REGION_NAME + " --"
+ + option + "=" + path;
+ }
+
+ /**
+ * Read access to region data on its own does not permit an export.
+ */
+ @Test
+ public void dataReadUserCannotExport() throws Exception {
+ connectAs(READ_ONLY_USER);
+ Path target = permittedDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(exportTo("file", target))
+ .statusIsError()
+ .containsOutput("not authorized for CLUSTER:WRITE");
+
+ assertThat(target).doesNotExist();
+ }
+
+ /**
+ * Permissions are checked before the path, so the target directory makes no difference.
+ */
+ @Test
+ public void dataReadUserIsRefusedForAnyDirectory() throws Exception {
+ connectAs(READ_ONLY_USER);
+ Path target = otherDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(exportTo("file", target))
+ .statusIsError()
+ .containsOutput("not authorized for CLUSTER:WRITE");
+
+ assertThat(target).doesNotExist();
+ }
+
+ /**
+ * The command works for a principal holding both permissions.
+ */
+ @Test
+ public void operatorWithClusterWriteCanExportIntoThePermittedDirectory() throws Exception {
+ connectAs(EXPORT_OPERATOR);
+ Path target = permittedDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(exportTo("file", target))
+ .statusIsSuccess()
+ .containsOutput("Data successfully exported");
+
+ assertThat(target).exists();
+ }
+
+ /**
+ * The directory restriction applies independently of the permission: even a permitted operator
+ * cannot place the snapshot anywhere it likes.
+ */
+ @Test
+ public void operatorCannotExportOutsideThePermittedDirectory() throws Exception {
+ connectAs(EXPORT_OPERATOR);
+ Path target = otherDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(exportTo("file", target)).statusIsError();
+
+ assertThat(target).doesNotExist();
+ }
+
+ /**
+ * Nor can the operator leave the permitted directory with "../".
+ */
+ @Test
+ public void operatorCannotLeavePermittedDirectoryWithParentReference() throws Exception {
+ connectAs(EXPORT_OPERATOR);
+ Path withParentReference = permittedDir.resolve("..").resolve("other");
+
+ gfsh.executeAndAssertThat(exportTo("dir", withParentReference)).statusIsError();
+
+ assertThat(otherDir.resolve(REGION_NAME + ".gfd")).doesNotExist();
+ }
+
+ /**
+ * An existing file outside the permitted directory survives an export aimed at it.
+ */
+ @Test
+ public void existingFileOutsideThePermittedDirectoryIsNotOverwritten() throws Exception {
+ connectAs(EXPORT_OPERATOR);
+ Path existingFile = otherDir.resolve("existing.gfd");
+ String originalContent = "existing content";
+ Files.write(existingFile, originalContent.getBytes(StandardCharsets.UTF_8));
+
+ gfsh.executeAndAssertThat(exportTo("file", existingFile)).statusIsError();
+
+ assertThat(new String(Files.readAllBytes(existingFile), StandardCharsets.UTF_8))
+ .isEqualTo(originalContent);
+ }
+
+ /**
+ * Confirms the read only grant really is read only, so the refusals above are the permission
+ * check taking effect rather than a misconfigured principal.
+ */
+ @Test
+ public void readOnlyUserCanStillReadData() throws Exception {
+ connectAs(READ_ONLY_USER);
+
+ gfsh.executeAndAssertThat("get --region=" + REGION_NAME + " --key=key").statusIsSuccess();
+ gfsh.executeAndAssertThat("put --region=" + REGION_NAME + " --key=k --value=v")
+ .statusIsError()
+ .containsOutput("dataRead not authorized for DATA:WRITE");
+ }
+}
diff --git a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java
index 80082f15f167..86e800d56058 100644
--- a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java
+++ b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java
@@ -17,6 +17,7 @@
package org.apache.geode.management.internal.cli.commands;
import static org.apache.geode.cache.Region.SEPARATOR;
+import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertFalse;
@@ -31,6 +32,7 @@
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
+import org.junit.contrib.java.lang.system.RestoreSystemProperties;
import org.junit.rules.TemporaryFolder;
import org.apache.geode.DataSerializable;
@@ -58,6 +60,9 @@ public class ExportDataIntegrationTest {
@Rule
public TemporaryFolder tempDir = new TemporaryFolder();
+ @Rule
+ public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
+
private Region region;
private Path snapshotFile;
private Path snapshotDir;
@@ -87,6 +92,8 @@ public void setup() throws Exception {
region = server.getCache().getRegion(TEST_REGION_NAME);
loadRegion("value");
Path basePath = tempDir.getRoot().toPath();
+ // configure the test's temporary folder as an export destination
+ System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString());
snapshotFile = basePath.resolve(SNAPSHOT_FILE);
snapshotDir = basePath.resolve(SNAPSHOT_DIR);
}
diff --git a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataPathValidationIntegrationTest.java b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataPathValidationIntegrationTest.java
new file mode 100644
index 000000000000..6b6add92a128
--- /dev/null
+++ b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataPathValidationIntegrationTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.management.internal.cli.commands;
+
+import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.IntStream;
+
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.contrib.java.lang.system.RestoreSystemProperties;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.geode.cache.Region;
+import org.apache.geode.cache.RegionShortcut;
+import org.apache.geode.management.internal.cli.util.CommandStringBuilder;
+import org.apache.geode.management.internal.i18n.CliStrings;
+import org.apache.geode.test.junit.rules.GfshCommandRule;
+import org.apache.geode.test.junit.rules.ServerStarterRule;
+
+/**
+ * End to end tests of the directories {@code export data} writes into: a live server exports into
+ * the directories it is configured to permit, and refuses paths that resolve outside them.
+ */
+public class ExportDataPathValidationIntegrationTest {
+ private static final String TEST_REGION_NAME = "testRegion";
+ private static final int DATA_POINTS = 10;
+
+ @ClassRule
+ public static ServerStarterRule server = new ServerStarterRule().withJMXManager()
+ .withRegion(RegionShortcut.PARTITION, TEST_REGION_NAME).withEmbeddedLocator();
+
+ @Rule
+ public GfshCommandRule gfsh = new GfshCommandRule();
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Rule
+ public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
+
+ /** The directory an operator has permitted this member to export into. */
+ private Path permittedDir;
+
+ /** Any other location on the server host. */
+ private Path otherDir;
+
+ private Region region;
+
+ @Before
+ public void setup() throws Exception {
+ gfsh.connectAndVerify(server.getEmbeddedLocatorPort(), GfshCommandRule.PortType.locator);
+ region = server.getCache().getRegion(TEST_REGION_NAME);
+ IntStream.range(0, DATA_POINTS).forEach(i -> region.put("key" + i, "value" + i));
+
+ permittedDir = temporaryFolder.newFolder("permitted").toPath();
+ otherDir = temporaryFolder.newFolder("other").toPath();
+ System.setProperty(EXPORT_DATA_DIRS_PROPERTY, permittedDir.toString());
+ }
+
+ /** The gfsh table wraps long messages, so compare against whitespace normalized output. */
+ private String normalizedOutput() {
+ return gfsh.getGfshOutput().replaceAll("\\s+", " ");
+ }
+
+ /**
+ * Exports into a permitted directory work normally.
+ */
+ @Test
+ public void exportIntoThePermittedDirectorySucceeds() {
+ Path target = permittedDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__FILE, target.toString())
+ .getCommandString()).statusIsSuccess();
+
+ assertThat(target).exists();
+ assertThat(target.toFile().length()).isGreaterThan(0L);
+ }
+
+ /** The --dir form works the same way. */
+ @Test
+ public void exportIntoThePermittedDirectoryWithDirOptionSucceeds() {
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__DIR, permittedDir.toString())
+ .getCommandString()).statusIsSuccess();
+
+ assertThat(permittedDir.resolve(TEST_REGION_NAME + ".gfd")).exists();
+ }
+
+ /**
+ * An absolute path outside the permitted directories does not produce a file.
+ */
+ @Test
+ public void exportToAnAbsolutePathOutsideThePermittedDirectoryIsRefused() {
+ Path target = otherDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__FILE, target.toString())
+ .getCommandString()).statusIsError();
+
+ assertThat(normalizedOutput()).contains("export directories configured for this member");
+ assertThat(target).doesNotExist();
+ }
+
+ /**
+ * A "../" in --dir is refused, and nothing is written at the location it points to.
+ */
+ @Test
+ public void exportWithParentReferenceInDirOptionIsRefused() {
+ String dirWithParentReference = permittedDir.resolve("..").resolve("other").toString();
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__DIR, dirWithParentReference)
+ .getCommandString()).statusIsError();
+
+ assertThat(normalizedOutput()).contains("path segment");
+ assertThat(otherDir.resolve(TEST_REGION_NAME + ".gfd")).doesNotExist();
+ }
+
+ /**
+ * The same for --file: it is caught before the export is sent to the member.
+ */
+ @Test
+ public void exportWithParentReferenceInFileOptionIsRefused() {
+ String fileWithParentReference = permittedDir.resolve("..").resolve("other")
+ .resolve("snapshot.gfd").toString();
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__FILE, fileWithParentReference)
+ .getCommandString()).statusIsError();
+
+ assertThat(normalizedOutput()).contains("path segment");
+ assertThat(otherDir.resolve("snapshot.gfd")).doesNotExist();
+ }
+
+ /**
+ * An existing file outside the permitted directories keeps its contents.
+ */
+ @Test
+ public void existingFileOutsideThePermittedDirectoryIsNotOverwritten() throws Exception {
+ Path existingFile = otherDir.resolve("existing.gfd");
+ String originalContent = "existing content";
+ Files.write(existingFile, originalContent.getBytes(StandardCharsets.UTF_8));
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__FILE, existingFile.toString())
+ .getCommandString()).statusIsError();
+
+ assertThat(new String(Files.readAllBytes(existingFile), StandardCharsets.UTF_8))
+ .isEqualTo(originalContent);
+ }
+
+ /**
+ * A parallel export does not create a directory tree outside the permitted directories.
+ */
+ @Test
+ public void parallelExportOutsideThePermittedDirectoryCreatesNoDirectories() {
+ Path newTree = otherDir.resolve("created/by/export");
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__DIR, newTree.toString())
+ .addOption(CliStrings.EXPORT_DATA__PARALLEL, "true")
+ .getCommandString()).statusIsError();
+
+ assertThat(newTree).doesNotExist();
+ }
+
+ /**
+ * Without configuration the member permits only its own working directory.
+ */
+ @Test
+ public void withoutConfigurationExportOutsideTheWorkingDirectoryIsRefused() {
+ System.clearProperty(EXPORT_DATA_DIRS_PROPERTY);
+ Path target = otherDir.resolve("snapshot.gfd");
+
+ gfsh.executeAndAssertThat(baseCommand()
+ .addOption(CliStrings.EXPORT_DATA__FILE, target.toString())
+ .getCommandString()).statusIsError();
+
+ assertThat(target).doesNotExist();
+ assertThat(normalizedOutput())
+ .contains(new File(System.getProperty("user.dir")).getName());
+ }
+
+ private CommandStringBuilder baseCommand() {
+ return new CommandStringBuilder(CliStrings.EXPORT_DATA)
+ .addOption(CliStrings.MEMBER, server.getName())
+ .addOption(CliStrings.EXPORT_DATA__REGION, TEST_REGION_NAME);
+ }
+}
diff --git a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java
index 63fb1461bf4c..2317ce847c91 100644
--- a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java
+++ b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java
@@ -17,6 +17,7 @@
package org.apache.geode.management.internal.cli.commands;
import static org.apache.geode.cache.Region.SEPARATOR;
+import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
@@ -30,6 +31,7 @@
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
+import org.junit.contrib.java.lang.system.RestoreSystemProperties;
import org.junit.rules.TemporaryFolder;
import org.apache.geode.cache.Region;
@@ -55,6 +57,9 @@ public class ImportDataIntegrationTest {
@Rule
public TemporaryFolder tempDir = new TemporaryFolder();
+ @Rule
+ public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
+
private Region region;
private Path snapshotFile;
private Path snapshotDir;
@@ -65,6 +70,8 @@ public void setup() throws Exception {
region = server.getCache().getRegion(TEST_REGION_NAME);
loadRegion("value");
Path basePath = tempDir.getRoot().toPath();
+ // configure the test's temporary folder as an export destination
+ System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString());
snapshotFile = basePath.resolve(SNAPSHOT_FILE);
snapshotDir = basePath.resolve(SNAPSHOT_DIR);
}
diff --git a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java
index 9892ceef5f3d..385d2c3e5242 100644
--- a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java
+++ b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java
@@ -16,6 +16,8 @@
package org.apache.geode.management.internal.cli.commands;
import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
@@ -33,6 +35,7 @@
import org.apache.geode.management.internal.cli.result.model.ResultModel;
import org.apache.geode.management.internal.functions.CliFunctionResult;
import org.apache.geode.management.internal.i18n.CliStrings;
+import org.apache.geode.security.ResourcePermission;
import org.apache.geode.security.ResourcePermission.Operation;
import org.apache.geode.security.ResourcePermission.Resource;
@@ -54,6 +57,7 @@ public ResultModel exportData(
help = CliStrings.EXPORT_DATA__PARALLEL_HELP) boolean parallel) {
authorize(Resource.DATA, Operation.READ, regionName);
+ authorize(Resource.CLUSTER, Operation.WRITE, ResourcePermission.ALL);
final DistributedMember targetMember = getMember(memberNameOrId);
Optional validationResult = validatePath(filePath, dirPath, parallel);
@@ -100,6 +104,28 @@ private Optional validatePath(String filePath, String dirPath, bool
return Optional.of(ResultModel.createError(CliStrings.format(
CliStrings.INVALID_FILE_EXTENSION, CliStrings.GEODE_DATA_FILE_EXTENSION)));
}
+
+ if (filePath != null && containsParentDirectorySegment(filePath)) {
+ return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__FILE, filePath));
+ }
+ if (dirPath != null && containsParentDirectorySegment(dirPath)) {
+ return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__DIR, dirPath));
+ }
+
return Optional.empty();
}
+
+ private static boolean containsParentDirectorySegment(String path) {
+ for (Path element : Paths.get(path)) {
+ if ("..".equals(element.toString())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static ResultModel invalidPathError(String option, String path) {
+ return ResultModel.createError(String.format(
+ "Option \"%s\" must not contain a \"..\" path segment: %s", option, path));
+ }
}
diff --git a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java
index 0c83d40a8ae6..2f0a18174721 100644
--- a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java
+++ b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java
@@ -15,6 +15,9 @@
package org.apache.geode.management.internal.cli.functions;
import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import org.apache.geode.cache.Cache;
import org.apache.geode.cache.Region;
@@ -27,12 +30,15 @@
import org.apache.geode.management.cli.CliFunction;
import org.apache.geode.management.internal.functions.CliFunctionResult;
import org.apache.geode.management.internal.i18n.CliStrings;
+import org.apache.geode.util.internal.GeodeGlossary;
/***
* Function which carries out the export of a region to a file on a member. Uses the
* RegionSnapshotService to export the data
*
- *
+ *
+ * Export destinations are resolved to their canonical form and must be within the export
+ * directories configured for this member.
*/
public class ExportDataFunction extends CliFunction {
private static final long serialVersionUID = 1L;
@@ -40,6 +46,18 @@ public class ExportDataFunction extends CliFunction {
private static final String ID =
"org.apache.geode.management.internal.cli.functions.ExportDataFunction";
+ /**
+ * System property naming additional directories this member writes {@code export data} snapshots
+ * into. Several directories may be listed, separated by {@link File#pathSeparator}. Exports into
+ * sub-directories of a configured directory are included.
+ *
+ *
+ * The member's working directory is always configured, since that is where a relative export
+ * path resolves to, so when this property is not set it is the only export destination.
+ */
+ public static final String EXPORT_DATA_DIRS_PROPERTY =
+ GeodeGlossary.GEMFIRE_PREFIX + "export.data.dirs";
+
@Override
public String getId() {
return ID;
@@ -62,7 +80,7 @@ public CliFunctionResult executeFunction(FunctionContext context) thro
String hostName = cache.getDistributedSystem().getDistributedMember().getHost();
if (region != null) {
RegionSnapshotService