Skip to content

Commit 972f696

Browse files
huaxingaopeter-toth
andcommitted
Validate executor local directories and block ids in the external shuffle service
Add input validation to the external shuffle service for the executor-supplied values it turns into filesystem paths. * Block ids passed to removeBlocks must be plain file names (no path separator, NUL, "." or ".."). A malformed id is skipped instead of being used to build a path, and a batched call continues with the remaining ids. * The localDirs an executor reports at RegisterExecutor are validated once at the RPC boundary in ExternalBlockHandler, before they are handed to either the block resolver or the push-based-shuffle merge manager. The check lives in a small LocalDirValidator built from the host's configured local-directory roots: each entry must canonicalize to a path contained under one of those roots and, when app scoping is required, within the registering application's own directory. The roots are supplied per host through ExternalBlockHandler constructor overloads: YarnShuffleService passes the NodeManager's local directories (yarn.nodemanager.local-dirs) with app scoping, and ExternalShuffleService passes the configured local directories (Utils.getConfiguredLocalDirs) without app scoping. Block-id validation is covered by ExternalShuffleBlockResolverSuite, local-directory validation by LocalDirValidatorSuite, and the standalone wiring by ExternalShuffleServiceLocalDirsSuite; YarnShuffleServiceSuite fixtures are updated so registered localDirs sit under the NM local dirs and contain the appId. Co-authored-by: Peter Toth <peter.toth@gmail.com>
1 parent e3ed125 commit 972f696

9 files changed

Lines changed: 498 additions & 28 deletions

File tree

common/network-shuffle/src/main/java/org/apache/spark/network/shuffle/ExternalBlockHandler.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public class ExternalBlockHandler extends RpcHandler
7878
private final OneForOneStreamManager streamManager;
7979
private final ShuffleMetrics metrics;
8080
private final MergedShuffleFileManager mergeManager;
81+
private final LocalDirValidator localDirValidator;
8182

8283
public ExternalBlockHandler(TransportConf conf, File registeredExecutorFile)
8384
throws IOException {
@@ -86,6 +87,17 @@ public ExternalBlockHandler(TransportConf conf, File registeredExecutorFile)
8687
new NoOpMergedShuffleFileManager(conf, null));
8788
}
8889

90+
public ExternalBlockHandler(
91+
TransportConf conf,
92+
File registeredExecutorFile,
93+
String[] allowedLocalDirs,
94+
boolean requireAppScopedLocalDirs) throws IOException {
95+
this(new OneForOneStreamManager(),
96+
new ExternalShuffleBlockResolver(conf, registeredExecutorFile),
97+
new NoOpMergedShuffleFileManager(conf, null),
98+
new LocalDirValidator(allowedLocalDirs, requireAppScopedLocalDirs));
99+
}
100+
89101
public ExternalBlockHandler(
90102
TransportConf conf,
91103
File registeredExecutorFile,
@@ -94,11 +106,32 @@ public ExternalBlockHandler(
94106
new ExternalShuffleBlockResolver(conf, registeredExecutorFile), mergeManager);
95107
}
96108

109+
public ExternalBlockHandler(
110+
TransportConf conf,
111+
File registeredExecutorFile,
112+
MergedShuffleFileManager mergeManager,
113+
String[] allowedLocalDirs,
114+
boolean requireAppScopedLocalDirs) throws IOException {
115+
this(new OneForOneStreamManager(),
116+
new ExternalShuffleBlockResolver(conf, registeredExecutorFile),
117+
mergeManager,
118+
new LocalDirValidator(allowedLocalDirs, requireAppScopedLocalDirs));
119+
}
120+
97121
@VisibleForTesting
98122
public ExternalShuffleBlockResolver getBlockResolver() {
99123
return blockManager;
100124
}
101125

126+
/**
127+
* Validates the local directories an executor reports against the service's configured roots.
128+
* Applied at the RPC boundary so both the block resolver and the merged-shuffle manager only
129+
* consume validated paths. Throws {@link IllegalArgumentException} if an entry is not allowed.
130+
*/
131+
public void validateLocalDirs(String[] localDirs, String appId) {
132+
localDirValidator.validate(localDirs, appId);
133+
}
134+
102135
/** Enables mocking out the StreamManager and BlockManager. */
103136
@VisibleForTesting
104137
public ExternalBlockHandler(
@@ -113,10 +146,19 @@ public ExternalBlockHandler(
113146
OneForOneStreamManager streamManager,
114147
ExternalShuffleBlockResolver blockManager,
115148
MergedShuffleFileManager mergeManager) {
149+
this(streamManager, blockManager, mergeManager, new LocalDirValidator(null, false));
150+
}
151+
152+
private ExternalBlockHandler(
153+
OneForOneStreamManager streamManager,
154+
ExternalShuffleBlockResolver blockManager,
155+
MergedShuffleFileManager mergeManager,
156+
LocalDirValidator localDirValidator) {
116157
this.metrics = new ShuffleMetrics();
117158
this.streamManager = streamManager;
118159
this.blockManager = blockManager;
119160
this.mergeManager = mergeManager;
161+
this.localDirValidator = localDirValidator;
120162
}
121163

122164
@Override
@@ -185,6 +227,9 @@ protected void handleMessage(
185227
metrics.registerExecutorRequestLatencyMillis.time();
186228
try {
187229
checkAuth(client, msg.appId);
230+
// Validate executor-supplied localDirs once here, at the RPC boundary, so both the block
231+
// resolver and the merged-shuffle manager only ever consume validated paths.
232+
validateLocalDirs(msg.executorInfo.localDirs, msg.appId);
188233
blockManager.registerExecutor(msg.appId, msg.execId, msg.executorInfo);
189234
mergeManager.registerExecutor(msg.appId, msg.executorInfo);
190235
callback.onSuccess(ByteBuffer.wrap(new byte[0]));

common/network-shuffle/src/main/java/org/apache/spark/network/shuffle/ExternalShuffleBlockResolver.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ public int removeBlocks(String appId, String execId, String[] blockIds) {
376376
}
377377
int numRemovedBlocks = 0;
378378
for (String blockId : blockIds) {
379+
try {
380+
validateBlockId(blockId);
381+
} catch (IllegalArgumentException e) {
382+
logger.warn("Skipping block with invalid id: {}",
383+
MDC.of(LogKeys.BLOCK_ID, blockId));
384+
continue;
385+
}
379386
File file = new File(
380387
ExecutorDiskUtils.getFilePath(executor.localDirs, executor.subDirsPerLocalDir, blockId));
381388
if (file.delete()) {
@@ -422,6 +429,26 @@ public Cause diagnoseShuffleBlockCorruption(
422429
algorithm, checksumFile, reduceId, data, checksumByReader);
423430
}
424431

432+
/**
433+
* Validates that a blockId is a plain file name before it is used to build a file path under
434+
* {@code <localDir>/<subDirHex>/}. Legitimate Spark block ids contain only ASCII alphanumerics,
435+
* underscores, dots and dashes (see {@code BlockId} in core), so a well-formed blockId never
436+
* contains a path separator and is never {@code "."} or {@code ".."}. Such values are rejected.
437+
*/
438+
@VisibleForTesting
439+
static void validateBlockId(String blockId) {
440+
if (blockId == null || blockId.isEmpty()) {
441+
throw new IllegalArgumentException("blockId must not be null or empty");
442+
}
443+
if (blockId.indexOf('/') >= 0 || blockId.indexOf('\\') >= 0 || blockId.indexOf('\0') >= 0) {
444+
throw new IllegalArgumentException(
445+
"blockId must not contain a path separator or NUL: " + blockId);
446+
}
447+
if (blockId.equals("..") || blockId.equals(".")) {
448+
throw new IllegalArgumentException("blockId must not be \".\" or \"..\": " + blockId);
449+
}
450+
}
451+
425452
/** Simply encodes an executor's full ID, which is appId + execId. */
426453
public static class AppExecId {
427454
public final String appId;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.network.shuffle;
19+
20+
import java.io.File;
21+
import java.io.IOException;
22+
import java.nio.file.Path;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
26+
import org.apache.spark.internal.SparkLogger;
27+
import org.apache.spark.internal.SparkLoggerFactory;
28+
import org.apache.spark.internal.LogKeys;
29+
import org.apache.spark.internal.MDC;
30+
31+
/**
32+
* Validates the local directories an executor reports at registration, before the external shuffle
33+
* service turns them into filesystem paths. {@link ExternalBlockHandler} owns one of these (built
34+
* from the host's configured local-directory roots) and applies it at the RPC boundary, so every
35+
* consumer of those directories -- the block resolver and the merged-shuffle manager -- only ever
36+
* operates on validated paths.
37+
*
38+
* Each entry must canonicalize to a path contained under one of the configured roots (YARN passes
39+
* {@code yarn.nodemanager.local-dirs}; standalone passes the configured local directories) and,
40+
* when app scoping is required, within the registering application's own directory (the
41+
* {@code appId} must appear as a path segment). When no roots are configured there is nothing to
42+
* contain the entries against and they are accepted. An empty array is always allowed.
43+
*/
44+
class LocalDirValidator {
45+
private static final SparkLogger logger = SparkLoggerFactory.getLogger(LocalDirValidator.class);
46+
47+
private final List<Path> allowedLocalDirRoots;
48+
private final boolean requireAppScopedLocalDirs;
49+
50+
LocalDirValidator(String[] allowedLocalDirs, boolean requireAppScopedLocalDirs) {
51+
this.allowedLocalDirRoots = canonicalizeRoots(allowedLocalDirs);
52+
this.requireAppScopedLocalDirs = requireAppScopedLocalDirs;
53+
}
54+
55+
void validate(String[] localDirs, String appId) {
56+
if (allowedLocalDirRoots.isEmpty()) {
57+
return;
58+
}
59+
if (localDirs == null) {
60+
throw new IllegalArgumentException("localDirs must not be null");
61+
}
62+
for (String localDir : localDirs) {
63+
if (localDir == null || localDir.isEmpty()) {
64+
throw new IllegalArgumentException("localDirs entries must be non-null and non-empty");
65+
}
66+
validateContained(localDir, appId);
67+
}
68+
}
69+
70+
private void validateContained(String localDir, String appId) {
71+
Path canonical;
72+
try {
73+
canonical = new File(localDir).getCanonicalFile().toPath();
74+
} catch (IOException e) {
75+
throw new IllegalArgumentException(
76+
"localDirs entry could not be canonicalized: " + localDir, e);
77+
}
78+
if (allowedLocalDirRoots.stream().noneMatch(canonical::startsWith)) {
79+
throw new IllegalArgumentException(
80+
"localDirs entry is not under any of the service's local directories: " + localDir);
81+
}
82+
if (requireAppScopedLocalDirs &&
83+
(appId == null || appId.isEmpty() || !pathContainsSegment(canonical, appId))) {
84+
throw new IllegalArgumentException(
85+
"localDirs entry is not within application " + appId + "'s directory: " + localDir);
86+
}
87+
}
88+
89+
private static boolean pathContainsSegment(Path path, String segment) {
90+
for (Path part : path) {
91+
if (part.toString().equals(segment)) {
92+
return true;
93+
}
94+
}
95+
return false;
96+
}
97+
98+
private static List<Path> canonicalizeRoots(String[] allowedLocalDirs) {
99+
List<Path> roots = new ArrayList<>();
100+
if (allowedLocalDirs != null) {
101+
for (String root : allowedLocalDirs) {
102+
if (root == null || root.isEmpty()) {
103+
continue;
104+
}
105+
try {
106+
roots.add(new File(root).getCanonicalFile().toPath());
107+
} catch (IOException e) {
108+
logger.warn("Ignoring local dir root that could not be canonicalized: {}",
109+
MDC.of(LogKeys.PATH, root));
110+
}
111+
}
112+
}
113+
return roots;
114+
}
115+
}

common/network-shuffle/src/test/java/org/apache/spark/network/shuffle/ExternalShuffleBlockResolverSuite.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717

1818
package org.apache.spark.network.shuffle;
1919

20+
import java.io.File;
2021
import java.io.IOException;
2122
import java.io.InputStream;
2223
import java.nio.charset.StandardCharsets;
24+
import java.nio.file.Files;
25+
import java.util.UUID;
2326

2427
import com.fasterxml.jackson.databind.ObjectMapper;
2528
import org.apache.spark.network.shuffle.protocol.ExecutorShuffleInfo;
@@ -120,4 +123,103 @@ public void jsonSerializationOfExecutorRegistration() throws IOException {
120123
assertEquals(shuffleInfo, mapper.readValue(legacyShuffleJson, ExecutorShuffleInfo.class));
121124
}
122125

126+
/**
127+
* removeBlocks only operates on plain block-id file names. A block id containing parent-directory
128+
* segments is skipped: it is not removed, and a file located outside the executor's local
129+
* directory is left untouched.
130+
*/
131+
@Test
132+
public void removeBlocksIgnoresBlockIdWithParentDirSegments() throws IOException {
133+
TestShuffleDataContext context = new TestShuffleDataContext(1, 5);
134+
context.create();
135+
File outsideFile = null;
136+
try {
137+
File localDir = new File(context.localDirs[0]);
138+
File parentOfLocalDir = localDir.getParentFile();
139+
outsideFile = new File(parentOfLocalDir, "outside-" + UUID.randomUUID() + ".txt");
140+
Files.writeString(outsideFile.toPath(), "should not be deleted", StandardCharsets.UTF_8);
141+
assertTrue(outsideFile.exists(), "precondition: file was created");
142+
143+
ExternalShuffleBlockResolver resolver = new ExternalShuffleBlockResolver(conf, null);
144+
resolver.registerExecutor("app0", "0", context.createExecutorInfo(SORT_MANAGER));
145+
146+
String blockId = ".." + File.separator + ".." + File.separator + outsideFile.getName();
147+
int removed = resolver.removeBlocks("app0", "0", new String[] { blockId });
148+
149+
assertEquals(0, removed, "A block id with parent-dir segments must not remove a block");
150+
assertTrue(outsideFile.exists(),
151+
"A file outside the local directory must not be removed (" +
152+
outsideFile.getAbsolutePath() + ")");
153+
} finally {
154+
if (outsideFile != null && outsideFile.exists()) {
155+
assertTrue(outsideFile.delete() || !outsideFile.exists());
156+
}
157+
context.cleanup();
158+
}
159+
}
160+
161+
/**
162+
* A block id containing a path separator is not a plain file name and is skipped, so removeBlocks
163+
* reports zero removed blocks.
164+
*/
165+
@Test
166+
public void removeBlocksIgnoresBlockIdWithSeparator() throws IOException {
167+
TestShuffleDataContext context = new TestShuffleDataContext(1, 5);
168+
context.create();
169+
try {
170+
ExternalShuffleBlockResolver resolver = new ExternalShuffleBlockResolver(conf, null);
171+
resolver.registerExecutor("app0", "0", context.createExecutorInfo(SORT_MANAGER));
172+
173+
String blockId = "sub" + File.separator + "child";
174+
int removed = resolver.removeBlocks("app0", "0", new String[] { blockId });
175+
176+
assertEquals(0, removed, "A block id containing a path separator must be skipped");
177+
} finally {
178+
context.cleanup();
179+
}
180+
}
181+
182+
/**
183+
* In a batched call, an invalid block id does not abort the whole operation: a valid block id in
184+
* the same batch is still removed while the invalid one is skipped.
185+
*/
186+
@Test
187+
public void removeBlocksContinuesAfterIgnoringInvalidBlockId() throws IOException {
188+
TestShuffleDataContext context = new TestShuffleDataContext(1, 5);
189+
context.create();
190+
File outsideFile = null;
191+
try {
192+
File localDir = new File(context.localDirs[0]);
193+
File parentOfLocalDir = localDir.getParentFile();
194+
outsideFile = new File(parentOfLocalDir, "outside-" + UUID.randomUUID() + ".txt");
195+
Files.writeString(outsideFile.toPath(), "should not be deleted", StandardCharsets.UTF_8);
196+
197+
// Plant a real, legitimately-named block in the localDir so the valid removal has something
198+
// to actually remove.
199+
context.insertCachedRddData(7, 0, new byte[] { 1, 2, 3 });
200+
String validBlockId = "rdd_7_0";
201+
File validFile = new File(ExecutorDiskUtils.getFilePath(
202+
context.localDirs, context.subDirsPerLocalDir, validBlockId));
203+
assertTrue(validFile.exists(), "precondition: block file was created");
204+
205+
ExternalShuffleBlockResolver resolver = new ExternalShuffleBlockResolver(conf, null);
206+
resolver.registerExecutor("app0", "0", context.createExecutorInfo(SORT_MANAGER));
207+
208+
String invalidBlockId =
209+
".." + File.separator + ".." + File.separator + outsideFile.getName();
210+
int removed = resolver.removeBlocks(
211+
"app0", "0", new String[] { invalidBlockId, validBlockId });
212+
213+
assertEquals(1, removed, "The valid block id in the batch should still be removed");
214+
assertTrue(outsideFile.exists(),
215+
"The invalid entry must not remove a file outside the local directory");
216+
assertFalse(validFile.exists(), "The valid block file should have been removed by the batch");
217+
} finally {
218+
if (outsideFile != null && outsideFile.exists()) {
219+
assertTrue(outsideFile.delete() || !outsideFile.exists());
220+
}
221+
context.cleanup();
222+
}
223+
}
224+
123225
}

0 commit comments

Comments
 (0)