diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-02 09:51:18 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-02 09:51:18 +0100 |
| commit | 34e29204a3720741b4b5373cae2afbdb0e17bad2 (patch) | |
| tree | 56d5107c713035674fbdf0ad8f033f384d8d117d | |
| parent | fda98e40f68539e5e50d29158bb5b446e6c41d5d (diff) | |
| download | HardenedPass-34e29204a3720741b4b5373cae2afbdb0e17bad2.tar.gz HardenedPass-34e29204a3720741b4b5373cae2afbdb0e17bad2.zip | |
fix: stop exporting DecryptActivity and validate decrypt paths
DecryptActivity was exported without an intent filter, so any installed
app could start it with arbitrary FILE_PATH/REPO_PATH extras (and crash
it by omitting them). Nothing outside the app needs to launch it:
shortcuts and the decrypt action go through LaunchActivity.
LaunchActivity stays exported, so it now treats FILE_PATH as untrusted:
the path is forwarded only when it canonically resolves to a regular
file inside the password store, and REPO_PATH is always the real store
directory instead of the caller-supplied value. Anything else falls
back to the main screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | app/src/main/AndroidManifest.xml | 2 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt | 25 | ||||
| -rw-r--r-- | app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt | 45 |
3 files changed, 62 insertions, 10 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d575c7f8..407697cf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,7 +51,7 @@ <activity android:name=".ui.crypto.DecryptActivity" - android:exported="true" /> + android:exported="false" /> <activity android:name=".ui.main.LaunchActivity" diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 90434bb7..1a5e8a89 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -11,6 +11,7 @@ import android.os.Handler import android.os.Looper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.DecryptActivity import app.passwordstore.ui.passwords.PasswordStore @@ -18,6 +19,7 @@ import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.PreferenceKeys +import java.io.File @SuppressLint("CustomSplashScreen") class LaunchActivity : AppCompatActivity() { @@ -53,17 +55,15 @@ class LaunchActivity : AppCompatActivity() { } private fun startTargetActivity(noAuth: Boolean) { + // This activity is exported, so the file path is untrusted input: only forward it to + // DecryptActivity when it points at a file inside the password store. + val repoDir = PasswordRepository.getRepositoryDirectory() + val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) val intentToStart = - if (intent.action == ACTION_DECRYPT_PASS) + if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) getDecryptIntent().apply { - putExtra( - BasePGPActivity.EXTRA_FILE_PATH, - intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH), - ) - putExtra( - BasePGPActivity.EXTRA_REPO_PATH, - intent.getStringExtra(BasePGPActivity.EXTRA_REPO_PATH), - ) + putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) + putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath) } else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) startActivity(intentToStart) @@ -74,5 +74,12 @@ class LaunchActivity : AppCompatActivity() { companion object { const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + + /** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */ + fun isFileInRepo(path: String?, repoDir: File): Boolean { + if (path.isNullOrEmpty()) return false + val file = File(path).canonicalFile + return file.isFile && file.startsWith(repoDir.canonicalFile) + } } } diff --git a/app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt b/app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt new file mode 100644 index 00000000..3c30071a --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.main + +import app.passwordstore.ui.main.LaunchActivity.Companion.isFileInRepo +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LaunchActivityTest { + + @Test + fun onlyAcceptsFilesInsideTheRepository() { + val root = Files.createTempDirectory("aps").toFile() + val repo = File(root, "store").apply { mkdirs() } + val inside = + File(repo, "site/login.gpg").apply { + parentFile!!.mkdirs() + writeText("x") + } + val outside = File(root, "secret.gpg").apply { writeText("x") } + val sibling = + File(root, "store-evil/login.gpg").apply { + parentFile!!.mkdirs() + writeText("x") + } + val link = File(repo, "link.gpg").toPath() + Files.createSymbolicLink(link, outside.toPath()) + + assertTrue(isFileInRepo(inside.absolutePath, repo)) + assertFalse(isFileInRepo(outside.absolutePath, repo)) + assertFalse(isFileInRepo("${repo.absolutePath}/../secret.gpg", repo)) + assertFalse(isFileInRepo(sibling.absolutePath, repo)) + assertFalse(isFileInRepo(link.toString(), repo)) + assertFalse(isFileInRepo(repo.absolutePath, repo)) + assertFalse(isFileInRepo(File(repo, "missing.gpg").absolutePath, repo)) + assertFalse(isFileInRepo(null, repo)) + assertFalse(isFileInRepo("", repo)) + root.deleteRecursively() + } +} |
