Skip to content

fix(db): file-deletion#16621

Merged
alperozturk96 merged 2 commits intomasterfrom
fix/file-deletion
Mar 4, 2026
Merged

fix(db): file-deletion#16621
alperozturk96 merged 2 commits intomasterfrom
fix/file-deletion

Conversation

@alperozturk96
Copy link
Collaborator

@alperozturk96 alperozturk96 commented Mar 3, 2026

Issue

Child of directory is not gets deleted. getContentProviderClient() is always null and getContentResolver().delete(folderUri, where, whereArgs); not removing the childs just deletes folder itself see where logic

String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + "=?";
String[] whereArgs = new String[]{user.getAccountName(), folder.getRemotePath()};

.

Changes

Replaces content resolver functions with DAO functions.
Applies fail fast princible and adds logs to follow execution flow more clearly.
Adds tests.

@alperozturk96
Copy link
Collaborator Author

/backport to stable-3.36


Log_OC.d(TAG, "removeLocalCopyIfNeeded: deleting local file -> " + localPath);

boolean success = new File(localPath).delete();

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

In general, to fix uncontrolled path usage you either validate the incoming path against a safe base directory or ensure that only application-generated paths (not arbitrary user input) are used. Here, we can keep the existing behavior but add a safety check before deleting a file based on OCFile.getStoragePath(). The key idea is: compute the canonical path of the candidate file and of the application’s configured storage root, and only proceed with deletion if the file path is within (a descendant of) that root. This directly mitigates the risk of a malicious or corrupted storagePath pointing outside the intended storage area.

The best minimal fix, without changing higher-level behavior, is in FileDataStorageManager.removeLocalCopyIfNeeded. Right before constructing new File(localPath) and deleting it, we should:

  1. Obtain the current configured storage root from MainApp.getStoragePath().
  2. If that storage root is empty/null, we bail out (no deletion).
  3. Resolve both the storage root and the target file to canonical paths (getCanonicalFile()).
  4. Check fileCanonical.getPath().startsWith(rootCanonical.getPath() + File.separator).
  5. If the check fails, log a warning and skip deletion.

This ensures that even if localPath has been tainted (for example, via a compromised database entry or weird storage configuration), we will not delete anything outside the app’s own storage tree. We do not need extra imports since java.io.File is already in use. All changes are confined to the existing method body in FileDataStorageManager and do not alter public APIs or other behavior except for refusing to delete files outside the configured storage root.


Suggested changeset 1
app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
--- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
@@ -930,9 +930,35 @@
             return true;
         }
 
+        // Ensure that the local path is within the configured storage root before deleting
+        String storageRoot = MainApp.getStoragePath();
+        if (TextUtils.isEmpty(storageRoot)) {
+            Log_OC.e(TAG, "removeLocalCopyIfNeeded: storage root is not configured, skip deletion for " + localPath);
+            return false;
+        }
+
+        File targetFile = new File(localPath);
+        File rootDir = new File(storageRoot);
+
+        try {
+            File targetCanonical = targetFile.getCanonicalFile();
+            File rootCanonical = rootDir.getCanonicalFile();
+
+            String rootPath = rootCanonical.getPath();
+            String targetPath = targetCanonical.getPath();
+
+            if (!targetPath.startsWith(rootPath + File.separator)) {
+                Log_OC.e(TAG, "removeLocalCopyIfNeeded: refusing to delete file outside storage root: " + targetPath);
+                return false;
+            }
+        } catch (IOException e) {
+            Log_OC.e(TAG, "removeLocalCopyIfNeeded: error resolving canonical paths for " + localPath, e);
+            return false;
+        }
+
         Log_OC.d(TAG, "removeLocalCopyIfNeeded: deleting local file -> " + localPath);
 
-        boolean success = new File(localPath).delete();
+        boolean success = targetFile.delete();
         Log_OC.d(TAG, "removeLocalCopyIfNeeded: file deletion result=" + success);
 
         if (!success) {
EOF
@@ -930,9 +930,35 @@
return true;
}

// Ensure that the local path is within the configured storage root before deleting
String storageRoot = MainApp.getStoragePath();
if (TextUtils.isEmpty(storageRoot)) {
Log_OC.e(TAG, "removeLocalCopyIfNeeded: storage root is not configured, skip deletion for " + localPath);
return false;
}

File targetFile = new File(localPath);
File rootDir = new File(storageRoot);

try {
File targetCanonical = targetFile.getCanonicalFile();
File rootCanonical = rootDir.getCanonicalFile();

String rootPath = rootCanonical.getPath();
String targetPath = targetCanonical.getPath();

if (!targetPath.startsWith(rootPath + File.separator)) {
Log_OC.e(TAG, "removeLocalCopyIfNeeded: refusing to delete file outside storage root: " + targetPath);
return false;
}
} catch (IOException e) {
Log_OC.e(TAG, "removeLocalCopyIfNeeded: error resolving canonical paths for " + localPath, e);
return false;
}

Log_OC.d(TAG, "removeLocalCopyIfNeeded: deleting local file -> " + localPath);

boolean success = new File(localPath).delete();
boolean success = targetFile.delete();
Log_OC.d(TAG, "removeLocalCopyIfNeeded: file deletion result=" + success);

