diff --git a/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java b/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java index bff8825f8..39c7ed17f 100644 --- a/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java +++ b/src/main/java/org/cryptomator/launcher/VaultCreationEvent.java @@ -24,6 +24,8 @@ import java.util.Optional; public record VaultCreationEvent(String name, byte[] template) implements AppLaunchEvent { private static final Logger LOG = LoggerFactory.getLogger(VaultCreationEvent.class); + private static final int MAX_NAME_LENGTH = 256; + private static final String FILE_SEPARATOR = System.getProperty("file.separator"); private static final String SCHEME = "cryptomator"; private static final String HOST = "vault"; private static final String PATH = "/create"; @@ -43,32 +45,57 @@ public record VaultCreationEvent(String name, byte[] template) implements AppLau return Optional.empty(); } var params = parseParams(uri.getRawFragment()); + var name = params.get("name"); - var templateParam = params.get("template"); if (name == null || name.isBlank()) { throw new IllegalArgumentException("Missing required fragment parameter 'name'."); } + validateName(name); + + var templateParam = params.get("template"); if (templateParam == null || templateParam.isEmpty()) { throw new IllegalArgumentException("Missing required fragment parameter 'template'."); } - validateName(name); byte[] template; try { template = Base64.getUrlDecoder().decode(templateParam); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Fragment parameter 'template' is not valid Base64URL.", e); } + var leftoverParams = params.keySet().stream().filter(k -> !(k.equals("template") || k.equals("name"))).toList(); if (!leftoverParams.isEmpty()) { LOG.debug("Ignoring unknown parameters {}", leftoverParams); } + return Optional.of(new VaultCreationEvent(name, template)); } + //TODO: what does Cryptomator Hub allow in vault names? private static void validateName(String name) { - if (name.contains("/") || name.contains("\\") || name.contains("..") || name.equals(".")) { + if (name.codePointCount(0, name.length()) > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("Fragment parameter 'name' must not exceed " + MAX_NAME_LENGTH + " characters."); + } + if (name.contains("/") || name.contains("\\") || name.contains(FILE_SEPARATOR) || name.contains("..") || name.equals(".")) { throw new IllegalArgumentException("Fragment parameter 'name' must be a single path segment, but was '" + name + "'."); } + if (!name.equals(name.stripTrailing())) { + // Windows silently strips these, so the directory would not match the requested name + throw new IllegalArgumentException("Fragment parameter 'name' must not end with whitespace, but was '" + name + "'."); + } + // invisible characters (control chars, bidi overrides such as U+202E, zero-width joiners, ...) let a name + // render deceptively, e.g. "Rechnunggnp.exe" showing up as "Rechnungexe.png" + var offendingCodePoint = name.codePoints().filter(VaultCreationEvent::isInvisible).findFirst(); + if (offendingCodePoint.isPresent()) { + throw new IllegalArgumentException("Fragment parameter 'name' must not contain invisible characters, but contained U+%04X.".formatted(offendingCodePoint.getAsInt())); + } + } + + private static boolean isInvisible(int codePoint) { + return switch (Character.getType(codePoint)) { + case Character.CONTROL, Character.FORMAT, Character.SURROGATE, Character.PRIVATE_USE, Character.UNASSIGNED -> true; + default -> false; + }; } private static Map parseParams(String rawParams) { diff --git a/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java b/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java index db6e9e228..056c4bfb7 100644 --- a/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java +++ b/src/test/java/org/cryptomator/launcher/VaultCreationEventTest.java @@ -3,6 +3,8 @@ package org.cryptomator.launcher; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -41,6 +43,51 @@ public class VaultCreationEventTest { Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri)); } + @ParameterizedTest + @DisplayName("a name with a disallowed character is rejected") + @ValueSource(strings = { // + "foo%2Fbar", // path separator + "foo%5Cbar", // backslash + "..", // parent directory + ".", // this directory + "foo%20", // trailing whitespace + "foo%09", // trailing tab + "Rechnung%E2%80%AEgnp.exe", // U+202E right-to-left override + "foo%E2%80%8Bbar", // U+200B zero-width space + "foo%00bar" // NUL + }) + public void testDisallowedName(String encodedName) { + var uri = URI.create("cryptomator://vault/create#name=" + encodedName + "&template=" + TEMPLATE_B64); + + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri)); + } + + @Test + @DisplayName("a name of exactly the maximum length is accepted") + public void testNameAtLengthLimit() { + var name = "a".repeat(256); + + var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=" + name + "&template=" + TEMPLATE_B64)).orElseThrow(); + + Assertions.assertEquals(name, inTest.name()); + } + + @Test + @DisplayName("a name exceeding the maximum length is rejected") + public void testNameExceedsLengthLimit() { + var uri = URI.create("cryptomator://vault/create#name=" + "a".repeat(257) + "&template=" + TEMPLATE_B64); + + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(uri)); + } + + @Test + @DisplayName("a name with non-ASCII letters is accepted") + public void testNameWithUmlauts() { + var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create#name=Gesch%C3%A4ftsberichte&template=" + TEMPLATE_B64)).orElseThrow(); + + Assertions.assertEquals("Geschäftsberichte", inTest.name()); + } + @Test @DisplayName("a URL-encoded name is decoded") public void testUrlEncodedName() {