You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2026/08/21 01:59:08 UTC

(james-project) branch 3.9.x updated: [FIX] Better validate script names in SieveFileRepository (#3111) (#3125)

This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch 3.9.x
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/3.9.x by this push:
     new bbc122eb1a [FIX] Better validate script names in SieveFileRepository (#3111) (#3125)
bbc122eb1a is described below

commit bbc122eb1a121f19c6b9271c3ad2ac2948bb686e
Author: Benoit TELLIER <bt...@linagora.com>
AuthorDate: Fri Aug 21 08:59:04 2026 +0700

    [FIX] Better validate script names in SieveFileRepository (#3111) (#3125)
---
 .../sieverepository/file/SieveFileRepository.java  | 45 ++++++++++----
 .../file/SieveFileRepositoryTest.java              | 71 ++++++++++++++++++++++
 2 files changed, 104 insertions(+), 12 deletions(-)

diff --git a/server/data/data-file/src/main/java/org/apache/james/sieverepository/file/SieveFileRepository.java b/server/data/data-file/src/main/java/org/apache/james/sieverepository/file/SieveFileRepository.java
index f6720b8034..7d9a8f5226 100644
--- a/server/data/data-file/src/main/java/org/apache/james/sieverepository/file/SieveFileRepository.java
+++ b/server/data/data-file/src/main/java/org/apache/james/sieverepository/file/SieveFileRepository.java
@@ -30,12 +30,14 @@ import java.io.InputStream;
 import java.io.OutputStreamWriter;
 import java.io.Writer;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.util.Arrays;
 import java.util.List;
 import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Scanner;
 import java.util.function.Predicate;
@@ -237,8 +239,7 @@ public class SieveFileRepository implements SieveRepository {
     @Override
     public void putScript(Username username, ScriptName name, ScriptContent content) throws StorageException, QuotaExceededException {
         synchronized (lock) {
-            File file = new File(getUserDirectory(username), name.getValue());
-            enforceRoot(file);
+            File file = resolveUserScript(username, name);
             haveSpace(username, name, content.length());
             toFile(file, content.getValue());
         }
@@ -249,8 +250,7 @@ public class SieveFileRepository implements SieveRepository {
             throws ScriptNotFoundException, DuplicateException, StorageException {
         synchronized (lock) {
             File oldFile = getScriptFile(username, oldName);
-            File newFile = new File(getUserDirectory(username), newName.getValue());
-            enforceRoot(newFile);
+            File newFile = resolveUserScript(username, newName);
             if (newFile.exists()) {
                 throw new DuplicateException("User: " + username.asString() + "Script: " + newName);
             }
@@ -330,6 +330,33 @@ public class SieveFileRepository implements SieveRepository {
         }
     }
 
+    /**
+     * Resolves a script name within the directory of its owner.
+     *
+     * Enforcing the sieve root alone is not enough: it prevents escaping the repository but still lets a crafted
+     * name reach a sibling user directory, and thus lets one user write another user's scripts and '.active' marker.
+     * Scripts therefore need to resolve as a direct child of their own user directory.
+     */
+    private File resolveInUserDirectory(File userDirectory, String name) throws StorageException {
+        if (name == null || name.trim().isEmpty()) {
+            throw new StorageException(new IllegalArgumentException("Script name should not be empty"));
+        }
+        if (SYSTEM_FILES.contains(name)) {
+            throw new StorageException(new IllegalArgumentException("Script name should not collide with system file '" + name + "'"));
+        }
+        File file = new File(userDirectory, name);
+        Path parent = file.toPath().normalize().getParent();
+        if (!Objects.equals(parent, userDirectory.toPath().normalize())) {
+            throw new StorageException(new IllegalArgumentException("Script name should not allow path traversal outside of the user directory"));
+        }
+        enforceRoot(file);
+        return file;
+    }
+
+    private File resolveUserScript(Username username, ScriptName name) throws StorageException {
+        return resolveInUserDirectory(getUserDirectory(username), name.getValue());
+    }
+
     protected File getUserDirectoryFile(Username username) throws StorageException {
         final File userFile = new File(getSieveRootDirectory(), username.asString() + '/');
         enforceRoot(userFile);
@@ -344,9 +371,7 @@ public class SieveFileRepository implements SieveRepository {
         } catch (FileNotFoundException ex) {
             throw new ScriptNotFoundException("There is no active script for user " + username.asString());
         }
-        File scriptFile = new File(dir, content);
-        enforceRoot(scriptFile);
-        return scriptFile;
+        return resolveInUserDirectory(dir, content);
     }
 
     protected boolean isActiveFile(Username username, File file) throws StorageException {
@@ -382,11 +407,7 @@ public class SieveFileRepository implements SieveRepository {
     }
 
     protected File getScriptFile(Username username, ScriptName name) throws ScriptNotFoundException, StorageException {
-        if (name.getValue().contains("/")) {
-            throw new StorageException(new IllegalArgumentException("Script name should not contain '/' as it can allow path traversal"));
-        }
-        File file = new File(getUserDirectory(username), name.getValue());
-        enforceRoot(file);
+        File file = resolveUserScript(username, name);
         if (!file.exists()) {
             throw new ScriptNotFoundException("User: " + username + "Script: " + name);
         }
diff --git a/server/data/data-file/src/test/java/org/apache/james/sieverepository/file/SieveFileRepositoryTest.java b/server/data/data-file/src/test/java/org/apache/james/sieverepository/file/SieveFileRepositoryTest.java
index e9851015f1..3dc09585d6 100644
--- a/server/data/data-file/src/test/java/org/apache/james/sieverepository/file/SieveFileRepositoryTest.java
+++ b/server/data/data-file/src/test/java/org/apache/james/sieverepository/file/SieveFileRepositoryTest.java
@@ -19,12 +19,14 @@
 
 package org.apache.james.sieverepository.file;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 
 import org.apache.commons.io.FileUtils;
 import org.apache.james.core.Username;
@@ -110,4 +112,73 @@ class SieveFileRepositoryTest implements SieveRepositoryContract {
             new ScriptName("../other/script")))
             .isInstanceOf(StorageException.class);
     }
+
+    @Test
+    void putScriptShouldNotAllowToWriteScriptsOfOtherUsers() throws Exception {
+        sieveRepository().putScript(Username.of("victim"), new ScriptName("script"), SCRIPT_CONTENT);
+
+        assertThatThrownBy(() -> sieveRepository().putScript(Username.of("attacker"),
+                new ScriptName("../victim/script"), new ScriptContent("PWND!!!")))
+            .isInstanceOf(StorageException.class);
+
+        assertThat(sieveRepository().getScript(Username.of("victim"), new ScriptName("script")))
+            .hasContent(SCRIPT_CONTENT.getValue());
+    }
+
+    @Test
+    void putScriptShouldNotAllowToOverwriteTheActiveMarkerOfOtherUsers() throws Exception {
+        sieveRepository().putScript(Username.of("victim"), new ScriptName("script"), SCRIPT_CONTENT);
+
+        assertThatThrownBy(() -> sieveRepository().putScript(Username.of("attacker"),
+                new ScriptName("../victim/.active"), new ScriptContent("script")))
+            .isInstanceOf(StorageException.class);
+
+        assertThat(new File(fileSystem.getFile(SIEVE_ROOT), "victim/.active")).doesNotExist();
+    }
+
+    @Test
+    void putScriptShouldNotAllowToOverwriteSystemFiles() {
+        assertThatThrownBy(() -> sieveRepository().putScript(Username.of("test"),
+                new ScriptName(".active"), SCRIPT_CONTENT))
+            .isInstanceOf(StorageException.class);
+
+        assertThatThrownBy(() -> sieveRepository().putScript(Username.of("test"),
+                new ScriptName(".quota"), SCRIPT_CONTENT))
+            .isInstanceOf(StorageException.class);
+    }
+
+    @Test
+    void putScriptShouldNotAllowToOverwriteTheGlobalQuotaFile() throws Exception {
+        assertThatThrownBy(() -> sieveRepository().putScript(Username.of("test"),
+                new ScriptName("../.quota"), new ScriptContent("1")))
+            .isInstanceOf(StorageException.class);
+
+        assertThat(new File(fileSystem.getFile(SIEVE_ROOT), ".quota")).doesNotExist();
+    }
+
+    @Test
+    void renameScriptShouldNotAllowToWriteScriptsOfOtherUsers() throws Exception {
+        Username attacker = Username.of("attacker");
+        sieveRepository().putScript(Username.of("victim"), new ScriptName("script"), SCRIPT_CONTENT);
+        sieveRepository().putScript(attacker, new ScriptName("evil"), new ScriptContent("PWND!!!"));
+        sieveRepository().setActive(attacker, new ScriptName("evil"));
+
+        assertThatThrownBy(() -> sieveRepository().renameScript(attacker,
+                new ScriptName("evil"), new ScriptName("../victim/evil")))
+            .isInstanceOf(StorageException.class);
+
+        assertThat(new File(fileSystem.getFile(SIEVE_ROOT), "victim/evil")).doesNotExist();
+        assertThat(new File(fileSystem.getFile(SIEVE_ROOT), "victim/.active")).doesNotExist();
+    }
+
+    @Test
+    void getActiveShouldNotFollowACraftedActiveMarker() throws Exception {
+        sieveRepository().putScript(Username.of("other"), new ScriptName("script"), new ScriptContent("PWND!!!"));
+        sieveRepository().putScript(Username.of("test"), new ScriptName("script"), SCRIPT_CONTENT);
+        FileUtils.write(new File(fileSystem.getFile(SIEVE_ROOT), "test/.active"),
+            "../other/script", StandardCharsets.UTF_8);
+
+        assertThatThrownBy(() -> sieveRepository().getActive(Username.of("test")))
+            .isInstanceOf(StorageException.class);
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org