if (!success) {
Copilot is powered by AI and may make mistakes. Always verify output.
ocFile.setStoragePath(null);
saveFile(ocFile);
}
if (!localFolder.exists()) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

In general, the problem should be fixed by validating and constraining any user-controllable storage path before it is persisted or used to construct file operation paths. For a “root storage location” setting, the safe approach is to ensure the chosen directory is within a set of allowed base directories (e.g., the app’s internal files dir, app-specific external storage, or other app-managed roots), and that the path is normalized (no .. traversal, no control characters). Storing only validated paths in MainApp.storagePath ensures all downstream uses (like FileStorageUtils.getSavePath and getDefaultSavePathFor) are safe.

The best fix with minimal functional change is:

  1. Introduce a reusable validator in FileStorageUtils that:

    • Rejects null/empty inputs.
    • Normalizes the candidate path (canonicalPath).
    • Ensures it is inside one of the allowed roots for the app (for example, the app’s internal files directory and/or an app-specific external files directory).
    • Optionally enforces a maximum length and disallows control characters.
      The method should return a boolean indicating whether the path is acceptable.
  2. Use this validator at the point where the path first becomes persistent application state:

    • In SettingsActivity.saveStoragePath(String newStoragePath), check FileStorageUtils.isValidStorageRoot(getApplicationContext(), newStoragePath) (or similar).
    • If invalid, do not persist it and show an error to the user (or silently fall back to the previous/default storage path). This prevents MainApp.setStoragePath from ever seeing an unsafe value.
  3. Optionally, add a defensive check in MainApp.setStoragePath(String path) to avoid storing obviously invalid values (e.g., null/empty). However, the core security fix is at the UI/settings layer.

  4. No changes are required in FileDataStorageManager.removeLocalFolder itself, because once MainApp.storagePath is guaranteed to be valid, FileStorageUtils.getDefaultSavePathFor and the paths it builds become safe by construction.

Concretely:

  • Edit FileStorageUtils.java to add:

    • An import for android.os.Environment is already present; we can also safely use Context which is already imported.
    • A new public static method isValidStorageRoot(Context context, String path) that performs normalization and containment checks using new File(path).getCanonicalPath() and compares it to allowed base directories obtained from context.getFilesDir() and context.getExternalFilesDir(null) (if non-null).
  • Edit SettingsActivity.java:

    • In saveStoragePath(String newStoragePath), before assigning to storagePath and calling MainApp.setStoragePath, call FileStorageUtils.isValidStorageRoot(getApplicationContext(), newStoragePath).
    • If validation fails, log and either keep the existing storagePath or fall back to the default (getFilesDir().getAbsolutePath()); do not save the unvalidated path.

These changes keep existing behavior for normal, valid inputs but prevent arbitrary or malicious paths from being used for storage or deletion.


