diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/OcflRepositoryBuilder.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/OcflRepositoryBuilder.java index 5b213670..a9778309 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/OcflRepositoryBuilder.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/OcflRepositoryBuilder.java @@ -99,7 +99,7 @@ public OcflRepositoryBuilder() { .maximumSize(512).build()); inventoryMapper = InventoryMapper.defaultMapper(); logicalPathMapper = LogicalPathMappers.directMapper(); - contentPathConstraintProcessor = ContentPathConstraints.none(); + contentPathConstraintProcessor = ContentPathConstraints.minimal(); unsupportedBehavior = UnsupportedExtensionBehavior.FAIL; ignoreUnsupportedExtensions = Collections.emptySet(); verifyStaging = true; @@ -262,7 +262,7 @@ public OcflRepositoryBuilder logicalPathMapper(LogicalPathMapper logicalPathMapp *
  • {@link ContentPathConstraints#windows()}
  • *
  • {@link ContentPathConstraints#cloud()}
  • *
  • {@link ContentPathConstraints#all()}
  • - *
  • {@link ContentPathConstraints#none()}
  • + *
  • {@link ContentPathConstraints#minimal()}
  • * * *

    Constraints should be applied that target filesystems that are NOT the local filesystem. The local filesystem diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/inventory/InventoryMapper.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/inventory/InventoryMapper.java index 63b07c4d..80a1637b 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/inventory/InventoryMapper.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/inventory/InventoryMapper.java @@ -33,6 +33,8 @@ import edu.wisc.library.ocfl.api.util.Enforce; import edu.wisc.library.ocfl.core.model.Inventory; import edu.wisc.library.ocfl.core.model.RevisionNum; +import edu.wisc.library.ocfl.core.path.constraint.ContentPathConstraintProcessor; +import edu.wisc.library.ocfl.core.path.constraint.ContentPathConstraints; import edu.wisc.library.ocfl.core.util.ObjectMappers; import java.io.BufferedInputStream; @@ -43,6 +45,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.security.DigestInputStream; +import java.util.Collection; /** * Wrapper around Jackson's ObjectMapper for serializing and deserializing Inventories. The ObjectMapper setup is a finicky @@ -51,6 +54,7 @@ public class InventoryMapper { private final ObjectMapper objectMapper; + private final ContentPathConstraintProcessor contentPathConstraints; /** * Creates an InventoryMapper that will pretty print JSON files. This should be used when you value human readability @@ -79,6 +83,7 @@ public static InventoryMapper defaultMapper() { */ public InventoryMapper(ObjectMapper objectMapper) { this.objectMapper = Enforce.notNull(objectMapper, "objectMapper cannot be null"); + this.contentPathConstraints = ContentPathConstraints.minimal(); } public void write(Path destination, Inventory inventory) { @@ -150,7 +155,7 @@ private Inventory readInternal(boolean mutableHead, String objectRootPath, ReadResult readResult) { try { - return objectMapper.reader( + Inventory inventory = objectMapper.reader( new InjectableValues.Std() .addValue("revisionNum", revisionNum) .addValue("mutableHead", mutableHead) @@ -158,6 +163,13 @@ private Inventory readInternal(boolean mutableHead, .addValue("inventoryDigest", readResult.digest)) .forType(Inventory.class) .readValue(readResult.bytes); + + // Ensure that all content paths are valid to avoid security problems due to malicious inventories + inventory.getManifest().values().stream() + .flatMap(Collection::stream) + .forEach(contentPathConstraints::apply); + + return inventory; } catch (IOException e) { throw new OcflIOException(e); } diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/ContentPathMapper.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/ContentPathMapper.java index 1d1b32b9..e871d28c 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/ContentPathMapper.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/ContentPathMapper.java @@ -60,7 +60,7 @@ public static class Builder { public Builder() { logicalPathMapper = new DirectLogicalPathMapper(); - contentPathConstraintProcessor = ContentPathConstraints.none(); + contentPathConstraintProcessor = ContentPathConstraints.minimal(); } public Builder logicalPathMapper(LogicalPathMapper logicalPathMapper) { diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraintProcessor.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraintProcessor.java index fe1ca479..1600f36c 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraintProcessor.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraintProcessor.java @@ -36,4 +36,13 @@ public interface ContentPathConstraintProcessor { */ void apply(String contentPath, String storagePath); + /** + * Applies the configured path constrains to the content path and storage path. If any constraints fail, + * a {@link edu.wisc.library.ocfl.api.exception.PathConstraintException} is thrown. + * + * @param contentPath the content path relative a version's content directory + * @throws edu.wisc.library.ocfl.api.exception.PathConstraintException when a constraint fails + */ + void apply(String contentPath); + } diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraints.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraints.java index 2b04a272..97202373 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraints.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/ContentPathConstraints.java @@ -178,11 +178,19 @@ public static ContentPathConstraintProcessor all() { } /** - * Constructs a ContentPathConstraintProcessor that does no special validation. + * Constructs a ContentPathConstraintProcessor that does the minimal content path validations as required + * by the OCFL spec. + * + *

    * * @return ContentPathConstraintProcessor */ - public static ContentPathConstraintProcessor none() { + public static ContentPathConstraintProcessor minimal() { return DefaultContentPathConstraintProcessor.builder().build(); } diff --git a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/DefaultContentPathConstraintProcessor.java b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/DefaultContentPathConstraintProcessor.java index e38ef85d..ef874e5f 100644 --- a/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/DefaultContentPathConstraintProcessor.java +++ b/ocfl-java-core/src/main/java/edu/wisc/library/ocfl/core/path/constraint/DefaultContentPathConstraintProcessor.java @@ -26,6 +26,7 @@ import edu.wisc.library.ocfl.api.util.Enforce; +import java.nio.file.FileSystems; import java.util.regex.Pattern; /** @@ -115,9 +116,12 @@ public DefaultContentPathConstraintProcessor(PathConstraintProcessor storagePath this.storagePathConstraintProcessor = Enforce.notNull(storagePathConstraintProcessor, "storagePathConstraintProcessor cannot be null"); this.contentPathConstraintProcessor = Enforce.notNull(contentPathConstraintProcessor, "contentPathConstraintProcessor cannot be null"); + if (filesystemUsesBackslashSeparator()) { + this.contentPathConstraintProcessor.prependCharConstraint(BACKSLASH_CONSTRAINT); + } + // Add the required content path constraints to the beginning of the content path constraint processor constraint list this.contentPathConstraintProcessor - .prependCharConstraint(BACKSLASH_CONSTRAINT) .prependFileNameConstraint(DOT_CONSTRAINT) .prependFileNameConstraint(NON_EMPTY_CONSTRAINT) .prependPathConstraint(TRAILING_SLASH_CONSTRAINT) @@ -133,4 +137,19 @@ public void apply(String contentPath, String storagePath) { contentPathConstraintProcessor.apply(contentPath); } + /** + * {@inheritDoc} + */ + @Override + public void apply(String contentPath) { + contentPathConstraintProcessor.apply(contentPath); + } + + private boolean filesystemUsesBackslashSeparator() { + // TODO note: this not 100% accurate because the filesystem that the repository is on may be different than the + // default filesystem + var pathSeparator = FileSystems.getDefault().getSeparator().charAt(0); + return pathSeparator == '\\'; + } + } diff --git a/ocfl-java-itest/src/test/java/edu/wisc/library/ocfl/itest/OcflITest.java b/ocfl-java-itest/src/test/java/edu/wisc/library/ocfl/itest/OcflITest.java index c611c730..06200ae1 100644 --- a/ocfl-java-itest/src/test/java/edu/wisc/library/ocfl/itest/OcflITest.java +++ b/ocfl-java-itest/src/test/java/edu/wisc/library/ocfl/itest/OcflITest.java @@ -39,6 +39,7 @@ import edu.wisc.library.ocfl.itest.ext.TestLayoutExtensionConfig; import edu.wisc.library.ocfl.test.OcflAsserts; import edu.wisc.library.ocfl.test.TestHelper; +import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,6 +48,7 @@ import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -88,6 +90,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; public abstract class OcflITest { @@ -860,6 +863,21 @@ public void failGetObjectWhenInvalidDigestAlgorithmUsed() { }).getMessage(), containsString("digestAlgorithm must be sha512 or sha256")); } + @Test + public void failReadingObjectWhenHasMaliciousPath() { + var repoName = "malicious-content-paths"; + var repoDir = sourceRepoPath(repoName); + var repo = existingRepo(repoName, repoDir); + + assertThrows(PathConstraintException.class, () -> { + try (var stream = repo.getObject(ObjectVersionId.head("urn:example:bad")) + .getFile("file.txt").getStream()) { + IOUtils.toString(stream, StandardCharsets.UTF_8); + fail("Should not have read file"); + } + }); + } + @Test public void putObjectWithDuplicateFiles() { var repoName = "repo8"; diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/0=ocfl_1.0 b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/0=ocfl_1.0 new file mode 100644 index 00000000..03c9c3d3 --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/0=ocfl_1.0 @@ -0,0 +1 @@ +ocfl_1.0 diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/0=ocfl_object_1.0 b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/0=ocfl_object_1.0 new file mode 100644 index 00000000..4d1d62c7 --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/0=ocfl_object_1.0 @@ -0,0 +1 @@ +ocfl_object_1.0 diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json new file mode 100644 index 00000000..7f6a95b6 --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json @@ -0,0 +1,26 @@ +{ + "id": "urn:example:bad", + "type": "https://ocfl.io/1.0/spec/#inventory", + "digestAlgorithm": "sha512", + "head": "v1", + "contentDirectory": "content", + "manifest": { + "0e3e75234abc68f4378a86b3f4b32a198ba301845b0cd6e50106e874345700cc6663a86c1ea125dc5e92be17c98f9a0f85ca9d5f595db2012f7cc3571945c123": [ + "v1/content/../../../../../../0=ocfl_1.0" + ] + }, + "versions": { + "v1": { + "created": "2022-02-11T13:36:06.106847171-06:00", + "state": { + "0e3e75234abc68f4378a86b3f4b32a198ba301845b0cd6e50106e874345700cc6663a86c1ea125dc5e92be17c98f9a0f85ca9d5f595db2012f7cc3571945c123": [ + "file.txt" + ] + }, + "user": { + "name": "Peter Winckles", + "address": "mailto:pwinckles@pm.me" + } + } + } +} diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json.sha512 b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json.sha512 new file mode 100644 index 00000000..f98cf70b --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/inventory.json.sha512 @@ -0,0 +1 @@ +d7cbc89e4f8f79d363d1543cb68c893dc2ad7d905f88e96e5694eeaba41d878496f7234c0eacf8ad437dfc8bfb583e16bc2850657209eb0ba30ff7bf3ca5b74a inventory.json diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/content/file.txt b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/content/file.txt new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/content/file.txt @@ -0,0 +1 @@ +test diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json new file mode 100644 index 00000000..7f6a95b6 --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json @@ -0,0 +1,26 @@ +{ + "id": "urn:example:bad", + "type": "https://ocfl.io/1.0/spec/#inventory", + "digestAlgorithm": "sha512", + "head": "v1", + "contentDirectory": "content", + "manifest": { + "0e3e75234abc68f4378a86b3f4b32a198ba301845b0cd6e50106e874345700cc6663a86c1ea125dc5e92be17c98f9a0f85ca9d5f595db2012f7cc3571945c123": [ + "v1/content/../../../../../../0=ocfl_1.0" + ] + }, + "versions": { + "v1": { + "created": "2022-02-11T13:36:06.106847171-06:00", + "state": { + "0e3e75234abc68f4378a86b3f4b32a198ba301845b0cd6e50106e874345700cc6663a86c1ea125dc5e92be17c98f9a0f85ca9d5f595db2012f7cc3571945c123": [ + "file.txt" + ] + }, + "user": { + "name": "Peter Winckles", + "address": "mailto:pwinckles@pm.me" + } + } + } +} diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json.sha512 b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json.sha512 new file mode 100644 index 00000000..f98cf70b --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/692/287/678/692287678fe1a8b750e230228b0e7bb145eca11fa7751a8ef336bd68962e1a5e/v1/inventory.json.sha512 @@ -0,0 +1 @@ +d7cbc89e4f8f79d363d1543cb68c893dc2ad7d905f88e96e5694eeaba41d878496f7234c0eacf8ad437dfc8bfb583e16bc2850657209eb0ba30ff7bf3ca5b74a inventory.json diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/extensions/0004-hashed-n-tuple-storage-layout/config.json b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/extensions/0004-hashed-n-tuple-storage-layout/config.json new file mode 100644 index 00000000..fc5582ed --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/extensions/0004-hashed-n-tuple-storage-layout/config.json @@ -0,0 +1,7 @@ +{ + "extensionName": "0004-hashed-n-tuple-storage-layout", + "digestAlgorithm": "sha256", + "tupleSize": 3, + "numberOfTuples": 3, + "shortObjectRoot": false +} \ No newline at end of file diff --git a/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/ocfl_layout.json b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/ocfl_layout.json new file mode 100644 index 00000000..2910b98e --- /dev/null +++ b/ocfl-java-itest/src/test/resources/sources/repos/malicious-content-paths/ocfl_layout.json @@ -0,0 +1,4 @@ +{ + "extension": "0004-hashed-n-tuple-storage-layout", + "description": "See specification document 0004-hashed-n-tuple-storage-layout.md" +} \ No newline at end of file