diff --git a/docs/changelog/126852.yaml b/docs/changelog/126852.yaml new file mode 100644 index 0000000000000..e2fe44b24ed69 --- /dev/null +++ b/docs/changelog/126852.yaml @@ -0,0 +1,5 @@ +pr: 126852 +summary: "Validation checks on paths allowed for 'files' entitlements. Restrict the paths we allow access to, forbidding plugins to specify/request entitlements for reading or writing to specific protected directories." +area: Infra/Core +type: enhancement +issues: [] diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java index 8c13fc398e5d4..911eff0615f73 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java @@ -11,6 +11,7 @@ import org.elasticsearch.core.Booleans; import org.elasticsearch.core.PathUtils; +import org.elasticsearch.core.Strings; import org.elasticsearch.core.internal.provider.ProviderLocator; import org.elasticsearch.entitlement.bootstrap.EntitlementBootstrap; import org.elasticsearch.entitlement.bridge.EntitlementChecker; @@ -20,6 +21,7 @@ import org.elasticsearch.entitlement.instrumentation.MethodKey; import org.elasticsearch.entitlement.instrumentation.Transformer; import org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementChecker; +import org.elasticsearch.entitlement.runtime.policy.FileAccessTree; import org.elasticsearch.entitlement.runtime.policy.PathLookup; import org.elasticsearch.entitlement.runtime.policy.Policy; import org.elasticsearch.entitlement.runtime.policy.PolicyManager; @@ -56,6 +58,7 @@ import java.nio.file.attribute.FileAttribute; import java.nio.file.spi.FileSystemProvider; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -315,6 +318,16 @@ private static PolicyManager createPolicyManager() { ) ) ); + + validateFilesEntitlements( + pluginPolicies, + pathLookup, + bootstrapArgs.configDir(), + bootstrapArgs.pluginsDir(), + bootstrapArgs.modulesDir(), + bootstrapArgs.libDir() + ); + return new PolicyManager( serverPolicy, agentEntitlements, @@ -328,6 +341,81 @@ private static PolicyManager createPolicyManager() { ); } + private static Set pathSet(Path... paths) { + return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet()); + } + + // package visible for tests + static void validateFilesEntitlements( + Map pluginPolicies, + PathLookup pathLookup, + Path configDir, + Path pluginsDir, + Path modulesDir, + Path libDir + ) { + var readAccessForbidden = pathSet(pluginsDir, modulesDir, libDir); + var writeAccessForbidden = pathSet(configDir); + for (var pluginPolicy : pluginPolicies.entrySet()) { + for (var scope : pluginPolicy.getValue().scopes()) { + var filesEntitlement = scope.entitlements() + .stream() + .filter(x -> x instanceof FilesEntitlement) + .map(x -> ((FilesEntitlement) x)) + .findFirst(); + if (filesEntitlement.isPresent()) { + var fileAccessTree = FileAccessTree.withoutExclusivePaths(filesEntitlement.get(), pathLookup, null); + validateReadFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, readAccessForbidden); + validateWriteFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, writeAccessForbidden); + } + } + } + } + + private static IllegalArgumentException buildValidationException( + String componentName, + String moduleName, + Path forbiddenPath, + FilesEntitlement.Mode mode + ) { + return new IllegalArgumentException( + Strings.format( + "policy for module [%s] in [%s] has an invalid file entitlement. Any path under [%s] is forbidden for mode [%s].", + moduleName, + componentName, + forbiddenPath, + mode + ) + ); + } + + private static void validateReadFilesEntitlements( + String componentName, + String moduleName, + FileAccessTree fileAccessTree, + Set readForbiddenPaths + ) { + + for (Path forbiddenPath : readForbiddenPaths) { + if (fileAccessTree.canRead(forbiddenPath)) { + throw buildValidationException(componentName, moduleName, forbiddenPath, READ); + } + } + } + + private static void validateWriteFilesEntitlements( + String componentName, + String moduleName, + FileAccessTree fileAccessTree, + Set writeForbiddenPaths + ) { + for (Path forbiddenPath : writeForbiddenPaths) { + if (fileAccessTree.canWrite(forbiddenPath)) { + throw buildValidationException(componentName, moduleName, forbiddenPath, READ_WRITE); + } + } + } + private static Path getUserHome() { String userHome = System.getProperty("user.home"); if (userHome == null) { diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java index 0e35ef0f0c72e..cae5ddda6eb03 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java @@ -111,12 +111,9 @@ static void validateExclusivePaths(List exclusivePaths) { private final String[] readPaths; private final String[] writePaths; - private FileAccessTree( + private static String[] buildUpdatedAndSortedExclusivePaths( String componentName, String moduleName, - FilesEntitlement filesEntitlement, - PathLookup pathLookup, - Path componentPath, List exclusivePaths ) { List updatedExclusivePaths = new ArrayList<>(); @@ -125,7 +122,11 @@ private FileAccessTree( updatedExclusivePaths.add(exclusivePath.path()); } } + updatedExclusivePaths.sort(PATH_ORDER); + return updatedExclusivePaths.toArray(new String[0]); + } + private FileAccessTree(FilesEntitlement filesEntitlement, PathLookup pathLookup, Path componentPath, String[] sortedExclusivePaths) { List readPaths = new ArrayList<>(); List writePaths = new ArrayList<>(); BiConsumer addPath = (path, mode) -> { @@ -177,11 +178,10 @@ private FileAccessTree( Path jdk = Paths.get(System.getProperty("java.home")); addPathAndMaybeLink.accept(jdk.resolve("conf"), Mode.READ); - updatedExclusivePaths.sort(PATH_ORDER); readPaths.sort(PATH_ORDER); writePaths.sort(PATH_ORDER); - this.exclusivePaths = updatedExclusivePaths.toArray(new String[0]); + this.exclusivePaths = sortedExclusivePaths; this.readPaths = pruneSortedPaths(readPaths).toArray(new String[0]); this.writePaths = pruneSortedPaths(writePaths).toArray(new String[0]); } @@ -203,7 +203,7 @@ static List pruneSortedPaths(List paths) { return prunedReadPaths; } - public static FileAccessTree of( + static FileAccessTree of( String componentName, String moduleName, FilesEntitlement filesEntitlement, @@ -211,14 +211,30 @@ public static FileAccessTree of( @Nullable Path componentPath, List exclusivePaths ) { - return new FileAccessTree(componentName, moduleName, filesEntitlement, pathLookup, componentPath, exclusivePaths); + return new FileAccessTree( + filesEntitlement, + pathLookup, + componentPath, + buildUpdatedAndSortedExclusivePaths(componentName, moduleName, exclusivePaths) + ); + } + + /** + * A special factory method to create a FileAccessTree with no ExclusivePaths, e.g. for quick validation or for default file access + */ + public static FileAccessTree withoutExclusivePaths( + FilesEntitlement filesEntitlement, + PathLookup pathLookup, + @Nullable Path componentPath + ) { + return new FileAccessTree(filesEntitlement, pathLookup, componentPath, new String[0]); } - boolean canRead(Path path) { + public boolean canRead(Path path) { return checkPath(normalizePath(path), readPaths); } - boolean canWrite(Path path) { + public boolean canWrite(Path path) { return checkPath(normalizePath(path), writePaths); } diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java index 75e098a95902d..71c0f9909d3e3 100644 --- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java +++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java @@ -101,18 +101,13 @@ public Stream getEntitlements(Class entitlementCla } } - private FileAccessTree getDefaultFileAccess(String componentName, Path componentPath) { - return FileAccessTree.of(componentName, UNKNOWN_COMPONENT_NAME, FilesEntitlement.EMPTY, pathLookup, componentPath, List.of()); + private FileAccessTree getDefaultFileAccess(Path componentPath) { + return FileAccessTree.withoutExclusivePaths(FilesEntitlement.EMPTY, pathLookup, componentPath); } // pkg private for testing ModuleEntitlements defaultEntitlements(String componentName, Path componentPath, String moduleName) { - return new ModuleEntitlements( - componentName, - Map.of(), - getDefaultFileAccess(componentName, componentPath), - getLogger(componentName, moduleName) - ); + return new ModuleEntitlements(componentName, Map.of(), getDefaultFileAccess(componentPath), getLogger(componentName, moduleName)); } // pkg private for testing diff --git a/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java new file mode 100644 index 0000000000000..80c7d0d77d449 --- /dev/null +++ b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.entitlement.initialization; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.entitlement.runtime.policy.PathLookup; +import org.elasticsearch.entitlement.runtime.policy.Policy; +import org.elasticsearch.entitlement.runtime.policy.Scope; +import org.elasticsearch.entitlement.runtime.policy.entitlements.CreateClassLoaderEntitlement; +import org.elasticsearch.entitlement.runtime.policy.entitlements.FilesEntitlement; +import org.elasticsearch.test.ESTestCase; +import org.junit.BeforeClass; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.both; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.startsWith; + +public class EntitlementInitializationTests extends ESTestCase { + + private static PathLookup TEST_PATH_LOOKUP; + + private static Path TEST_CONFIG_DIR; + + private static Path TEST_PLUGINS_DIR; + private static Path TEST_MODULES_DIR; + private static Path TEST_LIBS_DIR; + + @BeforeClass + public static void beforeClass() { + try { + Path testBaseDir = createTempDir().toAbsolutePath(); + TEST_CONFIG_DIR = testBaseDir.resolve("config"); + TEST_PLUGINS_DIR = testBaseDir.resolve("plugins"); + TEST_MODULES_DIR = testBaseDir.resolve("modules"); + TEST_LIBS_DIR = testBaseDir.resolve("libs"); + + TEST_PATH_LOOKUP = new PathLookup( + testBaseDir.resolve("user/home"), + TEST_CONFIG_DIR, + new Path[] { testBaseDir.resolve("data1"), testBaseDir.resolve("data2") }, + new Path[] { testBaseDir.resolve("shared1"), testBaseDir.resolve("shared2") }, + testBaseDir.resolve("temp"), + Settings.EMPTY::getValues + ); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public void testValidationPass() { + var policy = new Policy( + "plugin", + List.of( + new Scope( + "module1", + List.of( + new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_CONFIG_DIR, FilesEntitlement.Mode.READ))), + new CreateClassLoaderEntitlement() + ) + ) + ) + ); + EntitlementInitialization.validateFilesEntitlements( + Map.of("plugin", policy), + TEST_PATH_LOOKUP, + TEST_CONFIG_DIR, + TEST_PLUGINS_DIR, + TEST_MODULES_DIR, + TEST_LIBS_DIR + ); + } + + public void testValidationFailForRead() { + var policy = new Policy( + "plugin", + List.of( + new Scope( + "module2", + List.of( + new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_PLUGINS_DIR, FilesEntitlement.Mode.READ))), + new CreateClassLoaderEntitlement() + ) + ) + ) + ); + + var ex = expectThrows( + IllegalArgumentException.class, + () -> EntitlementInitialization.validateFilesEntitlements( + Map.of("plugin", policy), + TEST_PATH_LOOKUP, + TEST_CONFIG_DIR, + TEST_PLUGINS_DIR, + TEST_MODULES_DIR, + TEST_LIBS_DIR + ) + ); + assertThat( + ex.getMessage(), + both(startsWith("policy for module [module2] in [plugin] has an invalid file entitlement")).and( + endsWith("is forbidden for mode [READ].") + ) + ); + + // check fails for mode READ_WRITE too + var policy2 = new Policy( + "plugin", + List.of( + new Scope( + "module1", + List.of( + new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_LIBS_DIR, FilesEntitlement.Mode.READ_WRITE))), + new CreateClassLoaderEntitlement() + ) + ) + ) + ); + + ex = expectThrows( + IllegalArgumentException.class, + () -> EntitlementInitialization.validateFilesEntitlements( + Map.of("plugin2", policy2), + TEST_PATH_LOOKUP, + TEST_CONFIG_DIR, + TEST_PLUGINS_DIR, + TEST_MODULES_DIR, + TEST_LIBS_DIR + ) + ); + assertThat( + ex.getMessage(), + both(startsWith("policy for module [module1] in [plugin2] has an invalid file entitlement")).and( + endsWith("is forbidden for mode [READ].") + ) + ); + } + + public void testValidationFailForWrite() { + var policy = new Policy( + "plugin", + List.of( + new Scope( + "module1", + List.of( + new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_CONFIG_DIR, FilesEntitlement.Mode.READ_WRITE))), + new CreateClassLoaderEntitlement() + ) + ) + ) + ); + + var ex = expectThrows( + IllegalArgumentException.class, + () -> EntitlementInitialization.validateFilesEntitlements( + Map.of("plugin", policy), + TEST_PATH_LOOKUP, + TEST_CONFIG_DIR, + TEST_PLUGINS_DIR, + TEST_MODULES_DIR, + TEST_LIBS_DIR + ) + ); + assertThat( + ex.getMessage(), + both(startsWith("policy for module [module1] in [plugin] has an invalid file entitlement")).and( + endsWith("is forbidden for mode [READ_WRITE].") + ) + ); + } +} diff --git a/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml b/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml index a96c07a2d3b64..6c9edf6a5cf63 100644 --- a/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml +++ b/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml @@ -6,6 +6,6 @@ org.elasticsearch.ingest.geoip: mode: read com.maxmind.db: - files: - - relative_path: "ingest-geoip/" - relative_to: "config" - mode: "read_write" + - relative_path: "ingest-geoip" + relative_to: config + mode: read diff --git a/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml b/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml index 21d5fed283531..a036cab30d31b 100644 --- a/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml +++ b/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml @@ -7,6 +7,6 @@ ALL-UNNAMED: properties: - hadoop.home.dir - files: - - relative_path: "repository-hdfs/" + - relative_path: "repository-hdfs" relative_to: config - mode: read_write + mode: read diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java index 64fbc6176a31c..14178e84969ed 100644 --- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java +++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java @@ -571,7 +571,7 @@ public void test92ElasticsearchNodeCliPackaging() throws Exception { } public void test93ElasticsearchNodeCustomDataPathAndNotEsHomeWorkDir() throws Exception { - Path relativeDataPath = installation.data.relativize(installation.home); + Path relativeDataPath = getRootTempDir().resolve("custom_data"); append(installation.config("elasticsearch.yml"), "path.data: " + relativeDataPath); sh.setWorkingDirectory(getRootTempDir());