Suggested changeset 2
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
--- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
@@ -1188,7 +1188,18 @@
      */
     private void saveStoragePath(String newStoragePath) {
         SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
-        storagePath = newStoragePath;
+
+        // Validate the new storage path to avoid using arbitrary file system locations.
+        if (!FileStorageUtils.isValidStorageRoot(getApplicationContext(), newStoragePath)) {
+            Log_OC.e(TAG, "Refusing to save invalid storage path: " + newStoragePath);
+            // Keep existing storagePath; if none is set yet, ensure a safe default.
+            if (storagePath == null) {
+                storagePath = getApplicationContext().getFilesDir().getAbsolutePath();
+            }
+        } else {
+            storagePath = newStoragePath;
+        }
+
         MainApp.setStoragePath(storagePath);
         SharedPreferences.Editor editor = appPrefs.edit();
         editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
EOF
@@ -1188,7 +1188,18 @@
*/
private void saveStoragePath(String newStoragePath) {
SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
storagePath = newStoragePath;

// Validate the new storage path to avoid using arbitrary file system locations.
if (!FileStorageUtils.isValidStorageRoot(getApplicationContext(), newStoragePath)) {
Log_OC.e(TAG, "Refusing to save invalid storage path: " + newStoragePath);
// Keep existing storagePath; if none is set yet, ensure a safe default.
if (storagePath == null) {
storagePath = getApplicationContext().getFilesDir().getAbsolutePath();
}
} else {
storagePath = newStoragePath;
}

MainApp.setStoragePath(storagePath);
SharedPreferences.Editor editor = appPrefs.edit();
editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
--- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
+++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
@@ -157,6 +157,48 @@
     }
 
     /**
+     * Validate that a candidate storage root path is inside an app-controlled directory.
+     *
+     * This prevents arbitrary paths from being used as storage roots, which could otherwise
+     * lead to unintended access or deletion of files outside the app's sandbox.
+     *
+     * @param context application context used to resolve allowed base directories
+     * @param path    candidate storage root path
+     * @return true if the path is considered safe to use as a storage root, false otherwise
+     */
+    public static boolean isValidStorageRoot(Context context, String path) {
+        if (context == null || TextUtils.isEmpty(path)) {
+            return false;
+        }
+
+        try {
+            File candidate = new File(path);
+            String candidateCanonical = candidate.getCanonicalPath();
+
+            // Internal app files directory (always present)
+            File internalDir = context.getFilesDir();
+            String internalCanonical = internalDir != null ? internalDir.getCanonicalPath() : null;
+
+            // App-specific external files directory (may be null)
+            File externalDir = context.getExternalFilesDir(null);
+            String externalCanonical = externalDir != null ? externalDir.getCanonicalPath() : null;
+
+            // Require the candidate to be within one of the allowed roots
+            if (internalCanonical != null && candidateCanonical.startsWith(internalCanonical)) {
+                return true;
+            }
+            if (externalCanonical != null && candidateCanonical.startsWith(externalCanonical)) {
+                return true;
+            }
+
+            return false;
+        } catch (IOException e) {
+            Log_OC.e("FileStorageUtils", "Error validating storage root path: " + path, e);
+            return false;
+        }
+    }
+
+    /**
      * Get local owncloud storage path for accountName.
      */
     public static String getSavePath(String accountName) {
EOF
@@ -157,6 +157,48 @@
}

/**
* Validate that a candidate storage root path is inside an app-controlled directory.
*
* This prevents arbitrary paths from being used as storage roots, which could otherwise
* lead to unintended access or deletion of files outside the app's sandbox.
*
* @param context application context used to resolve allowed base directories
* @param path candidate storage root path
* @return true if the path is considered safe to use as a storage root, false otherwise
*/
public static boolean isValidStorageRoot(Context context, String path) {
if (context == null || TextUtils.isEmpty(path)) {
return false;
}

try {
File candidate = new File(path);
String candidateCanonical = candidate.getCanonicalPath();

// Internal app files directory (always present)
File internalDir = context.getFilesDir();
String internalCanonical = internalDir != null ? internalDir.getCanonicalPath() : null;

// App-specific external files directory (may be null)
File externalDir = context.getExternalFilesDir(null);
String externalCanonical = externalDir != null ? externalDir.getCanonicalPath() : null;

// Require the candidate to be within one of the allowed roots
if (internalCanonical != null && candidateCanonical.startsWith(internalCanonical)) {
return true;
}
if (externalCanonical != null && candidateCanonical.startsWith(externalCanonical)) {
return true;
}

return false;
} catch (IOException e) {
Log_OC.e("FileStorageUtils", "Error validating storage root path: " + path, e);
return false;
}
}

/**
* Get local owncloud storage path for accountName.
*/
public static String getSavePath(String accountName) {
Copilot is powered by AI and may make mistakes. Always verify output.
File localFile = new File(ocFile.getStoragePath());
Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + ocFile.getStoragePath());

boolean deleted = localFile.delete();

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

In general, to fix “uncontrolled data used in path expression” issues, we must ensure that any path derived from user-controllable input is either (a) validated/normalized against an allowlist of safe locations or (b) constrained to a specific directory tree, rejecting anything that escapes that tree. For this code path, the user controls the storage root path through settings; that root then influences where per-account files are stored and what storagePath the OCFile model holds. Deletion in removeLocalFolder must not operate on files outside the current storage root.

The best minimal fix without changing behaviour is to: (1) validate and normalize the storage root when it is saved (in SettingsActivity.saveStoragePath), and (2) guard removeLocalFolder so that it only deletes files whose storagePath is under the configured storage root. We will add a static helper to FileStorageUtils that encapsulates “safe storage root validation” and reuse it in both places.

Concrete changes:

  1. Add validation helper in FileStorageUtils

    • Implement a public static method, e.g. ensureValidStoragePath(String requestedPath), that:
      • Returns the default internal storage path if requestedPath is null/empty.
      • Attempts to canonicalize the requested path (new File(requestedPath).getCanonicalFile()).
      • Canonicalizes the app’s internal files directory root (getAppContext().getFilesDir()).
      • If the requested path is not under some acceptable base (here, at least not under the root path / alone), we still accept it but ensure we store the canonical absolute path (to remove .., symlinks, etc.).
        To satisfy the CodeQL rule more robustly and avoid directory traversal, we check that the canonical requested path is an absolute path and not equal to / and not containing .. segments; otherwise we fall back to default internal storage.
    • This helper only uses standard java.io.File and MainApp.getAppContext(), which are already present, so no new dependencies are needed.
  2. Use validation when saving storage path in SettingsActivity

    • In saveStoragePath(String newStoragePath), instead of directly assigning storagePath = newStoragePath, we call FileStorageUtils.ensureValidStoragePath(newStoragePath) and store that safe/normalized result. This ensures that MainApp.setStoragePath and subsequent FileStorageUtils.getSavePath calls operate on a sanitized root path.
  3. Restrict deletions in FileDataStorageManager.removeLocalFolder

    • Before deleting per-file paths, we ensure that the ocFile.getStoragePath() is non-null and lies under the current storage root (MainApp.getStoragePath()), using canonical path comparison (startsWith on canonical paths). If the file is outside the storage root, we skip deletion and log a warning instead of deleting it.
    • This prevents accidental deletion of arbitrary files if a storagePath in the DB is inconsistent with the configured storage root, and clearly constrains deletion to the intended data tree.

These changes leave the functional behaviour unchanged for valid, normal configurations, but add guardrails and satisfy the requirement to validate user-influenced paths before using them for file operations.


Suggested changeset 3
app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
--- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
@@ -1023,18 +1023,36 @@
 
             } else if (ocFile.isDown()) {
 
-                File localFile = new File(ocFile.getStoragePath());
-                Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + ocFile.getStoragePath());
+                String storagePath = ocFile.getStoragePath();
+                if (TextUtils.isEmpty(storagePath)) {
+                    Log_OC.w(TAG, "removeLocalFolder: skipping deletion, storage path is empty");
+                    continue;
+                }
 
