From 6593522bba0f7fbb6ef0236a3c839b73cd46159a Mon Sep 17 00:00:00 2001 From: smiley Date: Sun, 16 Aug 2026 19:20:13 +0300 Subject: [PATCH 1/2] Add configurable save directory for Autofill-saved entries Credentials saved via the Autofill framework (the system "Save to Password Store?" prompt shown while the app is closed) were always written directly to the repository root, with no way to route them into a category subfolder like a user-organized store might use. Add a "Save directory" setting under Settings > Autofill that lets users specify a root subfolder; Autofill-originated saves are then resolved under that folder instead of the store root. Leaving it unset preserves the existing behavior exactly. The in-app "+" entry creation flow is unaffected in every case. Fixes #127 --- .../ui/autofill/AutofillSaveActivity.kt | 3 ++- .../ui/settings/AutofillSettings.kt | 12 +++++++++++ .../util/autofill/AutofillPreferences.kt | 20 +++++++++++++++++++ .../util/settings/PreferenceKeys.kt | 1 + app/src/main/res/values/strings.xml | 4 ++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index 138c4a737c..176f148e0c 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -128,6 +128,7 @@ class AutofillSaveActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val repo = PasswordRepository.getRepositoryDirectory() + val saveRoot = AutofillPreferences.saveDirectory(this) val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { putExtras( @@ -136,7 +137,7 @@ class AutofillSaveActivity : AppCompatActivity() { putString(BasePGPActivity.EXTRA_REPO_PATH, repo.absolutePath) putString( BasePGPActivity.EXTRA_FILE_PATH, - repo + saveRoot .resolve(intent.getStringExtra(EXTRA_FOLDER_NAME) ?: throw NullPointerException()) .absolutePath, ) diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt index ce009a1071..684a09cc11 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt @@ -109,6 +109,18 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) } textInputHintRes = R.string.preference_custom_public_suffixes_hint } + editText(PreferenceKeys.AUTOFILL_SAVE_DIRECTORY) { + dependency = PreferenceKeys.AUTOFILL_ENABLE + titleRes = R.string.preference_autofill_save_directory_title + summaryProvider = { value -> + activity.getString( + R.string.preference_autofill_save_directory_summary, + value?.takeUnless { it.isBlank() } + ?: activity.getString(R.string.preference_autofill_save_directory_root_placeholder), + ) + } + textInputHintRes = R.string.preference_autofill_save_directory_hint + } } } } diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt index f9298b2c11..2b7d03708b 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -7,6 +7,7 @@ package app.passwordstore.util.autofill import android.content.Context import androidx.core.content.edit import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.services.getDefaultUsername @@ -22,6 +23,25 @@ object AutofillPreferences { return DirectoryStructure.fromValue(value) } + /** + * The directory Autofill-saved credentials should be placed under, relative to the repository + * root. Backed by [PreferenceKeys.AUTOFILL_SAVE_DIRECTORY]; falls back to the repository root + * itself when unset. This only affects saves made through the Autofill framework (the system + * "Save to Password Store?" prompt) — it has no effect on entries created from within the app. + */ + fun saveDirectory(context: Context): File { + val root = PasswordRepository.getRepositoryDirectory() + // Drop empty and ".." segments to prevent escaping the repository root. + val configured = + context.sharedPrefs + .getString(PreferenceKeys.AUTOFILL_SAVE_DIRECTORY) + ?.split('/') + ?.filter { it.isNotBlank() && it != ".." } + ?.joinToString("/") + ?.takeUnless { it.isBlank() } + return if (configured == null) root else root.resolve(configured) + } + fun strictDomainSearch(context: Context): Boolean { return context.sharedPrefs.getBoolean(PreferenceKeys.STRICT_DOMAIN_SEARCH, true) } diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt index c090b7cc0f..5261d0c57f 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -58,6 +58,7 @@ object PreferenceKeys { const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" const val DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" + const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory" const val STRICT_DOMAIN_SEARCH = "oreo_autofill_strict_domain_search" const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" const val REPOSITORY_INITIALIZED = "repository_initialized" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb18607fe7..88d50e883b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,6 +330,10 @@ Custom domains Autofill will distinguish subdomains of these domains. company.com\npersonal.com + Save directory + Credentials saved via Autofill are placed under this folder instead of the store root. Currently: %1$s + e.g. www + store root Password item edited From f8805ab54d8c4d986bf6857c92cf7a964acb91f3 Mon Sep 17 00:00:00 2001 From: smiley Date: Thu, 20 Aug 2026 20:11:56 +0300 Subject: [PATCH 2/2] Validate Autofill save directory instead of rewriting it Reject an invalid configured save directory (absolute paths, or paths containing "." or ".." components) outright rather than silently stripping the offending segments, so an unexpected value always falls back to the repository root instead of landing somewhere the user didn't ask for. Add unit tests covering the validation cases raised in review. Also decouple AutofillPreferences.saveDirectory() from PasswordRepository: it now returns the configured relative directory (or null), leaving AutofillSaveActivity, which already has the repository root, to resolve it. --- .../ui/autofill/AutofillSaveActivity.kt | 2 +- .../util/autofill/AutofillPreferences.kt | 40 +++++++------ .../util/autofill/AutofillPreferencesTest.kt | 56 +++++++++++++++++++ 3 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/app/passwordstore/util/autofill/AutofillPreferencesTest.kt diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index 176f148e0c..0cd06e68d9 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -128,7 +128,7 @@ class AutofillSaveActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val repo = PasswordRepository.getRepositoryDirectory() - val saveRoot = AutofillPreferences.saveDirectory(this) + val saveRoot = AutofillPreferences.saveDirectory(this)?.let(repo::resolve) ?: repo val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { putExtras( diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt index 2b7d03708b..4b367efecc 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -7,7 +7,6 @@ package app.passwordstore.util.autofill import android.content.Context import androidx.core.content.edit import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.services.getDefaultUsername @@ -24,22 +23,31 @@ object AutofillPreferences { } /** - * The directory Autofill-saved credentials should be placed under, relative to the repository - * root. Backed by [PreferenceKeys.AUTOFILL_SAVE_DIRECTORY]; falls back to the repository root - * itself when unset. This only affects saves made through the Autofill framework (the system - * "Save to Password Store?" prompt) — it has no effect on entries created from within the app. + * The configured relative directory Autofill-saved credentials should be placed under. Backed by + * [PreferenceKeys.AUTOFILL_SAVE_DIRECTORY]. Returns null when unset, or when the configured value + * is not a valid relative path (absolute, or containing "." or ".." components) — callers should + * treat a null result as the repository root itself and resolve a non-null result against it. + * This only affects saves made through the Autofill framework (the system "Save to Password + * Store?" prompt) — it has no effect on entries created from within the app. */ - fun saveDirectory(context: Context): File { - val root = PasswordRepository.getRepositoryDirectory() - // Drop empty and ".." segments to prevent escaping the repository root. - val configured = - context.sharedPrefs - .getString(PreferenceKeys.AUTOFILL_SAVE_DIRECTORY) - ?.split('/') - ?.filter { it.isNotBlank() && it != ".." } - ?.joinToString("/") - ?.takeUnless { it.isBlank() } - return if (configured == null) root else root.resolve(configured) + fun saveDirectory(context: Context): String? { + return sanitizeSaveDirectory( + context.sharedPrefs.getString(PreferenceKeys.AUTOFILL_SAVE_DIRECTORY) + ) + } + + /** + * Validates [value] as a relative path with no "." or ".." components, returning it unchanged if + * valid. Invalid or blank input is rejected outright (returns null) rather than being mutated + * into some other path, so a rejected value always falls back to the repository root instead of + * silently landing somewhere the user didn't ask for. + */ + internal fun sanitizeSaveDirectory(value: String?): String? { + if (value.isNullOrBlank()) return null + if (value.startsWith('/')) return null + val segments = value.split('/') + if (segments.any { it.isEmpty() || it == "." || it == ".." }) return null + return segments.joinToString("/") } fun strictDomainSearch(context: Context): Boolean { diff --git a/app/src/test/java/app/passwordstore/util/autofill/AutofillPreferencesTest.kt b/app/src/test/java/app/passwordstore/util/autofill/AutofillPreferencesTest.kt new file mode 100644 index 0000000000..ce1e5bd4db --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/autofill/AutofillPreferencesTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AutofillPreferencesTest { + + @Test + fun nullOrEmptyFallsBackToRoot() { + assertNull(AutofillPreferences.sanitizeSaveDirectory(null)) + assertNull(AutofillPreferences.sanitizeSaveDirectory("")) + assertNull(AutofillPreferences.sanitizeSaveDirectory(" ")) + } + + @Test + fun acceptsSingleSegment() { + assertEquals("www", AutofillPreferences.sanitizeSaveDirectory("www")) + } + + @Test + fun acceptsNestedSegments() { + assertEquals("www/personal", AutofillPreferences.sanitizeSaveDirectory("www/personal")) + } + + @Test + fun rejectsAbsolutePath() { + assertNull(AutofillPreferences.sanitizeSaveDirectory("/www")) + } + + @Test + fun rejectsLeadingTraversal() { + assertNull(AutofillPreferences.sanitizeSaveDirectory("../www")) + } + + @Test + fun rejectsEmbeddedTraversal() { + assertNull(AutofillPreferences.sanitizeSaveDirectory("www/../personal")) + } + + @Test + fun rejectsCurrentDirectoryComponent() { + assertNull(AutofillPreferences.sanitizeSaveDirectory(".")) + assertNull(AutofillPreferences.sanitizeSaveDirectory("www/./personal")) + } + + @Test + fun rejectsEmptyPathComponents() { + assertNull(AutofillPreferences.sanitizeSaveDirectory("www//personal")) + assertNull(AutofillPreferences.sanitizeSaveDirectory("www/")) + } +}