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/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/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