-                boolean deleted = localFile.delete();
-                success = deleted;
+                try {
+                    File localFile = new File(storagePath);
+                    File canonicalLocalFile = localFile.getCanonicalFile();
+                    File storageRoot = new File(MainApp.getStoragePath()).getCanonicalFile();
 
-                Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted);
+                    if (!canonicalLocalFile.getPath().startsWith(storageRoot.getPath() + File.separator)) {
+                        Log_OC.w(TAG, "removeLocalFolder: refusing to delete file outside storage root: " + canonicalLocalFile);
+                        continue;
+                    }
 
-                if (deleted) {
-                    deleteFileInMediaScan(ocFile.getStoragePath());
-                    ocFile.setStoragePath(null);
-                    saveFile(ocFile);
+                    Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + canonicalLocalFile.getAbsolutePath());
+                    boolean deleted = canonicalLocalFile.delete();
+                    success = deleted;
+
+                    Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted);
+
+                    if (deleted) {
+                        deleteFileInMediaScan(canonicalLocalFile.getAbsolutePath());
+                        ocFile.setStoragePath(null);
+                        saveFile(ocFile);
+                    }
+                } catch (IOException e) {
+                    Log_OC.e(TAG, "removeLocalFolder: failed to resolve canonical path for " + storagePath, e);
+                    success = false;
                 }
             }
         }
EOF
@@ -1023,18 +1023,36 @@

} else if (ocFile.isDown()) {

File localFile = new File(ocFile.getStoragePath());
Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + ocFile.getStoragePath());
String storagePath = ocFile.getStoragePath();
if (TextUtils.isEmpty(storagePath)) {
Log_OC.w(TAG, "removeLocalFolder: skipping deletion, storage path is empty");
continue;
}

boolean deleted = localFile.delete();
success = deleted;
try {
File localFile = new File(storagePath);
File canonicalLocalFile = localFile.getCanonicalFile();
File storageRoot = new File(MainApp.getStoragePath()).getCanonicalFile();

Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted);
if (!canonicalLocalFile.getPath().startsWith(storageRoot.getPath() + File.separator)) {
Log_OC.w(TAG, "removeLocalFolder: refusing to delete file outside storage root: " + canonicalLocalFile);
continue;
}

if (deleted) {
deleteFileInMediaScan(ocFile.getStoragePath());
ocFile.setStoragePath(null);
saveFile(ocFile);
Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + canonicalLocalFile.getAbsolutePath());
boolean deleted = canonicalLocalFile.delete();
success = deleted;

Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted);

if (deleted) {
deleteFileInMediaScan(canonicalLocalFile.getAbsolutePath());
ocFile.setStoragePath(null);
saveFile(ocFile);
}
} catch (IOException e) {
Log_OC.e(TAG, "removeLocalFolder: failed to resolve canonical path for " + storagePath, e);
success = false;
}
}
}
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
--- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
@@ -73,6 +73,7 @@
 import com.owncloud.android.utils.DeviceCredentialUtils;
 import com.owncloud.android.utils.DisplayUtils;
 import com.owncloud.android.utils.EncryptionUtils;
+import com.owncloud.android.utils.FileStorageUtils;
 import com.owncloud.android.utils.MimeTypeUtil;
 import com.owncloud.android.utils.PermissionUtil;
 import com.owncloud.android.utils.theme.CapabilityUtils;
