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