423e4e5afc36
fix: Restrict external URL dispatch to HTTPS (PB-26-027, v2.14.92)
- time
- 2026-09-02 11:26 UTC · 1 month ago
- machine
- koh2
- parent
- b963ed21e531
↓ peach-android-423e4e5a.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/ui/vault/AdditionalUrlsDetail.kt+36 −4app/src/test/java/com/peachpasswords/ui/vault/ExternalUrlPolicyTest.kt+45 −0app/src/main/java/com/peachpasswords/ui/vault/AdditionalUrlsDetail.kt+36 −4
@@ -51,6 +51,7 @@5151 import com.peachpasswords.ui.theme.LocalReduceMotion5252 import com.peachpasswords.ui.theme.peachColors5353 import com.peachpasswords.ui.theme.PeachType54+import java.net.URI5455 5556 /**5657 * E3 — additional-URL rows for EntryDetailScreen. Self-contained: it carries its@@ -192,12 +193,15 @@192193 fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"193194 194195 /**195- * Launches ACTION_VIEW for the URL, prefixing a scheme when the stored value is196- * bare (so "example.com" still resolves). Falls back to a toast if no browser197- * handles the intent.196+ * Launches ACTION_VIEW only for a canonical HTTPS URL. Stored/imported vault197+ * values are untrusted; non-web schemes never cross into another app.198198 */199199 fun openUrlInBrowser(context: Context, raw: String) {200- val normalized = if (raw.matches(Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://.*"))) raw else "https://$raw"200+ val normalized = normalizeExternalHttpsUrl(raw)201+ if (normalized == null) {202+ Toast.makeText(context, "Only valid HTTPS links can be opened", Toast.LENGTH_SHORT).show()203+ return204+ }201205 try {202206 context.startActivity(203207 Intent(Intent.ACTION_VIEW, Uri.parse(normalized)).apply {@@ -208,6 +212,34 @@208212 Toast.makeText(context, "No app can open this link", Toast.LENGTH_SHORT).show()209213 }210214 }215+216+/** Returns a canonical HTTPS URL or null when external dispatch is unsafe. */217+internal fun normalizeExternalHttpsUrl(raw: String): String? {218+ val value = raw.trim()219+ if (value.isEmpty() || value.any { it <= '\u001F' || it == '\u007F' || it == '\\' }) return null220+221+ val hasScheme = SCHEME_PREFIX.containsMatchIn(value)222+ val candidate = if (hasScheme) value else "https://$value"223+ val parsed = runCatching { URI(candidate) }.getOrNull() ?: return null224+ if (!parsed.scheme.equals("https", ignoreCase = true) || parsed.isOpaque) return null225+ if (parsed.rawAuthority.isNullOrBlank() || parsed.userInfo != null) return null226+ val host = parsed.host?.takeIf { it.isNotBlank() } ?: return null227+ if (parsed.port !in -1..65535) return null228+229+ return runCatching {230+ URI(231+ "https",232+ null,233+ host.lowercase(),234+ parsed.port,235+ parsed.path.orEmpty(),236+ parsed.query,237+ parsed.fragment,238+ ).toASCIIString()239+ }.getOrNull()240+}241+242+private val SCHEME_PREFIX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")211243 212244 /** Mirrors EntryDetailScreen's private FieldShell: mono label over a value/action row with a t3 hairline. */213245 @Composableapp/src/test/java/com/peachpasswords/ui/vault/ExternalUrlPolicyTest.kt+45 −0
@@ -0,0 +1,45 @@1+package com.peachpasswords.ui.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class ExternalUrlPolicyTest {8+9+ @Test10+ fun `bare host is normalized to HTTPS`() {11+ assertEquals("https://example.com/login", normalizeExternalHttpsUrl("example.com/login"))12+ }13+14+ @Test15+ fun `valid HTTPS URL is canonicalized`() {16+ assertEquals(17+ "https://example.com:8443/a?q=1#top",18+ normalizeExternalHttpsUrl(" HTTPS://Example.COM:8443/a?q=1#top "),19+ )20+ }21+22+ @Test23+ fun `non HTTPS schemes are rejected`() {24+ listOf(25+ "http://example.com",26+ "intent://example.com/#Intent;scheme=https;end",27+ "file:///data/local/tmp/secret",28+ "peach://example.com/open",29+ "javascript:alert(1)",30+ ).forEach { assertNull(it, normalizeExternalHttpsUrl(it)) }31+ }32+33+ @Test34+ fun `malformed or ambiguous authorities are rejected`() {35+ listOf(36+ "https:%2f%2fevil.example",37+ "https://[email protected]",38+ "https://example.com\\@evil.example",39+ "https://",40+ "https://example.com:99999",41+ "https://exa mple.com",42+ "https://example.com\u0000.evil",43+ ).forEach { assertNull(it, normalizeExternalHttpsUrl(it)) }44+ }45+}