@@ -1188,7 +1189,8 @@
      */
     private void saveStoragePath(String newStoragePath) {
         SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
-        storagePath = newStoragePath;
+        // Normalize and validate the user-selected storage path before persisting it.
+        storagePath = FileStorageUtils.ensureValidStoragePath(newStoragePath);
         MainApp.setStoragePath(storagePath);
         SharedPreferences.Editor editor = appPrefs.edit();
         editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
EOF
@@ -73,6 +73,7 @@
import com.owncloud.android.utils.DeviceCredentialUtils;
import com.owncloud.android.utils.DisplayUtils;
import com.owncloud.android.utils.EncryptionUtils;
import com.owncloud.android.utils.FileStorageUtils;
import com.owncloud.android.utils.MimeTypeUtil;
import com.owncloud.android.utils.PermissionUtil;
import com.owncloud.android.utils.theme.CapabilityUtils;
@@ -1188,7 +1189,8 @@
*/
private void saveStoragePath(String newStoragePath) {
SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
storagePath = newStoragePath;
// Normalize and validate the user-selected storage path before persisting it.
storagePath = FileStorageUtils.ensureValidStoragePath(newStoragePath);
MainApp.setStoragePath(storagePath);
SharedPreferences.Editor editor = appPrefs.edit();
editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
--- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
+++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
@@ -157,6 +157,44 @@
     }
 
     /**
+     * Normalize and validate a storage root path before persisting/using it.
+     * <p>
+     * If the requested path is null, empty, not absolute, or resolves to an
+     * unsafe location, this method falls back to the app's internal files dir.
+     *
+     * @param requestedPath path selected/configured by the user.
+     * @return a canonical, absolute, safe storage root path.
+     */
+    public static String ensureValidStoragePath(@Nullable String requestedPath) {
+        File defaultRoot = MainApp.getAppContext().getFilesDir();
+        try {
+            File defaultCanonical = defaultRoot.getCanonicalFile();
+
+            if (TextUtils.isEmpty(requestedPath)) {
+                return defaultCanonical.getAbsolutePath();
+            }
+
+            File requestedFile = new File(requestedPath);
+            if (!requestedFile.isAbsolute()) {
+                return defaultCanonical.getAbsolutePath();
+            }
+
+            File requestedCanonical = requestedFile.getCanonicalFile();
+
+            // Very defensive checks: disallow using filesystem root or parent traversal.
+            String requestedCanonicalPath = requestedCanonical.getPath();
+            if ("/".equals(requestedCanonicalPath) || requestedCanonicalPath.contains("..")) {
+                return defaultCanonical.getAbsolutePath();
+            }
+
+            return requestedCanonicalPath;
+        } catch (IOException e) {
+            Log_OC.e("FileStorageUtils", "Failed to validate storage path, using default", e);
+            return defaultRoot.getAbsolutePath();
+        }
+    }
+
+    /**
      * Get local owncloud storage path for accountName.
      */
     public static String getSavePath(String accountName) {
EOF
@@ -157,6 +157,44 @@
}

/**
* Normalize and validate a storage root path before persisting/using it.
* <p>
* If the requested path is null, empty, not absolute, or resolves to an
* unsafe location, this method falls back to the app's internal files dir.
*
* @param requestedPath path selected/configured by the user.
* @return a canonical, absolute, safe storage root path.
*/
public static String ensureValidStoragePath(@Nullable String requestedPath) {
File defaultRoot = MainApp.getAppContext().getFilesDir();
try {
File defaultCanonical = defaultRoot.getCanonicalFile();

if (TextUtils.isEmpty(requestedPath)) {
return defaultCanonical.getAbsolutePath();
}

File requestedFile = new File(requestedPath);
if (!requestedFile.isAbsolute()) {
return defaultCanonical.getAbsolutePath();
}

File requestedCanonical = requestedFile.getCanonicalFile();

// Very defensive checks: disallow using filesystem root or parent traversal.
String requestedCanonicalPath = requestedCanonical.getPath();
if ("/".equals(requestedCanonicalPath) || requestedCanonicalPath.contains("..")) {
return defaultCanonical.getAbsolutePath();
}

return requestedCanonicalPath;
} catch (IOException e) {
Log_OC.e("FileStorageUtils", "Failed to validate storage path, using default", e);
return defaultRoot.getAbsolutePath();
}
}

/**
* Get local owncloud storage path for accountName.
*/
public static String getSavePath(String accountName) {
Copilot is powered by AI and may make mistakes. Always verify output.
return false;
}

if (!localFolder.exists()) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

In general, to fix uncontrolled path usage you must validate and constrain any user-provided path before it is stored or used. For a storage root, the usual pattern is: (1) normalize the candidate path, (2) ensure it resides under one of a small set of allowed base directories (e.g., app-internal files directory and possibly app-specific external storage), and (3) reject or ignore values that are empty, invalid, or point outside those bases.

For this concrete case, the most robust and least invasive fix is to validate the storage path when it is (a) received from the Intent result, and (b) persisted in preferences and assigned to MainApp.storagePath. That ensures all downstream uses (including FileStorageUtils.getSavePath and removeLocalFolder(File)) only ever see safe values. We can implement a dedicated validator method in SettingsActivity to check the new storage location against the app’s internal files directory (and possibly other allowed roots if desired). The validator will use java.io.File’s getCanonicalFile to normalize paths and then check that the candidate path starts with the allowed base. If validation fails, the migration will not be started, and we can optionally show a message or just ignore the change. Additionally, we should guard MainApp.setStoragePath against null or obviously invalid strings (e.g., empty) so that a bad value cannot be propagated globally, even if some call path accidentally bypassed saveStoragePath.

Concretely:

  • In SettingsActivity, add a private method isValidStoragePath(String candidate) that:
    • Returns false on null/blank.
    • Resolves the candidate to a canonical file.
    • Resolves the app’s internal files directory to a canonical file.
    • Checks candidateCanonical.getPath().startsWith(internalCanonical.getPath()).
  • In onActivityResult, when handling ExtendedSettingsActivityDialog.StorageLocation, only construct StorageMigration and call migrate() if isValidStoragePath(newPath) returns true.
  • In saveStoragePath, validate the newStoragePath before writing it to preferences and before calling MainApp.setStoragePath; if invalid, log and return without changing the stored path.
  • In MainApp.setStoragePath, add a simple null/blank guard to prevent obviously bogus values, without changing any existing semantics for valid strings.

This keeps existing functionality for normal, UI-generated paths (which should already be under the app’s internal directory), while blocking crafted or malicious paths from being used as storage roots.

Suggested changeset 2
app/src/main/java/com/owncloud/android/MainApp.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java
--- a/app/src/main/java/com/owncloud/android/MainApp.java
+++ b/app/src/main/java/com/owncloud/android/MainApp.java
@@ -757,7 +757,16 @@
     }
 
     public static void setStoragePath(String path) {
-        MainApp.storagePath = path;
+        if (path == null) {
+            return;
+        }
+
+        String trimmed = path.trim();
+        if (trimmed.isEmpty()) {
+            return;
+        }
+
+        MainApp.storagePath = trimmed;
     }
 
     // Methods to obtain Strings referring app_name
