Skip to content

Commit 5795edd

Browse files
committed
SOLR-17543: Input validation in FSConfigSetService
See JIRA ticket for more details.
1 parent 125fc3f commit 5795edd

File tree

4 files changed

+134
-16
lines changed

4 files changed

+134
-16
lines changed

solr/core/src/java/org/apache/solr/core/FileSystemConfigSetService.java

+25-13
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.apache.solr.common.cloud.ZkMaintenanceUtils;
3939
import org.apache.solr.common.util.Utils;
4040
import org.apache.solr.util.FileTypeMagicUtil;
41+
import org.apache.solr.util.FileUtils;
4142
import org.slf4j.Logger;
4243
import org.slf4j.LoggerFactory;
4344

@@ -150,19 +151,30 @@ public void uploadFileToConfig(
150151
throws IOException {
151152
if (ZkMaintenanceUtils.isFileForbiddenInConfigSets(fileName)) {
152153
log.warn("Not including uploading file to config, as it is a forbidden type: {}", fileName);
153-
} else {
154-
if (!FileTypeMagicUtil.isFileForbiddenInConfigset(data)) {
155-
Path filePath = getConfigDir(configName).resolve(normalizePathToOsSeparator(fileName));
156-
if (!Files.exists(filePath) || overwriteOnExists) {
157-
Files.write(filePath, data);
158-
}
159-
} else {
160-
String mimeType = FileTypeMagicUtil.INSTANCE.guessMimeType(data);
161-
log.warn(
162-
"Not including uploading file {}, as it matched the MAGIC signature of a forbidden mime type {}",
163-
fileName,
164-
mimeType);
165-
}
154+
return;
155+
}
156+
if (FileTypeMagicUtil.isFileForbiddenInConfigset(data)) {
157+
String mimeType = FileTypeMagicUtil.INSTANCE.guessMimeType(data);
158+
log.warn(
159+
"Not including uploading file {}, as it matched the MAGIC signature of a forbidden mime type {}",
160+
fileName,
161+
mimeType);
162+
return;
163+
}
164+
final var configsetBasePath = getConfigDir(configName);
165+
final var configsetFilePath = configsetBasePath.resolve(normalizePathToOsSeparator(fileName));
166+
if (!FileUtils.isPathAChildOfParent(
167+
configsetBasePath, configsetFilePath)) { // See SOLR-17543 for context
168+
log.warn(
169+
"Not uploading file [{}], as it resolves to a location [{}] outside of the configset root directory [{}]",
170+
fileName,
171+
configsetFilePath,
172+
configsetBasePath);
173+
return;
174+
}
175+
176+
if (overwriteOnExists || !Files.exists(configsetFilePath)) {
177+
Files.write(configsetFilePath, data);
166178
}
167179
}
168180

solr/core/src/java/org/apache/solr/util/FileUtils.java

+24
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,28 @@ public static Path createDirectories(Path path) throws IOException {
103103
}
104104
return Files.createDirectories(path);
105105
}
106+
107+
/**
108+
* Checks whether a child path falls under a particular parent
109+
*
110+
* <p>Useful for validating user-provided relative paths, which generally aren't expected to
111+
* "escape" a given parent/root directory. Parent and child paths are "normalized" by {@link
112+
* Path#normalize()}. This removes explicit backtracking (e.g. "../") though it will not resolve
113+
* symlinks if any are present in the provided Paths, so some forms of parent "escape" remain
114+
* undetected. Paths needn't exist as a file or directory for comparison purposes.
115+
*
116+
* <p>Note, this method does not consult the file system
117+
*
118+
* @param parent the path of a 'parent' node. Path must be syntactically valid but needn't exist.
119+
* @param potentialChild the path of a potential child. Typically obtained via:
120+
* parent.resolve(relativeChildPath). Path must be syntactically valid but needn't exist.
121+
* @return true if 'potentialChild' nests under the provided 'parent', false otherwise.
122+
*/
123+
public static boolean isPathAChildOfParent(Path parent, Path potentialChild) {
124+
final var normalizedParent = parent.toAbsolutePath().normalize();
125+
final var normalizedChild = potentialChild.toAbsolutePath().normalize();
126+
127+
return normalizedChild.startsWith(normalizedParent)
128+
&& !normalizedChild.equals(normalizedParent);
129+
}
106130
}

solr/core/src/test/org/apache/solr/core/TestFileSystemConfigSetService.java

+35-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
package org.apache.solr.core;
1818

1919
import static org.apache.solr.core.FileSystemConfigSetService.METADATA_FILE;
20+
import static org.hamcrest.Matchers.hasItem;
2021

22+
import java.io.File;
2123
import java.io.IOException;
2224
import java.nio.charset.StandardCharsets;
2325
import java.nio.file.Files;
@@ -49,13 +51,43 @@ public static void afterClass() throws Exception {
4951
fileSystemConfigSetService = null;
5052
}
5153

54+
@Test
55+
public void testIgnoresFileUploadsOutsideOfConfigSetDirectory() throws IOException {
56+
final var initialNumConfigs = fileSystemConfigSetService.listConfigs().size();
57+
final String configName = "fileEscapeTestConfig";
58+
final var specificConfigSetBase = configSetBase.resolve(configName);
59+
60+
fileSystemConfigSetService.uploadConfig(configName, configset("cloud-minimal"));
61+
assertEquals(fileSystemConfigSetService.listConfigs().size(), initialNumConfigs + 1);
62+
assertTrue(fileSystemConfigSetService.checkConfigExists(configName));
63+
64+
// This succeeds, as the file is an allowed type and the path doesn't attempt to escape the
65+
// configset's root
66+
byte[] testdata = "test data".getBytes(StandardCharsets.UTF_8);
67+
fileSystemConfigSetService.uploadFileToConfig(configName, "validPath", testdata, true);
68+
final var knownFiles = fileSystemConfigSetService.getAllConfigFiles(configName);
69+
assertThat(knownFiles, hasItem("validPath"));
70+
assertTrue(Files.exists(specificConfigSetBase.resolve("validPath")));
71+
72+
// Each of these will fail "quietly" as ConfigSetService opts to log warnings but otherwise not
73+
// surface validation errors to enable bulk uploading
74+
final var invalidFilePaths =
75+
List.of(
76+
".." + File.separator + "escapePath",
77+
"foo" + File.separator + ".." + File.separator + ".." + File.separator + "bar");
78+
for (String invalidFilePath : invalidFilePaths) {
79+
fileSystemConfigSetService.uploadFileToConfig(configName, invalidFilePath, testdata, true);
80+
assertFalse(Files.exists(specificConfigSetBase.resolve(invalidFilePath)));
81+
}
82+
}
83+
5284
@Test
5385
public void testUploadAndDeleteConfig() throws IOException {
86+
final var initialNumConfigs = fileSystemConfigSetService.listConfigs().size();
5487
String configName = "testconfig";
5588

5689
fileSystemConfigSetService.uploadConfig(configName, configset("cloud-minimal"));
57-
58-
assertEquals(fileSystemConfigSetService.listConfigs().size(), 1);
90+
assertEquals(fileSystemConfigSetService.listConfigs().size(), initialNumConfigs + 1);
5991
assertTrue(fileSystemConfigSetService.checkConfigExists(configName));
6092

6193
byte[] testdata = "test data".getBytes(StandardCharsets.UTF_8);
@@ -79,7 +111,7 @@ public void testUploadAndDeleteConfig() throws IOException {
79111
assertEquals("[schema.xml, solrconfig.xml]", allConfigFiles.toString());
80112

81113
fileSystemConfigSetService.copyConfig(configName, "copytestconfig");
82-
assertEquals(fileSystemConfigSetService.listConfigs().size(), 2);
114+
assertEquals(fileSystemConfigSetService.listConfigs().size(), initialNumConfigs + 2);
83115

84116
allConfigFiles = fileSystemConfigSetService.getAllConfigFiles("copytestconfig");
85117
assertEquals("[schema.xml, solrconfig.xml]", allConfigFiles.toString());

solr/core/src/test/org/apache/solr/util/FileUtilsTest.java

+50
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,65 @@
1717
package org.apache.solr.util;
1818

1919
import java.io.File;
20+
import java.nio.file.Path;
2021
import org.apache.solr.SolrTestCase;
22+
import org.junit.Test;
2123

2224
public class FileUtilsTest extends SolrTestCase {
2325

26+
@Test
2427
public void testResolve() {
2528
String cwd = new File(".").getAbsolutePath();
2629
assertEquals(new File("conf/data"), FileUtils.resolvePath(new File("conf"), "data"));
2730
assertEquals(
2831
new File(cwd + "/conf/data"), FileUtils.resolvePath(new File(cwd + "/conf"), "data"));
2932
assertEquals(new File(cwd + "/data"), FileUtils.resolvePath(new File("conf"), cwd + "/data"));
3033
}
34+
35+
@Test
36+
public void testDetectsPathEscape() {
37+
final var parent = Path.of(".");
38+
39+
// Allows simple child
40+
assertTrue(FileUtils.isPathAChildOfParent(parent, parent.resolve("child")));
41+
42+
// Allows "./" prefixed child
43+
assertTrue(FileUtils.isPathAChildOfParent(parent, parent.resolve(buildPath(".", "child"))));
44+
45+
// Allows nested child
46+
assertTrue(
47+
FileUtils.isPathAChildOfParent(parent, parent.resolve(buildPath("nested", "child"))));
48+
49+
// Allows backtracking, provided it stays "under" parent
50+
assertTrue(
51+
FileUtils.isPathAChildOfParent(
52+
parent, parent.resolve(buildPath("child1", "..", "child2"))));
53+
assertTrue(
54+
FileUtils.isPathAChildOfParent(
55+
parent, parent.resolve(buildPath("child", "grandchild1", "..", "grandchild2"))));
56+
57+
// Prevents identical path
58+
assertFalse(FileUtils.isPathAChildOfParent(parent, parent));
59+
60+
// Detects sibling of parent
61+
assertFalse(FileUtils.isPathAChildOfParent(parent, parent.resolve(buildPath("..", "sibling"))));
62+
63+
// Detects "grandparent" of parent
64+
assertFalse(FileUtils.isPathAChildOfParent(parent, parent.resolve("..")));
65+
66+
// Detects many-layered backtracking
67+
assertFalse(
68+
FileUtils.isPathAChildOfParent(parent, parent.resolve(buildPath("..", "..", "..", ".."))));
69+
}
70+
71+
private static String buildPath(String... pathSegments) {
72+
final var sb = new StringBuilder();
73+
for (int i = 0; i < pathSegments.length; i++) {
74+
sb.append(pathSegments[i]);
75+
if (i < pathSegments.length - 1) {
76+
sb.append(File.separator);
77+
}
78+
}
79+
return sb.toString();
80+
}
3181
}

0 commit comments

Comments
 (0)