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..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,6 +128,7 @@ class AutofillSaveActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val repo = PasswordRepository.getRepositoryDirectory() + val saveRoot = AutofillPreferences.saveDirectory(this)?.let(repo::resolve) ?: repo 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..4b367efecc 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -22,6 +22,34 @@ object AutofillPreferences { return DirectoryStructure.fromValue(value) } + /** + * 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): 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 { 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 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/")) + } +}