Skip to content
Merged
5 changes: 5 additions & 0 deletions docs/changelog/126852.yaml
Original file line number Diff line number Diff line change
@@ -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: []
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +57,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;
Expand Down Expand Up @@ -315,6 +317,16 @@ private static PolicyManager createPolicyManager() {
)
)
);

validateFilesEntitlements(
pluginPolicies,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered to add validation for server and agent entitlements here too, but decided it's not worth it. Let me know if you thing those should be validated too.

pathLookup,
bootstrapArgs.configDir(),
bootstrapArgs.pluginsDir(),
bootstrapArgs.modulesDir(),
bootstrapArgs.libDir()
);

return new PolicyManager(
serverPolicy,
agentEntitlements,
Expand All @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
);
}

private static Set<Path> pathSet(Path... paths) {
return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
}

// package visible for tests
static void validateFilesEntitlements(
Map<String, Policy> pluginPolicies,
PathLookup pathLookup,
Path configDir,
Path pluginsDir,
Path modulesDir,
Path libDir
) {
var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
var pluginWriteAccessForbidden = pathSet(configDir);
for (var pluginPolicy : pluginPolicies.entrySet()) {
List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
.flatMap(x -> x.resolvePaths(pathLookup))
.toList();
validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
}
}

private static Stream<FileData> getFileDataStream(Policy policy) {
return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
}

private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
}

private static void validateLayerFilesEntitlements(
String layerName,
List<Path> paths,
Set<Path> forbiddenPaths,
FilesEntitlement.Mode mode
) {
for (var path : paths) {
for (Path forbiddenPath : forbiddenPaths) {
if (path.startsWith(forbiddenPath)) {
throw new IllegalArgumentException(
Strings.format(
"policy for [%s] cannot contain a file entitlement for [%s]. Any path under [%s] is forbidden for mode [%s].",
layerName,
path,
forbiddenPath,
mode
)
);
}
}
}
}

private static Path getUserHome() {
String userHome = System.getProperty("user.home");
if (userHome == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ static List<String> pruneSortedPaths(List<String> paths) {
return prunedReadPaths;
}

public static FileAccessTree of(
static FileAccessTree of(
String componentName,
String moduleName,
FilesEntitlement filesEntitlement,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* 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 [plugin] cannot contain a file entitlement for")).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 [plugin2] cannot contain a 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 [plugin] cannot contain a file entitlement")).and(endsWith("is forbidden for mode [READ_WRITE]."))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌶️

Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading