Skip to content

Commit bee0bb6

Browse files
committed
Validate file and folder names to avoid illegal characters/reserved filenames
1 parent 2bb8672 commit bee0bb6

File tree

5 files changed

+198
-14
lines changed

5 files changed

+198
-14
lines changed

workspace-server/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies {
3535
implementation 'com.zaxxer:HikariCP:5.0.1'
3636

3737
testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.0.1'
38+
testImplementation 'org.junit.jupiter:junit-jupiter-params:6.0.1'
3839
testImplementation 'net.jqwik:jqwik:1.6.5'
3940

4041
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,14 @@ private void put(Context context) throws NoSuchWorkspaceException, IOException {
424424
return;
425425
}
426426

427-
if (workspaceService.saveFile(pathInfo.workspaceId, pathInfo.filePath, file)) {
428-
context.status(200).result("File " + pathInfo.fileName() + " uploaded to " + pathInfo.filePath);
429-
} else {
430-
context.status(500).json(new FormattedError("Could not save file."));
427+
try {
428+
if (workspaceService.saveFile(pathInfo.workspaceId, pathInfo.filePath, file)) {
429+
context.status(200).result("File " + pathInfo.fileName() + " uploaded to " + pathInfo.filePath);
430+
} else {
431+
context.status(500).json(new FormattedError("Could not save file."));
432+
}
433+
} catch (WorkspaceFileOpException wfe) {
434+
context.status(500).json(new FormattedError(wfe, "Could not save file."));
431435
}
432436
} else if (type == ItemType.directory) {
433437
// Reject the request if the "overwrite" flag is supplied
@@ -436,10 +440,14 @@ private void put(Context context) throws NoSuchWorkspaceException, IOException {
436440
return;
437441
}
438442

439-
if (workspaceService.createDirectory(pathInfo.workspaceId, pathInfo.filePath)) {
440-
context.status(200).result("Directory created.");
441-
} else {
442-
context.status(500).json(new FormattedError("Could not create directory."));
443+
try {
444+
if (workspaceService.createDirectory(pathInfo.workspaceId, pathInfo.filePath)) {
445+
context.status(200).result("Directory created.");
446+
} else {
447+
context.status(500).json(new FormattedError("Could not create directory."));
448+
}
449+
} catch (WorkspaceFileOpException wfe) {
450+
context.status(500).json(new FormattedError(wfe, "Could not create directory."));
443451
}
444452
} else {
445453
context.status(400).json(new FormattedError("Query param 'type' has invalid value "+type));
@@ -803,6 +811,9 @@ public void bulkUpload(Context context) throws NoSuchWorkspaceException {
803811
} catch (IOException ioe) {
804812
response.add("status", 500)
805813
.add("response", new FormattedError(ioe, "Could not save file.").toJson());
814+
} catch (WorkspaceFileOpException wfe) {
815+
response.add("status", 500)
816+
.add("response", new FormattedError(wfe, "Could not create directory.").toJson());
806817
}
807818
} else if (item.uploadType() == ItemType.directory) {
808819
// Create directory
@@ -817,6 +828,9 @@ public void bulkUpload(Context context) throws NoSuchWorkspaceException {
817828
} catch (IOException ioe) {
818829
response.add("status", 500)
819830
.add("response", new FormattedError(ioe, "Could not create directory.").toJson());
831+
} catch (WorkspaceFileOpException wfe) {
832+
response.add("status", 500)
833+
.add("response", new FormattedError(wfe, "Could not create directory.").toJson());
820834
}
821835
} else {
822836
response.add("status", 501)

workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemService.java

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import java.nio.file.StandardCopyOption;
1616
import java.sql.SQLException;
1717
import java.util.ArrayList;
18+
import java.util.Arrays;
1819
import java.util.Optional;
20+
import java.util.regex.Pattern;
1921
import java.util.stream.Stream;
2022
import org.slf4j.Logger;
2123
import org.slf4j.LoggerFactory;
@@ -45,6 +47,65 @@ Path resolveSubPath(final Path rootPath, final Path filePath) {
4547
return resolvedPath;
4648
}
4749

50+
/**
51+
* Validates that the path does not contain any invalid characters.
52+
*
53+
* Forbidden Characters for File and Folder names:
54+
* < (less than), > (greater than), : (colon), " (double quote), / (forward slash), \ (backslash),
55+
* | (vertical bar or pipe), ? (question mark), * (asterisk),
56+
* % (percent sign - causes issues with URL path resolution as it is not automatically encoded),
57+
* # (pound sign - causes issues with URL path resolution as it is not automatically encoded),
58+
* Unicode Control Characters (0-31, 127-159),
59+
* trailing .
60+
* trailing space
61+
*
62+
* While / (forward slash) is a forbidden characters in filenames, it's interpreted by Java's Path class as a
63+
* folder delineator, meaning that it will not appear as a path segment.
64+
* The character is still checked for just in case.
65+
*
66+
* Reserved Filenames (these are not permitted on Windows even if they have an extension):
67+
* CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9,
68+
* LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9
69+
*
70+
* @param path the Path to validate
71+
*/
72+
void validatePath(final Path path) throws WorkspaceFileOpException {
73+
final String[] reservedFilenames = {"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
74+
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"};
75+
final var controlCharacters = Pattern.compile("([\u0000-\u001F]|[\u007F-\u009F])+", Pattern.UNICODE_CHARACTER_CLASS);
76+
final var forbiddenCharacters = Pattern.compile("([|<>:/\"?*%#\\\\])+", Pattern.UNICODE_CHARACTER_CLASS);
77+
78+
79+
for(final var pathSegment : path) {
80+
final var segment = pathSegment.toString();
81+
// Check for trailing period or space
82+
if(segment.endsWith(" ")) {
83+
throw new WorkspaceFileOpException("Path segment '"+ segment+ "' cannot end in a space.");
84+
}
85+
if(segment.endsWith(".")) {
86+
throw new WorkspaceFileOpException("Path segment '"+ segment+ "' cannot end in a period.");
87+
}
88+
89+
// Check for control characters
90+
final var controlMatcher = controlCharacters.matcher(segment);
91+
if(controlMatcher.find()){
92+
throw new WorkspaceFileOpException("Path segment '"+ segment+ "' has illegal characters: "+controlMatcher.group());
93+
}
94+
95+
// Check for forbidden characters
96+
final var forbiddenMatcher = forbiddenCharacters.matcher(segment);
97+
if(forbiddenMatcher.find()){
98+
throw new WorkspaceFileOpException("Path segment '"+ segment+ "' has illegal characters: "+forbiddenMatcher.group());
99+
}
100+
101+
// Check that the segment is not a reserved filenames:
102+
final var name = segment.split("\\.")[0];
103+
if(Arrays.asList(reservedFilenames).contains(name)){
104+
throw new WorkspaceFileOpException("Path segment '"+ segment+ "' contains reserved name: "+name);
105+
}
106+
}
107+
}
108+
48109
public WorkspaceFileSystemService(final WorkspacePostgresRepository postgresRepository) {
49110
this.postgresRepository = postgresRepository;
50111
}
@@ -149,26 +210,29 @@ public FileStream loadFile(final int workspaceId, final Path filePath) throws IO
149210

150211
@Override
151212
public boolean saveFile(final int workspaceId, final Path filePath, final UploadedFile file)
152-
throws NoSuchWorkspaceException {
213+
throws NoSuchWorkspaceException, WorkspaceFileOpException {
153214
final var repoPath = postgresRepository.workspaceRootPath(workspaceId);
154215
final var path = resolveSubPath(repoPath, filePath);
155216

156217
if(path.toFile().isDirectory()) return false;
157218

219+
validatePath(path);
158220
FileUtil.streamToFile(file.content(), path.toString());
159221
return true;
160222
}
161223

162224
@Override
163225
public boolean moveFile(final int oldWorkspaceId, final Path oldFilePath, final int newWorkspaceId, final Path newFilePath)
164-
throws NoSuchWorkspaceException, SQLException
226+
throws NoSuchWorkspaceException, SQLException, WorkspaceFileOpException
165227
{
166228
final var oldRepoPath = postgresRepository.workspaceRootPath(oldWorkspaceId);
167229
final var oldPath = resolveSubPath(oldRepoPath, oldFilePath);
168230
final var newRepoPath = (oldWorkspaceId == newWorkspaceId) ? oldRepoPath : postgresRepository.workspaceRootPath(newWorkspaceId);
169231
final var newPath = resolveSubPath(newRepoPath, newFilePath);
170232
boolean success = true;
171233

234+
validatePath(newPath);
235+
172236
// Find hidden metadata files, if they exist, and move them
173237
final var metadataExtensions = postgresRepository.getMetadataExtensions();
174238
for(final var extension : metadataExtensions) {
@@ -191,6 +255,8 @@ public boolean copyFile(final int sourceWorkspaceId, final Path sourceFilePath,
191255
final var destRepoPath = (sourceWorkspaceId == destWorkspaceId) ? sourceRepoPath : postgresRepository.workspaceRootPath(destWorkspaceId);
192256
final var destPath = resolveSubPath(destRepoPath, destFilePath);
193257

258+
validatePath(destPath);
259+
194260
try {
195261
// Do not copy the file if the source file does not exist
196262
if(!sourcePath.toFile().exists()) throw new WorkspaceFileOpException("Source file \"%s\" in workspace %d does not exist.".formatted(sourceFilePath, sourceWorkspaceId));
@@ -228,10 +294,11 @@ public boolean copyDirectory(final int sourceWorkspaceId, final Path sourceFileP
228294
final var destRepoPath = (sourceWorkspaceId == destWorkspaceId) ? sourceRepoPath : postgresRepository.workspaceRootPath(destWorkspaceId);
229295
final var destPath = resolveSubPath(destRepoPath, destFilePath);
230296

297+
validatePath(destPath);
231298
try {
232299
// Validate source exists and is a directory
233300
if (!Files.exists(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d does not exist.".formatted(sourceFilePath, sourceWorkspaceId));
234-
if (!Files.isDirectory(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d is not actually a directory.".formatted(sourceFilePath, sourceWorkspaceId));
301+
if (!Files.isDirectory(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d is not a directory.".formatted(sourceFilePath, sourceWorkspaceId));
235302

236303
// Do not try to copy a directory into itself
237304
if(sourceWorkspaceId == destWorkspaceId && destPath.startsWith(sourcePath)){
@@ -304,9 +371,11 @@ public DirectoryTree listFiles(final int workspaceId, final Optional<Path> direc
304371
}
305372

306373
@Override
307-
public boolean createDirectory(final int workspaceId, final Path directoryPath) throws IOException, NoSuchWorkspaceException {
374+
public boolean createDirectory(final int workspaceId, final Path directoryPath)
375+
throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException {
308376
final var repoPath = postgresRepository.workspaceRootPath(workspaceId);
309377
final var path = resolveSubPath(repoPath, directoryPath);
378+
validatePath(path);
310379
Files.createDirectories(path);
311380
return true;
312381
}
@@ -320,6 +389,7 @@ public boolean moveDirectory(final int oldWorkspaceId, final Path oldDirectoryPa
320389
final var oldPath = resolveSubPath(oldRepoPath, oldDirectoryPath);
321390
final var newRepoPath = (oldWorkspaceId == newWorkspaceId) ? oldRepoPath : postgresRepository.workspaceRootPath(newWorkspaceId).normalize();
322391
final var newPath = resolveSubPath(newRepoPath, newDirectoryPath);
392+
validatePath(newPath);
323393

324394
// Do not permit the source workspace's root directory to be moved
325395
if(Files.isSameFile(oldPath, oldRepoPath)) throw new WorkspaceFileOpException("Cannot move the workspace root directory.");

workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceService.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ record FileStream(InputStream readingStream, String fileName, long fileSize){}
4646
* @return true if the file was saved, false otherwise
4747
*/
4848
boolean saveFile(final int workspaceId, final Path filePath, final UploadedFile file)
49-
throws IOException, NoSuchWorkspaceException;
49+
throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException;
5050

5151
/**
5252
* Copy a file within a workspace or between workspaces.
@@ -84,7 +84,8 @@ boolean deleteFile(final int workspaceId, final Path filePath)
8484
DirectoryTree listFiles(final int workspaceId, final Optional<Path> directoryPath, final int depth)
8585
throws SQLException, NoSuchWorkspaceException, IOException;
8686

87-
boolean createDirectory(final int workspaceId, final Path directoryPath) throws IOException, NoSuchWorkspaceException;
87+
boolean createDirectory(final int workspaceId, final Path directoryPath)
88+
throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException;
8889
/**
8990
* Move a directory within a workspace or between workspaces.
9091
* @param oldWorkspaceId the id of the source workspace

workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemServiceTest.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import org.junit.jupiter.api.BeforeEach;
44
import org.junit.jupiter.api.Nested;
55
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.params.ParameterizedTest;
7+
import org.junit.jupiter.params.provider.FieldSource;
68

79
import java.nio.file.Path;
810

@@ -57,4 +59,100 @@ void pathTraversalThrowsSecurityException() {
5759
service.resolveSubPath(Path.of("/workspace/123"), Path.of("folder/../../workspace/123/../456/file.txt")));
5860
}
5961
}
62+
63+
@Nested
64+
class ValidatePathTests {
65+
final static String[] reservedFileNames = {
66+
"CON", "PRN", "AUX", "NUL",
67+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
68+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
69+
};
70+
final static String[] forbiddenChars = {
71+
// Normal forbidden characters
72+
"<", ">", ":", "\"", "\\", "|", "?", "*", "%", "#",
73+
// Unicode/ASCII 0-31 control characters
74+
// "\u0000" is not tested as it isn't permitted in a Path, meaning it cannot be passed to validatePath
75+
"\u0001", "\u0002", "\u0003", "\u0004", "\u0005", "\u0006", "\u0007", "\u0008", "\t",
76+
"\n", "\u000b", "\u000c", "\r", "\u000e", "\u000f",
77+
"\u0010", "\u0011", "\u0012", "\u0013", "\u0014", "\u0015", "\u0016", "\u0017", "\u0018", "\u0019",
78+
"\u001A", "\u001b", "\u001c", "\u001d", "\u001e", "\u001f",
79+
// Unicode 127-159 control characters
80+
"\u007F", "\u0080", "\u0081", "\u0082", "\u0083", "\u0084", "\u0085", "\u0086", "\u0087", "\u0088", "\u0089",
81+
"\u008A", "\u008B", "\u008C", "\u008D", "\u008E", "\u008F",
82+
"\u0090", "\u0091", "\u0092", "\u0093", "\u0094", "\u0095", "\u0096", "\u0097", "\u0098", "\u0099",
83+
"\u009A", "\u009B", "\u009C", "\u009D", "\u009E", "\u009F",
84+
};
85+
final static String[] trailingChars = {"foo ", "foo.", " ", "."};
86+
87+
@ParameterizedTest
88+
@FieldSource("forbiddenChars")
89+
void validatePathForbiddenChars(String forbidden) {
90+
// These characters are not allowed on their own
91+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden)));
92+
// These characters are not allowed as part of a longer file name
93+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("foobar" + forbidden)));
94+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("file"+forbidden+".txt")));
95+
// There characters are not allowed as the last part of the path
96+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder", forbidden)));
97+
// There characters are not allowed as part of a folder name
98+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden, "file")));
99+
}
100+
101+
@ParameterizedTest
102+
@FieldSource("trailingChars")
103+
void validatePathTrailingChars(String forbidden) {
104+
// Trailing characters are not permitted at the end of a file
105+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden)));
106+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("foobar" + forbidden)));
107+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder", forbidden)));
108+
109+
// Trailing characters are permitted before an extension (as the extension makes them non-trailing)
110+
assertDoesNotThrow(() -> service.validatePath(Path.of("file"+forbidden+".txt")));
111+
112+
// Trailing characters are not permitted in folder names
113+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden, "file")));
114+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder"+forbidden, "file")));
115+
}
116+
117+
@ParameterizedTest
118+
@FieldSource("reservedFileNames")
119+
void validatePathReservedFilenames(String reserved) {
120+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved)));
121+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved+".txt")));
122+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder/"+reserved)));
123+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder/"+reserved+".txt")));
124+
assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved, "file.txt")));
125+
126+
// They are allowed to be PART of the file name
127+
assertDoesNotThrow(() -> service.validatePath(Path.of("myFile_"+reserved)));
128+
assertDoesNotThrow(() -> service.validatePath(Path.of("myFile_"+reserved+".txt")));
129+
assertDoesNotThrow(() -> service.validatePath(Path.of("myFolder_"+reserved, "myFile.txt")));
130+
}
131+
132+
/**
133+
* Forward slashes are considered a path delineator and will not throw
134+
*/
135+
@Test
136+
void forwardSlash() {
137+
assertDoesNotThrow(() -> service.validatePath(Path.of("/")));
138+
assertDoesNotThrow(() -> service.validatePath(Path.of("foobar/")));
139+
assertDoesNotThrow(() -> service.validatePath(Path.of("file/.txt")));
140+
assertDoesNotThrow(() -> service.validatePath(Path.of("folder", "/")));
141+
assertDoesNotThrow(() -> service.validatePath(Path.of("folder/")));
142+
assertDoesNotThrow(() -> service.validatePath(Path.of("/", "file.txt")));
143+
}
144+
145+
/**
146+
* Spaces and periods are allowed in the path so long as they are not trailing
147+
*/
148+
@Test
149+
void validatePathPermitsSpacePeriod() {
150+
assertDoesNotThrow(() -> service.validatePath(Path.of("my file")));
151+
assertDoesNotThrow(() -> service.validatePath(Path.of("my folder","my file")));
152+
assertDoesNotThrow(() -> service.validatePath(Path.of("my folder", " my file")));
153+
assertDoesNotThrow(() -> service.validatePath(Path.of("my.file")));
154+
assertDoesNotThrow(() -> service.validatePath(Path.of("my.folder","my.file")));
155+
assertDoesNotThrow(() -> service.validatePath(Path.of("my.folder",".my.file")));
156+
}
157+
}
60158
}

0 commit comments

Comments
 (0)