Skip to content

Commit 0cf1a9a

Browse files
authored
[Entitlements] Forbidden paths (elastic#138927) (elastic#138957)
Elasticsearch has a need to protect some special files and paths, guaranteeing that nobody (not even server code) could read or write them. This PR adds protection for these forbidden paths by leveraging the existing "exclusive access" mechanism.
1 parent c4e0e17 commit 0cf1a9a

File tree

9 files changed

+280
-58
lines changed

9 files changed

+280
-58
lines changed

libs/entitlement/qa/entitlement-test-plugin/src/main/java/org/elasticsearch/entitlement/qa/test/FileCheckActions.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,42 @@ static void writeAccessConfigDirectory(Environment environment) throws IOExcepti
591591
Files.createFile(file);
592592
}
593593

594+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
595+
static void readAccessForbiddenJvmOptionsFile(Environment environment) throws IOException {
596+
var file = environment.configDir().resolve("jvm.options");
597+
Files.readAllBytes(file);
598+
}
599+
600+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
601+
static void readAccessForbiddenElasticsearchYmlFile(Environment environment) throws IOException {
602+
var file = environment.configDir().resolve("elasticsearch.yml");
603+
Files.readAllBytes(file);
604+
}
605+
606+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
607+
static void readAccessForbiddenJvmOptionsDirectory(Environment environment) throws IOException {
608+
var file = environment.configDir().resolve("jvm.options.d");
609+
Files.isDirectory(file);
610+
}
611+
612+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
613+
static void writeAccessForbiddenJvmOptionsFile(Environment environment) throws IOException {
614+
var file = environment.configDir().resolve("jvm.options");
615+
Files.newBufferedWriter(file).close();
616+
}
617+
618+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
619+
static void writeAccessForbiddenElasticsearchYmlFile(Environment environment) throws IOException {
620+
var file = environment.configDir().resolve("elasticsearch.yml");
621+
Files.newBufferedWriter(file).close();
622+
}
623+
624+
@EntitlementTest(expectedAccess = ALWAYS_DENIED)
625+
static void writerAccessForbiddenJvmOptionsDirectory(Environment environment) throws IOException {
626+
var file = environment.configDir().resolve("jvm.options.d").resolve("foo");
627+
Files.newBufferedWriter(file).close();
628+
}
629+
594630
@EntitlementTest(expectedAccess = ALWAYS_ALLOWED)
595631
static void readAccessSourcePath() throws URISyntaxException {
596632
var sourcePath = Paths.get(EntitlementTestPlugin.class.getProtectionDomain().getCodeSource().getLocation().toURI());

libs/entitlement/qa/entitlement-test-plugin/src/main/java/org/elasticsearch/entitlement/qa/test/RestEntitlementsCheckAction.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import static java.util.Map.entry;
3636
import static org.elasticsearch.entitlement.qa.test.EntitlementTest.ExpectedAccess.ALWAYS_ALLOWED;
37+
import static org.elasticsearch.entitlement.qa.test.EntitlementTest.ExpectedAccess.ALWAYS_DENIED;
3738
import static org.elasticsearch.entitlement.qa.test.EntitlementTest.ExpectedAccess.PLUGINS;
3839
import static org.elasticsearch.rest.RestRequest.Method.GET;
3940

@@ -154,6 +155,14 @@ public static Set<String> getAlwaysAllowedCheckActions() {
154155
.collect(Collectors.toSet());
155156
}
156157

158+
public static Set<String> getAlwaysDeniedCheckActions() {
159+
return checkActions.entrySet()
160+
.stream()
161+
.filter(kv -> kv.getValue().expectedAccess().equals(ALWAYS_DENIED))
162+
.map(Map.Entry::getKey)
163+
.collect(Collectors.toSet());
164+
}
165+
157166
public static Set<String> getDeniableCheckActions() {
158167
return checkActions.entrySet()
159168
.stream()

libs/entitlement/qa/src/javaRestTest/java/org/elasticsearch/entitlement/qa/AbstractEntitlementsIT.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ public abstract class AbstractEntitlementsIT extends ESRestTestCase {
4646
Map.of("path", tempDir.resolve("read_dir"), "mode", "read"),
4747
Map.of("path", tempDir.resolve("read_write_dir"), "mode", "read_write"),
4848
Map.of("path", tempDir.resolve("read_file"), "mode", "read"),
49-
Map.of("path", tempDir.resolve("read_write_file"), "mode", "read_write")
49+
Map.of("path", tempDir.resolve("read_write_file"), "mode", "read_write"),
50+
// Try to grant explicit access to forbidden files (and test this is not possible in any case)
51+
Map.of("relative_path", "jvm.options.d", "relative_to", "config", "mode", "read_write"),
52+
Map.of("relative_path", "jvm.options", "relative_to", "config", "mode", "read_write"),
53+
Map.of("relative_path", "elasticsearch.yml", "relative_to", "config", "mode", "read_write")
5054
)
5155
)
5256
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.entitlement.qa;
11+
12+
import com.carrotsearch.randomizedtesting.annotations.Name;
13+
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
14+
15+
import org.elasticsearch.entitlement.qa.test.RestEntitlementsCheckAction;
16+
import org.junit.ClassRule;
17+
18+
/**
19+
* Actions denied even when we allow them via explicit entitlements
20+
*/
21+
public class EntitlementsAlwaysDeniedIT extends AbstractEntitlementsIT {
22+
23+
@ClassRule
24+
public static EntitlementsTestRule testRule = new EntitlementsTestRule(true, ALLOWED_TEST_ENTITLEMENTS);
25+
26+
public EntitlementsAlwaysDeniedIT(@Name("actionName") String actionName) {
27+
super(actionName, false);
28+
}
29+
30+
@ParametersFactory
31+
public static Iterable<Object[]> data() {
32+
return RestEntitlementsCheckAction.getAlwaysDeniedCheckActions().stream().map(action -> new Object[] { action }).toList();
33+
}
34+
35+
@Override
36+
protected String getTestRestCluster() {
37+
return testRule.cluster.getHttpAddresses();
38+
}
39+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.entitlement.qa;
11+
12+
import com.carrotsearch.randomizedtesting.annotations.Name;
13+
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
14+
15+
import org.elasticsearch.entitlement.qa.test.RestEntitlementsCheckAction;
16+
import org.junit.ClassRule;
17+
18+
/**
19+
* Actions denied even when we allow them via explicit entitlements
20+
*/
21+
public class EntitlementsAlwaysDeniedNonModularIT extends AbstractEntitlementsIT {
22+
23+
@ClassRule
24+
public static EntitlementsTestRule testRule = new EntitlementsTestRule(false, ALLOWED_TEST_ENTITLEMENTS);
25+
26+
public EntitlementsAlwaysDeniedNonModularIT(@Name("actionName") String actionName) {
27+
super(actionName, false);
28+
}
29+
30+
@ParametersFactory
31+
public static Iterable<Object[]> data() {
32+
return RestEntitlementsCheckAction.getAlwaysDeniedCheckActions().stream().map(action -> new Object[] { action }).toList();
33+
}
34+
35+
@Override
36+
protected String getTestRestCluster() {
37+
return testRule.cluster.getHttpAddresses();
38+
}
39+
}

libs/entitlement/src/main/java/org/elasticsearch/entitlement/bootstrap/FilesEntitlementsValidation.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ static void validate(Map<String, Policy> pluginPolicies, PathLookup pathLookup)
4545
.map(x -> ((FilesEntitlement) x))
4646
.findFirst();
4747
if (filesEntitlement.isPresent()) {
48-
var fileAccessTree = FileAccessTree.withoutExclusivePaths(filesEntitlement.get(), pathLookup, List.of());
48+
var fileAccessTree = FileAccessTree.withoutExclusivePaths(filesEntitlement.get(), pathLookup, List.of(), List.of());
4949
validateReadFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, readAccessForbidden);
5050
validateWriteFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, writeAccessForbidden);
5151
}

libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
* Permission is granted if both:
9191
* <ul>
9292
* <li>
93-
* there is no match in exclusivePaths, and
93+
* there is no match in {@link FileAccessTree#forbiddenPaths}, and
9494
* </li>
9595
* <li>
9696
* there is a match in the array corresponding to the desired operation (read or write).
@@ -187,10 +187,11 @@ static char separatorChar() {
187187

188188
private final FileAccessTreeComparison comparison;
189189
/**
190-
* lists paths that are forbidden for this component+module because some other component has granted exclusive access to one of its
191-
* modules
190+
* lists paths that are forbidden for this component+module
191+
* A path can be forbidden unconditionally, or because some other component has granted exclusive
192+
* access to one of its modules
192193
*/
193-
private final String[] exclusivePaths;
194+
private final String[] forbiddenPaths;
194195
/**
195196
* lists paths for which the component has granted read or read_write access to the module
196197
*/
@@ -200,20 +201,21 @@ static char separatorChar() {
200201
*/
201202
private final String[] writePaths;
202203

203-
private static String[] buildUpdatedAndSortedExclusivePaths(
204+
private static String[] buildFinalSortedForbiddenPaths(
204205
String componentName,
205206
String moduleName,
206207
List<ExclusivePath> exclusivePaths,
208+
Collection<String> forbiddenPaths,
207209
FileAccessTreeComparison comparison
208210
) {
209-
List<String> updatedExclusivePaths = new ArrayList<>();
211+
List<String> finalForbiddenPathList = new ArrayList<>(forbiddenPaths);
210212
for (ExclusivePath exclusivePath : exclusivePaths) {
211213
if (exclusivePath.componentName().equals(componentName) == false || exclusivePath.moduleNames().contains(moduleName) == false) {
212-
updatedExclusivePaths.add(exclusivePath.path());
214+
finalForbiddenPathList.add(exclusivePath.path());
213215
}
214216
}
215-
updatedExclusivePaths.sort(comparison.pathComparator());
216-
return updatedExclusivePaths.toArray(new String[0]);
217+
finalForbiddenPathList.sort(comparison.pathComparator());
218+
return finalForbiddenPathList.toArray(new String[0]);
217219
}
218220

219221
FileAccessTree(
@@ -276,14 +278,14 @@ private static String[] buildUpdatedAndSortedExclusivePaths(
276278
readPaths.sort(comparison.pathComparator());
277279
writePaths.sort(comparison.pathComparator());
278280

279-
this.exclusivePaths = sortedExclusivePaths;
281+
this.forbiddenPaths = sortedExclusivePaths;
280282
this.readPaths = pruneSortedPaths(readPaths, comparison).toArray(new String[0]);
281283
this.writePaths = pruneSortedPaths(writePaths, comparison).toArray(new String[0]);
282284

283285
logger.debug(
284286
() -> Strings.format(
285-
"Created FileAccessTree with paths: exclusive [%s], read [%s], write [%s]",
286-
String.join(",", this.exclusivePaths),
287+
"Created FileAccessTree with paths: forbidden [%s], read [%s], write [%s]",
288+
String.join(",", this.forbiddenPaths),
287289
String.join(",", this.readPaths),
288290
String.join(",", this.writePaths)
289291
)
@@ -313,13 +315,14 @@ static FileAccessTree of(
313315
FilesEntitlement filesEntitlement,
314316
PathLookup pathLookup,
315317
Collection<Path> componentPaths,
316-
List<ExclusivePath> exclusivePaths
318+
List<ExclusivePath> exclusivePaths,
319+
Collection<String> forbiddenPaths
317320
) {
318321
return new FileAccessTree(
319322
filesEntitlement,
320323
pathLookup,
321324
componentPaths,
322-
buildUpdatedAndSortedExclusivePaths(componentName, moduleName, exclusivePaths, DEFAULT_COMPARISON),
325+
buildFinalSortedForbiddenPaths(componentName, moduleName, exclusivePaths, forbiddenPaths, DEFAULT_COMPARISON),
323326
DEFAULT_COMPARISON
324327
);
325328
}
@@ -330,9 +333,16 @@ static FileAccessTree of(
330333
public static FileAccessTree withoutExclusivePaths(
331334
FilesEntitlement filesEntitlement,
332335
PathLookup pathLookup,
336+
Collection<String> forbiddenPaths,
333337
Collection<Path> componentPaths
334338
) {
335-
return new FileAccessTree(filesEntitlement, pathLookup, componentPaths, new String[0], DEFAULT_COMPARISON);
339+
return new FileAccessTree(
340+
filesEntitlement,
341+
pathLookup,
342+
componentPaths,
343+
forbiddenPaths.stream().sorted(DEFAULT_COMPARISON.pathComparator()).toArray(String[]::new),
344+
DEFAULT_COMPARISON
345+
);
336346
}
337347

338348
public boolean canRead(Path path) {
@@ -368,8 +378,8 @@ private boolean checkPath(String path, String[] paths) {
368378
return false;
369379
}
370380

371-
int endx = Arrays.binarySearch(exclusivePaths, path, comparison.pathComparator());
372-
if (endx < -1 && comparison.isParent(exclusivePaths[-endx - 2], path) || endx >= 0) {
381+
int endx = Arrays.binarySearch(forbiddenPaths, path, comparison.pathComparator());
382+
if (endx < -1 && comparison.isParent(forbiddenPaths[-endx - 2], path) || endx >= 0) {
373383
return false;
374384
}
375385

libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Logger logger(Class<?> requestingClass) {
151151
}
152152

153153
private FileAccessTree getDefaultFileAccess(Collection<Path> componentPaths) {
154-
return FileAccessTree.withoutExclusivePaths(FilesEntitlement.EMPTY, pathLookup, componentPaths);
154+
return FileAccessTree.withoutExclusivePaths(FilesEntitlement.EMPTY, pathLookup, forbiddenPaths, componentPaths);
155155
}
156156

157157
// pkg private for testing
@@ -176,7 +176,7 @@ ModuleEntitlements policyEntitlements(
176176
componentName,
177177
moduleName,
178178
entitlements.stream().collect(groupingBy(Entitlement::getClass)),
179-
FileAccessTree.of(componentName, moduleName, filesEntitlement, pathLookup, componentPaths, exclusivePaths)
179+
FileAccessTree.of(componentName, moduleName, filesEntitlement, pathLookup, componentPaths, exclusivePaths, forbiddenPaths)
180180
);
181181
}
182182

@@ -226,6 +226,20 @@ private static Set<Module> findSystemLayerModules() {
226226
*/
227227
private final List<ExclusivePath> exclusivePaths;
228228

229+
/**
230+
* Paths for which we never want to allow access to, from any component
231+
*/
232+
private final Set<String> forbiddenPaths;
233+
234+
private static Set<String> createForbiddenPaths(PathLookup pathLookup) {
235+
return pathLookup.getBaseDirPaths(PathLookup.BaseDir.CONFIG)
236+
.flatMap(
237+
baseDir -> Stream.of(baseDir.resolve("elasticsearch.yml"), baseDir.resolve("jvm.options"), baseDir.resolve("jvm.options.d"))
238+
)
239+
.map(FileAccessTree::normalizePath)
240+
.collect(Collectors.toSet());
241+
}
242+
229243
public PolicyManager(
230244
Policy serverPolicy,
231245
List<Entitlement> apmAgentEntitlements,
@@ -260,6 +274,7 @@ public PolicyManager(
260274
);
261275
FileAccessTree.validateExclusivePaths(exclusivePaths, FileAccessTree.DEFAULT_COMPARISON);
262276
this.exclusivePaths = exclusivePaths;
277+
this.forbiddenPaths = createForbiddenPaths(pathLookup);
263278
}
264279

265280
private static Map<String, List<Entitlement>> buildScopeEntitlementsMap(Policy policy) {

0 commit comments

Comments
 (0)