Validate filepath and filename to prevent path traversal (#1604)

Validate filepath and filename to prevent path traversal in modification
command and provide validations for editor plugin.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-03-25 12:50:24 +01:00
committed by GitHub
parent 08549a37b1
commit d94ebb2e3e
12 changed files with 169 additions and 11 deletions

View File

@@ -0,0 +1,2 @@
- type: fixed
description: Adjust path and filename validation to prevent path traversal ([#1604](https://github.com/scm-manager/scm-manager/pull/1604))

View File

@@ -41,6 +41,7 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static sonia.scm.AlreadyExistsException.alreadyExists; import static sonia.scm.AlreadyExistsException.alreadyExists;
import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound; import static sonia.scm.NotFoundException.notFound;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
/** /**
* This "interface" is not really intended to be used as an interface but rather as * This "interface" is not really intended to be used as an interface but rather as
@@ -52,7 +53,7 @@ public interface ModifyWorkerHelper extends ModifyCommand.Worker {
@Override @Override
default void delete(String toBeDeleted) throws IOException { default void delete(String toBeDeleted) throws IOException {
Path fileToBeDeleted = new File(getWorkDir(), toBeDeleted).toPath(); Path fileToBeDeleted = getTargetFile(toBeDeleted);
try { try {
Files.delete(fileToBeDeleted); Files.delete(fileToBeDeleted);
} catch (NoSuchFileException e) { } catch (NoSuchFileException e) {
@@ -65,7 +66,7 @@ public interface ModifyWorkerHelper extends ModifyCommand.Worker {
@Override @Override
default void create(String toBeCreated, File file, boolean overwrite) throws IOException { default void create(String toBeCreated, File file, boolean overwrite) throws IOException {
Path targetFile = new File(getWorkDir(), toBeCreated).toPath(); Path targetFile = getTargetFile(toBeCreated);
createDirectories(targetFile); createDirectories(targetFile);
if (overwrite) { if (overwrite) {
Files.move(file.toPath(), targetFile, REPLACE_EXISTING); Files.move(file.toPath(), targetFile, REPLACE_EXISTING);
@@ -80,7 +81,7 @@ public interface ModifyWorkerHelper extends ModifyCommand.Worker {
} }
default void modify(String path, File file) throws IOException { default void modify(String path, File file) throws IOException {
Path targetFile = new File(getWorkDir(), path).toPath(); Path targetFile = getTargetFile(path);
createDirectories(targetFile); createDirectories(targetFile);
if (!targetFile.toFile().exists()) { if (!targetFile.toFile().exists()) {
throw notFound(createFileContext(path)); throw notFound(createFileContext(path));
@@ -112,9 +113,32 @@ public interface ModifyWorkerHelper extends ModifyCommand.Worker {
} }
} }
default Path getTargetFile(String path) {
File workDir = getWorkDir();
// WARNING: 'new File(workDir, path)' is not the same as
// 'workDir.toPath().resolve(path)'! The first one does not
// mind whether 'path' starts with a '/', the nio api does.
// So using the file api the two paths '/file' and 'file'
// lead to the same result, whereas the nio api would
// lead to an absolute path starting at the ssytem root in the
// first example starting with a '/'.
Path targetFile = new File(workDir, path).toPath().normalize();
doThrow()
.violation("illegal path traversal: " + path)
.when(!targetFile.startsWith(workDir.toPath().normalize()));
doThrow()
.violation("protected path: " + path)
.when(isProtectedPath(targetFile));
return targetFile;
}
File getWorkDir(); File getWorkDir();
Repository getRepository(); Repository getRepository();
String getBranch(); String getBranch();
default boolean isProtectedPath(Path path) {
return false;
}
} }

View File

@@ -51,6 +51,21 @@ public final class ValidationUtil {
return Util.isNotEmpty(filename) && isNotContaining(filename, "/", "\\", ":"); return Util.isNotEmpty(filename) && isNotContaining(filename, "/", "\\", ":");
} }
/**
* Checks if the path is a valid path and does not enable path traversal
*
* @param path path to be validated
*
* @return {@code true} if path is valid else false
*/
public static boolean isPathValid(String path)
{
return !path.equals(".")
&& !path.contains("../")
&& !path.contains("//")
&& !path.equals("..");
}
/** /**
* Returns {@code true} if the mail is valid. * Returns {@code true} if the mail is valid.
* *

View File

@@ -26,6 +26,7 @@ package sonia.scm.repository.spi;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import java.io.File; import java.io.File;
@@ -34,9 +35,12 @@ import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ModifyWorkerHelperTest { class ModifyWorkerHelperTest {
private boolean pathProtected = false;
@Test @Test
void shouldKeepExecutableFlag(@TempDir Path temp) throws IOException { void shouldKeepExecutableFlag(@TempDir Path temp) throws IOException {
@@ -52,6 +56,33 @@ class ModifyWorkerHelperTest {
assertThat(target.canExecute()).isTrue(); assertThat(target.canExecute()).isTrue();
} }
@Test
void shouldNotWriteInProtectedPath(@TempDir Path temp) throws IOException {
pathProtected = true;
File target = createFile(temp, "some.txt");
ModifyWorkerHelper helper = new MinimalModifyWorkerHelper(temp);
assertThrows(
ScmConstraintViolationException.class,
() -> helper.create("some.txt", target, true)
);
}
@Test
void shouldNotWritePathWithPathTraversal(@TempDir Path temp) throws IOException {
File target = createFile(temp, "some.txt");
ModifyWorkerHelper helper = new MinimalModifyWorkerHelper(temp);
assertThrows(
ScmConstraintViolationException.class,
() -> helper.create("../some.txt", target, true)
);
}
private File createFile(Path temp, String fileName) throws IOException { private File createFile(Path temp, String fileName) throws IOException {
File file = new File(temp.toFile(), fileName); File file = new File(temp.toFile(), fileName);
FileWriter source = new FileWriter(file); FileWriter source = new FileWriter(file);
@@ -60,7 +91,7 @@ class ModifyWorkerHelperTest {
return file; return file;
} }
private static class MinimalModifyWorkerHelper implements ModifyWorkerHelper { private class MinimalModifyWorkerHelper implements ModifyWorkerHelper {
private final Path temp; private final Path temp;
@@ -92,5 +123,10 @@ class ModifyWorkerHelperTest {
public String getBranch() { public String getBranch() {
return null; return null;
} }
@Override
public boolean isProtectedPath(Path path) {
return pathProtected;
}
} }
} }

View File

@@ -55,6 +55,28 @@ class ValidationUtilTest {
assertFalse(ValidationUtil.isFilenameValid(value)); assertFalse(ValidationUtil.isFilenameValid(value));
} }
@ParameterizedTest
@ValueSource(strings = {
"test",
"test 123"
})
void shouldAcceptPath(String value) {
// true
assertTrue(ValidationUtil.isPathValid(value));
}
@ParameterizedTest
@ValueSource(strings = {
"..",
"../",
"../../",
"../ka",
"test/../.."
})
void shouldRejectPath(String value) {
assertFalse(ValidationUtil.isPathValid(value));
}
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { @ValueSource(strings = {
"s.sdorra@ostfalia.de", "s.sdorra@ostfalia.de",

View File

@@ -176,6 +176,11 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman
} }
} }
@Override
public boolean isProtectedPath(Path path) {
return path.startsWith(getClone().getRepository().getDirectory().toPath().normalize());
}
@Override @Override
public File getWorkDir() { public File getWorkDir() {
return workDir; return workDir;

View File

@@ -34,6 +34,7 @@ import sonia.scm.AlreadyExistsException;
import sonia.scm.BadRequestException; import sonia.scm.BadRequestException;
import sonia.scm.ConcurrentModificationException; import sonia.scm.ConcurrentModificationException;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.GitTestHelper;
import sonia.scm.repository.Person; import sonia.scm.repository.Person;
import sonia.scm.repository.RepositoryHookType; import sonia.scm.repository.RepositoryHookType;
@@ -355,4 +356,18 @@ public class GitModifyCommandTest extends GitModifyCommandTestBase {
.fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.POST_RECEIVE)) .fireHookEvent(argThat(argument -> argument.getType() == RepositoryHookType.POST_RECEIVE))
); );
} }
@Test(expected = ScmConstraintViolationException.class)
public void shouldFailIfPathInGitMetadata() throws IOException {
File newFile = Files.write(temporaryFolder.newFile().toPath(), "other".getBytes()).toFile();
GitModifyCommand command = createCommand();
ModifyCommandRequest request = new ModifyCommandRequest();
request.setCommitMessage("test commit");
request.addRequest(new ModifyCommandRequest.CreateFileRequest(".git/ome.txt", newFile, true));
request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
command.execute(request);
}
} }

View File

@@ -93,6 +93,11 @@ public class HgModifyCommand extends AbstractWorkingCopyCommand implements Modif
private void addFileToHg(File file) { private void addFileToHg(File file) {
workingRepository.workingCopy().add(file.getAbsolutePath()); workingRepository.workingCopy().add(file.getAbsolutePath());
} }
@Override
public boolean isProtectedPath(Path path) {
return path.startsWith(workingRepository.getDirectory().toPath().normalize().resolve(".hg"));
}
}); });
} catch (IOException e) { } catch (IOException e) {
throwInternalRepositoryException("could not execute command on repository", e); throwInternalRepositoryException("could not execute command on repository", e);

View File

@@ -32,6 +32,7 @@ import org.junit.rules.TemporaryFolder;
import sonia.scm.AlreadyExistsException; import sonia.scm.AlreadyExistsException;
import sonia.scm.NoChangesMadeException; import sonia.scm.NoChangesMadeException;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.repository.Person; import sonia.scm.repository.Person;
import sonia.scm.repository.work.NoneCachingWorkingCopyPool; import sonia.scm.repository.work.NoneCachingWorkingCopyPool;
import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.repository.work.WorkdirProvider;
@@ -196,4 +197,17 @@ public class HgModifyCommandTest extends AbstractHgCommandTestBase {
matcher.matches(); matcher.matches();
assertThat(matcher.group(1)).isEqualTo("This is an error message"); assertThat(matcher.group(1)).isEqualTo("This is an error message");
} }
@Test(expected = ScmConstraintViolationException.class)
public void shouldFailIfPathInHgMetadata() throws IOException {
File testFile = temporaryFolder.newFile("a.txt");
new FileOutputStream(testFile).write(42);
ModifyCommandRequest request2 = new ModifyCommandRequest();
request2.addRequest(new ModifyCommandRequest.CreateFileRequest(".hg/some.txt", testFile, true));
request2.setCommitMessage("Now i really found the answer");
request2.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com"));
hgModifyCommand.execute(request2);
}
} }

View File

@@ -136,7 +136,7 @@ describe("test number validation", () => {
}); });
describe("test path validation", () => { describe("test path validation", () => {
const invalid = ["//", "some//path", "end//"]; const invalid = ["//", "some//path", "end//", ".", "..", "../"];
for (const path of invalid) { for (const path of invalid) {
it(`should return false for '${path}'`, () => { it(`should return false for '${path}'`, () => {
expect(validator.isPathValid(path)).toBe(false); expect(validator.isPathValid(path)).toBe(false);
@@ -233,3 +233,18 @@ describe("test url validation", () => {
}); });
} }
}); });
describe("test filename validation", () => {
const invalid = ["", "/", "some/file", ".", "..", "../", "\\", "\\name", "file:some"];
for (const filename of invalid) {
it(`should return false for '${filename}'`, () => {
expect(validator.isFilenameValid(filename)).toBe(false);
});
}
const valid = ["a", "test", "some_file", "end.txt", ".gitignore"];
for (const filename of valid) {
it(`should return true for '${filename}'`, () => {
expect(validator.isFilenameValid(filename)).toBe(true);
});
}
});

View File

@@ -44,10 +44,11 @@ export const isNumberValid = (number: any) => {
return !isNaN(number); return !isNaN(number);
}; };
const pathRegex = /^((?!\/{2,}).)*$/;
export const isPathValid = (path: string) => { export const isPathValid = (path: string) => {
return pathRegex.test(path); return path !== "."
&& !path.includes("../")
&& !path.includes("//")
&& path !== "..";
}; };
const urlRegex = /^[A-Za-z0-9]+:\/\/[^\s$.?#].[^\s]*$/; const urlRegex = /^[A-Za-z0-9]+:\/\/[^\s$.?#].[^\s]*$/;
@@ -55,3 +56,9 @@ const urlRegex = /^[A-Za-z0-9]+:\/\/[^\s$.?#].[^\s]*$/;
export const isUrlValid = (url: string) => { export const isUrlValid = (url: string) => {
return urlRegex.test(url); return urlRegex.test(url);
}; };
const filenameRegex = /^[^/\\:]+$/;
export const isFilenameValid = (filename: string) => {
return filenameRegex.test(filename) && filename !== "." && filename !== ".." && !filename.includes("./");
};

View File

@@ -31,8 +31,6 @@ import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
public class ManagerDaoAdapter<T extends ModelObject> { public class ManagerDaoAdapter<T extends ModelObject> {
private final GenericDAO<T> dao; private final GenericDAO<T> dao;