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 snapshotService = region.getSnapshotService(); - final File exportFile = new File(fileName); + final File exportFile = resolveExportFile(fileName); if (parallel) { SnapshotOptions options = new SnapshotOptionsImpl<>().setParallelMode(true); snapshotService.save(exportFile, SnapshotFormat.GEODE, options); @@ -81,4 +99,42 @@ public CliFunctionResult executeFunction(FunctionContext context) thro return result; } + + /** + * Resolves the requested export path against the export directories configured for this member. + * + * @param fileName the path requested by the caller, which may be relative or absolute + * @return the canonical file to export to + * @throws IllegalArgumentException if the path is not within a configured export directory + */ + static File resolveExportFile(String fileName) throws IOException { + File exportFile = new File(fileName).getCanonicalFile(); + List exportDirs = configuredExportDirs(); + + for (File exportDir : exportDirs) { + if (exportFile.toPath().startsWith(exportDir.toPath())) { + return exportFile; + } + } + + throw new IllegalArgumentException(String.format( + "Cannot export to %s: the path is not within the export directories configured for this member (%s). Use the %s system property to configure additional directories.", + exportFile, exportDirs, EXPORT_DATA_DIRS_PROPERTY)); + } + + private static List configuredExportDirs() throws IOException { + List exportDirs = new ArrayList<>(); + exportDirs.add(new File(System.getProperty("user.dir")).getCanonicalFile()); + + String configuredDirs = System.getProperty(EXPORT_DATA_DIRS_PROPERTY); + if (configuredDirs != null) { + for (String configuredDir : configuredDirs.split(File.pathSeparator)) { + if (!configuredDir.trim().isEmpty()) { + exportDirs.add(new File(configuredDir.trim()).getCanonicalFile()); + } + } + } + + return exportDirs; + } } diff --git a/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPathValidationTest.java b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPathValidationTest.java new file mode 100644 index 000000000000..da3868806414 --- /dev/null +++ b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandPathValidationTest.java @@ -0,0 +1,215 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.io.File; +import java.util.Collections; + +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import org.apache.geode.cache.execute.ResultCollector; +import org.apache.geode.distributed.DistributedMember; +import org.apache.geode.management.internal.functions.CliFunctionResult; +import org.apache.geode.security.NotAuthorizedException; +import org.apache.geode.security.ResourcePermission; +import org.apache.geode.security.ResourcePermission.Operation; +import org.apache.geode.security.ResourcePermission.Resource; +import org.apache.geode.test.junit.rules.GfshParserRule; + +/** + * Tests the path validation and the authorization {@code export data} applies before it sends any + * work to a member. + * + *

+ * The directory configuration is applied on the member; the command checks the option for a parent + * directory reference and asks for the permissions the operation needs. + * + * @see ExportDataCommandPermissionsDUnitTest for the permissions end to end in a secured + * cluster + */ +public class ExportDataCommandPathValidationTest { + + @ClassRule + public static GfshParserRule parser = new GfshParserRule(); + + private static final String REGION = "testRegion"; + /** + * On this branch the option carries no ConverterHint.REGION_PATH, so the command method sees the + * region name exactly as typed (on support/1.15 the converter prepends the separator). + */ + private static final String REGION_PATH = REGION; + + private ExportDataCommand command; + private ArgumentCaptor functionArgsCaptor; + + @Before + public void before() { + command = spy(ExportDataCommand.class); + + doNothing().when(command).authorize(any(Resource.class), any(Operation.class), anyString()); + doReturn(mock(DistributedMember.class)).when(command).getMember(anyString()); + + CliFunctionResult okResult = + new CliFunctionResult("server1", CliFunctionResult.StatusState.OK, "exported"); + ResultCollector collector = mock(ResultCollector.class); + doReturn(Collections.singletonList(okResult)).when(collector).getResult(); + + functionArgsCaptor = ArgumentCaptor.forClass(Object.class); + doReturn(collector).when(command).executeFunction(any(), functionArgsCaptor.capture(), + any(DistributedMember.class)); + } + + private String capturedExportPath() { + Object args = functionArgsCaptor.getValue(); + assertThat(args).isInstanceOf(String[].class); + return ((String[]) args)[1]; + } + + private void verifyNoExportWasRequested() { + verify(command, never()).executeFunction(any(), any(), any(DistributedMember.class)); + } + + /** + * A "../" element in --file is refused before anything is sent to a member. + */ + @Test + public void parentReferenceInFileOptionIsRejected() { + parser + .executeAndAssertThat(command, "export data --member=server1 --region=" + REGION + + " --file=../../../../var/tmp/snapshot.gfd") + .statusIsError() + .containsOutput("must not contain a \"..\" path segment"); + + verifyNoExportWasRequested(); + } + + /** + * A "../" buried in the middle of an otherwise absolute --file is refused too - the check looks + * at every element of the path, not just its start. + */ + @Test + public void parentReferenceInsideAnAbsoluteFilePathIsRejected() { + parser + .executeAndAssertThat(command, "export data --member=server1 --region=" + REGION + + " --file=/var/tmp/subdir/../../snapshot.gfd") + .statusIsError() + .containsOutput("must not contain a \"..\" path segment"); + + verifyNoExportWasRequested(); + } + + /** + * The --dir option is checked as well, even though its file name is generated rather than + * supplied. + */ + @Test + public void parentReferenceInDirOptionIsRejected() { + parser + .executeAndAssertThat(command, + "export data --member=server1 --region=" + REGION + " --dir=/tmp/subdir/../../var/tmp") + .statusIsError() + .containsOutput("must not contain a \"..\" path segment"); + + verifyNoExportWasRequested(); + } + + /** + * An ordinary path is forwarded unchanged, for the member to resolve. + */ + @Test + public void ordinaryPathIsForwardedToTheMember() { + parser + .executeAndAssertThat(command, + "export data --member=server1 --region=" + REGION + " --file=/var/tmp/snapshot.gfd") + .statusIsSuccess(); + + assertThat(capturedExportPath()).isEqualTo("/var/tmp/snapshot.gfd"); + } + + /** + * Same for --dir, with the generated file name appended. + */ + @Test + public void ordinaryDirectoryIsForwardedToTheMember() { + parser + .executeAndAssertThat(command, + "export data --member=server1 --region=" + REGION + " --dir=/var/tmp") + .statusIsSuccess(); + + assertThat(capturedExportPath()).isEqualTo(new File("/var/tmp", REGION + ".gfd").getPath()); + } + + /** + * The extension check still applies. + */ + @Test + public void fileExtensionIsValidated() { + parser + .executeAndAssertThat(command, + "export data --member=server1 --region=" + REGION + " --file=/var/tmp/snapshot.txt") + .statusIsError() + .containsOutput("Invalid file type, the file extension must be \".gfd\""); + + verifyNoExportWasRequested(); + } + + /** + * Writing a file on a member's host needs a cluster write permission, alongside read access to + * the data being exported. + */ + @Test + public void exportRequiresClusterWriteAndDataRead() { + parser + .executeAndAssertThat(command, + "export data --member=server1 --region=" + REGION + " --file=/var/tmp/snapshot.gfd") + .statusIsSuccess(); + + verify(command).authorize(Resource.DATA, Operation.READ, REGION_PATH); + verify(command).authorize(Resource.CLUSTER, Operation.WRITE, ResourcePermission.ALL); + } + + /** + * Permissions are checked before any work is done, so the export is not sent to the member. + */ + @Test + public void clusterWriteIsCheckedBeforeTheExportIsSent() { + doThrow(new NotAuthorizedException("dataRead not authorized for CLUSTER:WRITE")) + .when(command).authorize(eq(Resource.CLUSTER), eq(Operation.WRITE), anyString()); + + assertThatThrownBy( + () -> command.exportData("server1", REGION_PATH, "/var/tmp/snapshot.gfd", null, false)) + .isInstanceOf(NotAuthorizedException.class) + .hasMessageContaining("CLUSTER:WRITE"); + + verifyNoExportWasRequested(); + } +} diff --git a/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataFunctionPathValidationTest.java b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataFunctionPathValidationTest.java new file mode 100644 index 000000000000..23a2a89b41fe --- /dev/null +++ b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataFunctionPathValidationTest.java @@ -0,0 +1,248 @@ +/* + * 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.functions; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.RestoreSystemProperties; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; + +import org.apache.geode.cache.Region; +import org.apache.geode.cache.execute.FunctionContext; +import org.apache.geode.cache.snapshot.RegionSnapshotService; +import org.apache.geode.cache.snapshot.SnapshotOptions.SnapshotFormat; +import org.apache.geode.distributed.internal.InternalDistributedSystem; +import org.apache.geode.distributed.internal.membership.InternalDistributedMember; +import org.apache.geode.internal.cache.InternalCache; +import org.apache.geode.internal.cache.InternalCacheForClientAccess; +import org.apache.geode.management.internal.functions.CliFunctionResult; + +/** + * Tests the directories a member permits {@code export data} to write into. + */ +public class ExportDataFunctionPathValidationTest { + + private static final String REGION = "testRegion"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties(); + + private ExportDataFunction function; + private RegionSnapshotService snapshotService; + private FunctionContext context; + + @Before + @SuppressWarnings("unchecked") + public void before() { + function = new ExportDataFunction(); + + snapshotService = mock(RegionSnapshotService.class); + Region region = mock(Region.class); + when(region.getSnapshotService()).thenReturn(snapshotService); + + InternalCacheForClientAccess clientCache = mock(InternalCacheForClientAccess.class); + when(clientCache.getRegion(REGION)).thenReturn(region); + + InternalCache cache = mock(InternalCache.class); + when(cache.getCacheForProcessingClientRequests()).thenReturn(clientCache); + + InternalDistributedMember member = mock(InternalDistributedMember.class); + when(member.getHost()).thenReturn("localhost"); + InternalDistributedSystem system = mock(InternalDistributedSystem.class); + when(system.getDistributedMember()).thenReturn(member); + when(clientCache.getDistributedSystem()).thenReturn(system); + + context = mock(FunctionContext.class); + when(context.getCache()).thenReturn(cache); + when(context.getMemberName()).thenReturn("server1"); + } + + /** Permits exports into the temporary folder, in addition to the working directory. */ + private Path permitTemporaryFolder() { + Path permitted = temporaryFolder.getRoot().toPath(); + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, permitted.toString()); + return permitted; + } + + private CliFunctionResult export(String requestedPath) throws Exception { + when(context.getArguments()) + .thenReturn(new String[] {REGION, requestedPath, Boolean.toString(false)}); + return function.executeFunction(context); + } + + private File captureExportFile() throws Exception { + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(File.class); + verify(snapshotService).save(fileCaptor.capture(), eq(SnapshotFormat.GEODE)); + return fileCaptor.getValue(); + } + + private void verifyNothingWasWritten() throws Exception { + verify(snapshotService, never()).save(any(File.class), eq(SnapshotFormat.GEODE)); + } + + /** + * An export into a permitted directory works normally. + */ + @Test + public void exportIntoAPermittedDirectorySucceeds() throws Exception { + Path permitted = permitTemporaryFolder(); + + CliFunctionResult result = export(permitted.resolve("snapshot.gfd").toString()); + + assertThat(result.isSuccessful()).isTrue(); + assertThat(captureExportFile().toPath()) + .isEqualTo(permitted.toRealPath().resolve("snapshot.gfd")); + } + + /** Sub-directories of a permitted directory are permitted too. */ + @Test + public void exportIntoASubdirectoryOfAPermittedDirectorySucceeds() throws Exception { + Path permitted = permitTemporaryFolder(); + + CliFunctionResult result = export(permitted.resolve("nested/snapshot.gfd").toString()); + + assertThat(result.isSuccessful()).isTrue(); + } + + /** + * An absolute path outside every permitted directory is refused. + */ + @Test + public void exportToAnAbsolutePathOutsideEveryPermittedDirectoryIsRefused() throws Exception { + permitTemporaryFolder(); + + assertThatThrownBy(() -> export("/var/tmp/snapshot.gfd")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not within the export directories configured for this member"); + + verifyNothingWasWritten(); + } + + /** + * A "../" element that climbs out of a permitted directory is refused: the path is canonicalized + * before it is compared, so the comparison uses the location actually written to. + */ + @Test + public void parentReferenceOutOfAPermittedDirectoryIsRefused() throws Exception { + Path permitted = permitTemporaryFolder(); + + assertThatThrownBy(() -> export(permitted.resolve("../escaped.gfd").toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not within the export directories configured for this member"); + + verifyNothingWasWritten(); + } + + /** + * A "../" element that stays inside a permitted directory is honoured, and is resolved before + * the write, so no "../" reaches the snapshot service. + */ + @Test + public void parentReferenceInsideAPermittedDirectoryIsResolvedBeforeTheWrite() + throws Exception { + Path permitted = permitTemporaryFolder(); + + CliFunctionResult result = + export(permitted.resolve("nested/../snapshot.gfd").toString()); + + assertThat(result.isSuccessful()).isTrue(); + File exportFile = captureExportFile(); + assertThat(exportFile.getPath()).doesNotContain(".."); + assertThat(exportFile.toPath()).isEqualTo(permitted.toRealPath().resolve("snapshot.gfd")); + } + + /** + * With no configuration, the only permitted directory is the member's working directory - which + * is where a relative export path lands. + */ + @Test + public void withoutConfigurationOnlyTheMemberWorkingDirectoryIsPermitted() throws Exception { + System.clearProperty(EXPORT_DATA_DIRS_PROPERTY); + Path workingDir = Paths.get(System.getProperty("user.dir")).toRealPath(); + + assertThat(export(workingDir.resolve("snapshot.gfd").toString()).isSuccessful()).isTrue(); + + assertThatThrownBy(() -> export(temporaryFolder.getRoot().toPath().resolve("x.gfd").toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + /** + * The member's working directory stays permitted when other directories are configured, so a + * relative export path keeps working. + */ + @Test + public void theWorkingDirectoryRemainsPermittedWhenOtherDirectoriesAreConfigured() + throws Exception { + permitTemporaryFolder(); + + assertThat(export("snapshot.gfd").isSuccessful()).isTrue(); + assertThat(captureExportFile().toPath()) + .isEqualTo(Paths.get(System.getProperty("user.dir")).toRealPath().resolve("snapshot.gfd")); + } + + /** + * More than one directory can be permitted, which is how a deployment that exports to a + * dedicated backup location configures the member. + */ + @Test + public void severalDirectoriesCanBePermitted() throws Exception { + File backup = temporaryFolder.newFolder("backup"); + File other = temporaryFolder.newFolder("other"); + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, + backup.getAbsolutePath() + File.pathSeparator + other.getAbsolutePath()); + + assertThat(export(new File(backup, "snapshot.gfd").getPath()).isSuccessful()).isTrue(); + assertThat(export(new File(other, "snapshot.gfd").getPath()).isSuccessful()).isTrue(); + + assertThatThrownBy(() -> export(temporaryFolder.getRoot().toPath().resolve("x.gfd").toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + /** + * A directory whose name merely starts with a permitted directory's name is not inside it - the + * check compares path elements, not string prefixes. + */ + @Test + public void aSiblingDirectoryWithAMatchingNamePrefixIsNotPermitted() throws Exception { + File permitted = temporaryFolder.newFolder("exports"); + File sibling = temporaryFolder.newFolder("exports-archive"); + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, permitted.getAbsolutePath()); + + assertThatThrownBy(() -> export(new File(sibling, "snapshot.gfd").getPath())) + .isInstanceOf(IllegalArgumentException.class); + + verifyNothingWasWritten(); + } +} diff --git a/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java b/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java index ebbd8c950c43..0199985546af 100644 --- a/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java +++ b/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java @@ -135,7 +135,7 @@ private static void init() { // Data Commands createTestCommand("rebalance --include-region=RegionA", ResourcePermissions.DATA_MANAGE); createTestCommand("export data --region=RegionA --file=export.txt --member=exportMember", - regionARead); + regionARead, ResourcePermissions.CLUSTER_WRITE); createTestCommand("import data --region=RegionA --file=import.txt --member=importMember", regionAWrite); createTestCommand("put --key=key1 --value=value1 --region=RegionA", regionAWrite);