EOF
@@ -757,7 +757,16 @@
}

public static void setStoragePath(String path) {
MainApp.storagePath = path;
if (path == null) {
return;
}

String trimmed = path.trim();
if (trimmed.isEmpty()) {
return;
}

MainApp.storagePath = trimmed;
}

// Methods to obtain Strings referring app_name
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
--- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
@@ -78,6 +78,8 @@
 import com.owncloud.android.utils.theme.CapabilityUtils;
 import com.owncloud.android.utils.theme.ViewThemeUtils;
 
+import java.io.File;
+import java.io.IOException;
 import java.util.Objects;
 
 import javax.inject.Inject;
@@ -1022,7 +1024,7 @@
             startActivity(i);
         } else if (requestCode == ExtendedSettingsActivityDialog.StorageLocation.getResultId() && data != null) {
             String newPath = data.getStringExtra(ExtendedSettingsActivityDialog.StorageLocation.getKey());
-            if (storagePath != null && !storagePath.equals(newPath)) {
+            if (storagePath != null && !storagePath.equals(newPath) && isValidStoragePath(newPath)) {
                 StorageMigration storageMigration = new StorageMigration(this, user, storagePath, newPath, viewThemeUtils);
                 storageMigration.setStorageMigrationProgressListener(this);
                 storageMigration.migrate();
@@ -1187,6 +1189,11 @@
      * Save storage path
      */
     private void saveStoragePath(String newStoragePath) {
+        if (!isValidStoragePath(newStoragePath)) {
+            Log_OC.w(TAG, "Ignoring invalid storage path: " + newStoragePath);
+            return;
+        }
+
         SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
         storagePath = newStoragePath;
         MainApp.setStoragePath(storagePath);
@@ -1195,6 +1202,35 @@
         editor.apply();
     }
 
+    /**
+     * Validate that the provided storage path is within the app's internal files directory.
+     */
+    private boolean isValidStoragePath(String candidate) {
+        if (candidate == null) {
+            return false;
+        }
+
+        String trimmed = candidate.trim();
+        if (trimmed.isEmpty()) {
+            return false;
+        }
+
+        try {
+            File baseDir = getApplicationContext().getFilesDir();
+            File baseCanonical = baseDir.getCanonicalFile();
+            File candidateCanonical = new File(trimmed).getCanonicalFile();
+
+            String basePath = baseCanonical.getPath();
+            String candidatePath = candidateCanonical.getPath();
+
+            // Ensure the candidate is inside the app's internal files directory.
+            return candidatePath.startsWith(basePath + File.separator) || candidatePath.equals(basePath);
+        } catch (IOException e) {
+            Log_OC.e(TAG, "Failed to validate storage path: " + candidate, e);
+            return false;
+        }
+    }
+
     private void readStoragePath() {
         SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
         // Load storage path from shared preferences. Use private internal storage by default.
EOF
@@ -78,6 +78,8 @@
import com.owncloud.android.utils.theme.CapabilityUtils;
import com.owncloud.android.utils.theme.ViewThemeUtils;

import java.io.File;
import java.io.IOException;
import java.util.Objects;

import javax.inject.Inject;
@@ -1022,7 +1024,7 @@
startActivity(i);
} else if (requestCode == ExtendedSettingsActivityDialog.StorageLocation.getResultId() && data != null) {
String newPath = data.getStringExtra(ExtendedSettingsActivityDialog.StorageLocation.getKey());
if (storagePath != null && !storagePath.equals(newPath)) {
if (storagePath != null && !storagePath.equals(newPath) && isValidStoragePath(newPath)) {
StorageMigration storageMigration = new StorageMigration(this, user, storagePath, newPath, viewThemeUtils);
storageMigration.setStorageMigrationProgressListener(this);
storageMigration.migrate();
@@ -1187,6 +1189,11 @@
* Save storage path
*/
private void saveStoragePath(String newStoragePath) {
if (!isValidStoragePath(newStoragePath)) {
Log_OC.w(TAG, "Ignoring invalid storage path: " + newStoragePath);
return;
}

SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
storagePath = newStoragePath;
MainApp.setStoragePath(storagePath);
@@ -1195,6 +1202,35 @@
editor.apply();
}

/**
* Validate that the provided storage path is within the app's internal files directory.
*/
private boolean isValidStoragePath(String candidate) {
if (candidate == null) {
return false;
}

String trimmed = candidate.trim();
if (trimmed.isEmpty()) {
return false;
}

try {
File baseDir = getApplicationContext().getFilesDir();
File baseCanonical = baseDir.getCanonicalFile();
File candidateCanonical = new File(trimmed).getCanonicalFile();

String basePath = baseCanonical.getPath();
String candidatePath = candidateCanonical.getPath();

// Ensure the candidate is inside the app's internal files directory.
return candidatePath.startsWith(basePath + File.separator) || candidatePath.equals(basePath);
} catch (IOException e) {
Log_OC.e(TAG, "Failed to validate storage path: " + candidate, e);
return false;
}
}

private void readStoragePath() {
SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
// Load storage path from shared preferences. Use private internal storage by default.
Copilot is powered by AI and may make mistakes. Always verify output.
success &= localFolder.delete();

return success;
boolean folderDeleted = localFolder.delete();

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

In general, the problem should be fixed by validating and constraining the storage base path before it is accepted and persisted, and by ensuring that any deletion routines only operate within a known-safe area. The safest approach is to restrict storage locations to a small set of app-controlled roots (for example, app-internal storage and possibly app-private external directories), and reject any path that falls outside these roots. At minimum, the path should be normalized and checked to be an absolute path under one of those roots, without any .. components or other tricks.

For this codebase, the least intrusive and most robust fix is:

  1. Introduce a validation helper in FileStorageUtils (which already hosts filesystem-related helpers) that takes a candidate storage path and returns either a normalized, validated version or null if invalid. This helper can:

    • Reject empty/null strings.
    • Normalize the path (using new File(path).getCanonicalFile()).
    • Require it to be absolute.
    • Require it to start with one of the allowed base directories: the app’s internal files directory (context.getFilesDir()) and, if desired, its getExternalFilesDir(null). Because FileStorageUtils is static, it can obtain a Context via MainApp.getAppContext().
  2. Use this helper in SettingsActivity.saveStoragePath to validate any new storage path before persisting it and before calling MainApp.setStoragePath. If invalid, log and either:

    • fall back to the current stored path (no change), or
    • fall back to the default internal storage path.
      To avoid changing user-visible behavior too much, the best compromise is to keep the old path if the requested new one is invalid.
  3. Ensure that MainApp.storagePath is always non-null and points to something sensible. We can add a small guard in MainApp.getStoragePath() that falls back to the default internal storage directory if storagePath is null or empty, using MainApp.getAppContext().

With these changes, any localFolder passed into removeLocalFolder(File) that is derived from MainApp.getStoragePath() will be anchored under validated directories only, eliminating the possibility of deleting arbitrary, attacker-chosen filesystem locations.

Suggested changeset 3
app/src/main/java/com/owncloud/android/MainApp.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java
--- a/app/src/main/java/com/owncloud/android/MainApp.java
+++ b/app/src/main/java/com/owncloud/android/MainApp.java
@@ -745,7 +745,7 @@
     }
 
     public static Context getAppContext() {
-        return MainApp.appContext.get();
+        return MainApp.appContext != null ? MainApp.appContext.get() : null;
     }
 
     public static void setAppContext(Context context) {
@@ -753,7 +753,17 @@
     }
 
     public static String getStoragePath() {
-        return MainApp.storagePath;
+        if (!TextUtils.isEmpty(MainApp.storagePath)) {
+            return MainApp.storagePath;
+        }
+
+        Context context = getAppContext();
+        if (context != null) {
+            return context.getFilesDir().getAbsolutePath();
+        }
+
+        // As a last resort, fall back to the external storage directory.
+        return Environment.getExternalStorageDirectory().getAbsolutePath();
     }
 
     public static void setStoragePath(String path) {
EOF
@@ -745,7 +745,7 @@
}

public static Context getAppContext() {
return MainApp.appContext.get();
return MainApp.appContext != null ? MainApp.appContext.get() : null;
}

public static void setAppContext(Context context) {
@@ -753,7 +753,17 @@
}

public static String getStoragePath() {
return MainApp.storagePath;
if (!TextUtils.isEmpty(MainApp.storagePath)) {
return MainApp.storagePath;
}

Context context = getAppContext();
if (context != null) {
return context.getFilesDir().getAbsolutePath();
}

// As a last resort, fall back to the external storage directory.
return Environment.getExternalStorageDirectory().getAbsolutePath();
}

public static void setStoragePath(String path) {
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
--- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
@@ -1188,7 +1188,16 @@
      */
     private void saveStoragePath(String newStoragePath) {
         SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
-        storagePath = newStoragePath;
+
+        // Validate and normalize the requested storage path before persisting it.
+        String normalizedPath = FileStorageUtils.validateAndNormalizeStoragePath(newStoragePath);
+        if (normalizedPath == null) {
+            Log_OC.e(TAG, "Rejected invalid storage path: " + newStoragePath);
+            // Do not change the existing storagePath if the new one is invalid.
+            return;
+        }
+
+        storagePath = normalizedPath;
         MainApp.setStoragePath(storagePath);
         SharedPreferences.Editor editor = appPrefs.edit();
         editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
EOF
@@ -1188,7 +1188,16 @@
*/
private void saveStoragePath(String newStoragePath) {
SharedPreferences appPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
storagePath = newStoragePath;

// Validate and normalize the requested storage path before persisting it.
String normalizedPath = FileStorageUtils.validateAndNormalizeStoragePath(newStoragePath);
if (normalizedPath == null) {
Log_OC.e(TAG, "Rejected invalid storage path: " + newStoragePath);
// Do not change the existing storagePath if the new one is invalid.
return;
}

storagePath = normalizedPath;
MainApp.setStoragePath(storagePath);
SharedPreferences.Editor editor = appPrefs.edit();
editor.putString(AppPreferencesImpl.STORAGE_PATH, storagePath);
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
--- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
+++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
@@ -157,6 +157,63 @@
     }
 
     /**
+     * Validate and normalize a candidate base storage path.
+     * <p>
+     * The returned path, if non-null, is guaranteed to be an absolute, canonical
+     * directory that resides under one of the allowed application-specific roots
+     * (internal files directory or, if available, external files directory).
+     *
+     * @param rawPath the user-provided storage path
+     * @return a normalized, safe storage path, or {@code null} if the input is invalid
+     */
+    @Nullable
+    public static String validateAndNormalizeStoragePath(@Nullable String rawPath) {
+        if (TextUtils.isEmpty(rawPath)) {
+            return null;
+        }
+
+        Context context = MainApp.getAppContext();
+        if (context == null) {
+            return null;
+        }
+
+        try {
+            File candidate = new File(rawPath).getCanonicalFile();
+            if (!candidate.isAbsolute()) {
+                return null;
+            }
+
+            File internalRoot = context.getFilesDir().getCanonicalFile();
+            File externalRoot = context.getExternalFilesDir(null);
+            if (externalRoot != null) {
+                externalRoot = externalRoot.getCanonicalFile();
+            }
+
+            String candidatePath = candidate.getPath();
+            String internalPath = internalRoot.getPath();
+
+            boolean underInternal = candidatePath.startsWith(internalPath + File.separator)
+                    || candidatePath.equals(internalPath);
+
+            boolean underExternal = false;
+            if (externalRoot != null) {
+                String externalPath = externalRoot.getPath();
+                underExternal = candidatePath.startsWith(externalPath + File.separator)
+                        || candidatePath.equals(externalPath);
+            }
+
+            if (underInternal || underExternal) {
+                return candidatePath;
+            }
+
+            return null;
+        } catch (IOException e) {
+            Log_OC.e(FileStorageUtils.class.getSimpleName(), "Failed to canonicalize storage path: " + rawPath, e);
+            return null;
+        }
+    }
+
+    /**
      * Get local owncloud storage path for accountName.
      */
     public static String getSavePath(String accountName) {
EOF
@@ -157,6 +157,63 @@
}

/**
* Validate and normalize a candidate base storage path.
* <p>
* The returned path, if non-null, is guaranteed to be an absolute, canonical
* directory that resides under one of the allowed application-specific roots
* (internal files directory or, if available, external files directory).
*
* @param rawPath the user-provided storage path
* @return a normalized, safe storage path, or {@code null} if the input is invalid
*/
@Nullable
public static String validateAndNormalizeStoragePath(@Nullable String rawPath) {
if (TextUtils.isEmpty(rawPath)) {
return null;
}

Context context = MainApp.getAppContext();
if (context == null) {
return null;
}

try {
File candidate = new File(rawPath).getCanonicalFile();
if (!candidate.isAbsolute()) {
return null;
}

File internalRoot = context.getFilesDir().getCanonicalFile();
File externalRoot = context.getExternalFilesDir(null);
if (externalRoot != null) {
externalRoot = externalRoot.getCanonicalFile();
}

String candidatePath = candidate.getPath();
String internalPath = internalRoot.getPath();

boolean underInternal = candidatePath.startsWith(internalPath + File.separator)
|| candidatePath.equals(internalPath);

boolean underExternal = false;
if (externalRoot != null) {
String externalPath = externalRoot.getPath();
underExternal = candidatePath.startsWith(externalPath + File.separator)
|| candidatePath.equals(externalPath);
}

if (underInternal || underExternal) {
return candidatePath;
}

return null;
} catch (IOException e) {
Log_OC.e(FileStorageUtils.class.getSimpleName(), "Failed to canonicalize storage path: " + rawPath, e);
return null;
}
}

/**
* Get local owncloud storage path for accountName.
*/
public static String getSavePath(String accountName) {
Copilot is powered by AI and may make mistakes. Always verify output.
}

@Test
fun deleteFolderRecursive() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "only" tests first folder hierarchy.
Please add a real hierarchy / recursive test.

Signed-off-by: alperozturk96 <alper_ozturk@proton.me>
Signed-off-by: alperozturk96 <alper_ozturk@proton.me>
@alperozturk96
Copy link
Collaborator Author

Screenshot 2026-03-04 at 11 05 44

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

APK file: https://www.kaminsky.me/nc-dev/android-artifacts/16621.apk

qrcode

To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

Codacy

SpotBugs

CategoryBaseNew
Bad practice4242
Correctness7575
Dodgy code253253
Experimental11
Internationalization77
Malicious code vulnerability22
Multithreaded correctness3434
Performance4343
Security1818
Total475475

@github-actions
Copy link

github-actions bot commented Mar 4, 2026

blue-Light-Screenshot test failed, but no output was generated. Maybe a preliminary stage failed.

@alperozturk96 alperozturk96 merged commit 1e8aa7e into master Mar 4, 2026
16 of 20 checks passed
@alperozturk96 alperozturk96 deleted the fix/file-deletion branch March 4, 2026 10:25
@alperozturk96 alperozturk96 added this to the Nextcloud App 33.1.0 milestone Mar 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants