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
3 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.911+2.14.92

view file

app/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 @Composable

view file

app/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+}

view file