restrict value of name parameter in deeplink

no path separator, no invisible chars, no trailing whitespaces

Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Armin Schrenk
2026-07-21 11:50:31 +02:00
parent 222e1f18ad
commit e7db172a89
2 changed files with 77 additions and 3 deletions
@@ -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. "Rechnung<U+202E>gnp.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<String, String> parseParams(String rawParams) {
@@ -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() {