diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt new file mode 100644 index 0000000000..eb4c4dbb1d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt @@ -0,0 +1,137 @@ +package com.flipcash.app.core.links + +import android.content.Context +import androidx.compose.ui.platform.UriHandler +import com.flipcash.core.R +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.R as ResourcesR +import java.net.IDN + +/** + * The hosts that are Flipcash's own: the union of Android's manifest intent filters and iOS's + * associated-domains entitlement. The chat card classifier and the external-link warning both + * read this one list. + */ +val FIRST_PARTY_HOSTS: Set = setOf( + "app.flipcash.com", + "send.flipcash.com", + "flipcash.com", + "www.flipcash.com", + "jump.flipcash.com", +) + +/** Where a link goes, as far as the external-link warning is concerned. */ +sealed interface LinkDestination { + /** The host is exactly one of [FIRST_PARTY_HOSTS]; open without asking. */ + data object FirstParty : LinkDestination + + /** + * Anywhere else. [host] is what the warning shows: lowercased and in ASCII (punycode) form, so + * a homograph domain can't pass for the real one. For a link with no host at all (`mailto:`), + * it is the scheme. + */ + data class External(val host: String) : LinkDestination +} + +/** + * Classifies [url] by its host, on an exact match against [FIRST_PARTY_HOSTS] — never a suffix + * match, so `evilflipcash.com` and `flipcash.com.evil.tld` both warn. + * + * The host is parsed here rather than with `Uri`/`URI`: those differ from browsers on inputs an + * attacker picks (`\` as a path separator, non-ASCII hosts), and the answer has to agree with where + * the browser actually goes. Anything this can't read as first-party warns. + */ +fun classifyLink(url: String): LinkDestination { + val raw = hostOf(url.trim()) + if (raw == null) { + val scheme = url.trim().substringBefore(':', missingDelimiterValue = "").lowercase() + return LinkDestination.External(scheme.ifEmpty { url.trim().take(MAX_FALLBACK_LENGTH) }) + } + val host = asciiHost(raw) + return if (host in FIRST_PARTY_HOSTS) LinkDestination.FirstParty else LinkDestination.External(host) +} + +private const val MAX_FALLBACK_LENGTH = 64 + +/** + * The authority's host, or null when the URL has none. `scheme://[userinfo@]host[:port]`, with + * `/`, `\`, `?` and `#` ending the authority — `\` because browsers treat it as `/` on http(s), + * which is what makes `https://flipcash.com\@evil.com` land on `flipcash.com`. + */ +private fun hostOf(url: String): String? { + val schemeEnd = url.indexOf("://") + val afterScheme = when { + schemeEnd >= 0 -> url.substring(schemeEnd + 3) + url.startsWith("//") -> url.substring(2) + // `mailto:x`, `tel:1` — a scheme with no authority. + SCHEME_ONLY.containsMatchIn(url) -> return null + // A bare `x.com/path`, as link detection can hand over. + else -> url + } + val authority = afterScheme.takeWhile { it !in AUTHORITY_TERMINATORS } + val hostAndPort = authority.substringAfterLast('@') + val host = if (hostAndPort.startsWith("[")) { + hostAndPort.substringBefore(']') + "]" + } else { + hostAndPort.substringBefore(':') + } + return host.takeIf { it.isNotBlank() } +} + +private val SCHEME_ONLY = Regex("^[A-Za-z][A-Za-z0-9+.-]*:(?!//)") +private val AUTHORITY_TERMINATORS = setOf('/', '\\', '?', '#') + +private fun asciiHost(host: String): String { + val ascii = runCatching { IDN.toASCII(host, IDN.ALLOW_UNASSIGNED) } + .getOrElse { + // Not a valid IDN; show it with any non-ASCII byte escaped rather than as it looks. + host.toByteArray(Charsets.UTF_8).joinToString("") { byte -> + val c = byte.toInt() and 0xFF + if (c < 0x80) c.toChar().toString() else "%%%02X".format(c) + } + } + return ascii.lowercase() +} + +/** + * Runs [open] straight away for a first-party link, and otherwise asks first: "You're leaving + * Flipcash", naming the host, with Cancel as the primary button and Open Link as the secondary. + * + * Only for links someone else wrote, such as a chat message. A link the app opens on purpose + * (terms, a token's socials) goes straight to the browser. + */ +fun openWithExternalLinkCheck(context: Context, url: String, open: () -> Unit) { + when (val destination = classifyLink(url)) { + LinkDestination.FirstParty -> open() + is LinkDestination.External -> BottomBarManager.showAlert( + title = context.getString(R.string.prompt_title_externalLink), + message = context.getString(R.string.prompt_description_externalLink, destination.host), + actions = listOf( + BottomBarAction( + text = context.getString(ResourcesR.string.action_cancel), + style = BottomBarManager.BottomBarButtonStyle.Filled, + ), + BottomBarAction( + text = context.getString(R.string.action_openLink), + style = BottomBarManager.BottomBarButtonStyle.Text, + onClick = open, + ), + ), + ) + } +} + +/** + * A `LocalUriHandler` that runs [openWithExternalLinkCheck] before [delegate] opens the link. + * Provided around the chat transcript, so the `LinkAnnotation.Url` spans in message text pass + * through it. + */ +class ExternalLinkUriHandler( + private val context: Context, + private val delegate: UriHandler, +) : UriHandler { + override fun openUri(uri: String) { + openWithExternalLinkCheck(context, uri) { delegate.openUri(uri) } + } +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b42d5b4321..fe0d3b04f5 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -394,6 +394,10 @@ No social accounts linked Unlink Account? This social account will be removed from your profile. + You\'re leaving Flipcash + + This link opens %1$s. Flipcash will never ask for your Access Key on a website + Open Link Unlink Account Verify Your Phone Number And Email To Continue diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt new file mode 100644 index 0000000000..1778dc63ef --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.app.core.links + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ExternalLinksTest { + + @Test + fun `every first-party host opens without a warning`() { + FIRST_PARTY_HOSTS.forEach { host -> + assertEquals(LinkDestination.FirstParty, classifyLink("https://$host/c/#/e=abc"), host) + } + } + + @Test + fun `first-party match ignores case, port, userinfo and scheme`() { + listOf( + "HTTPS://Send.FlipCash.com/c/#/e=abc", + "https://flipcash.com:443/download", + "https://user@app.flipcash.com/token/x", + "http://www.flipcash.com", + "flipcash.com/download", + ).forEach { assertEquals(LinkDestination.FirstParty, classifyLink(it), it) } + } + + @Test + fun `a lookalike host warns`() { + assertEquals(LinkDestination.External("evilflipcash.com"), classifyLink("https://evilflipcash.com/")) + assertEquals( + LinkDestination.External("flipcash.com.evil.tld"), + classifyLink("https://flipcash.com.evil.tld/c/#/e=abc"), + ) + assertEquals(LinkDestination.External("evil.flipcash.com"), classifyLink("https://evil.flipcash.com")) + } + + @Test + fun `the host after userinfo is the one checked`() { + assertEquals(LinkDestination.External("evil.com"), classifyLink("https://flipcash.com@evil.com/")) + } + + @Test + fun `a backslash ends the authority, as it does in a browser`() { + assertEquals(LinkDestination.FirstParty, classifyLink("https://flipcash.com\\@evil.com")) + assertEquals(LinkDestination.External("evil.com"), classifyLink("https://evil.com\\@flipcash.com")) + } + + @Test + fun `a unicode host warns and is shown as punycode`() { + // Cyrillic "а" in place of the Latin "a". + val destination = classifyLink("https://flipc\u0430sh.com/login") + assertEquals(LinkDestination.External("xn--flipcsh-6fg.com"), destination) + destination as LinkDestination.External + assertTrue(destination.host.all { it.code < 0x80 }, "host must be ASCII: ${destination.host}") + } + + @Test + fun `a punycode host warns and stays as written`() { + assertEquals( + LinkDestination.External("xn--flipcsh-6fg.com"), + classifyLink("https://XN--FLIPCSH-6FG.com/"), + ) + } + + @Test + fun `only the host is shown, never the path or query`() { + assertEquals( + LinkDestination.External("x.com"), + classifyLink("https://X.com/flipcash?ref=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#top"), + ) + } + + @Test + fun `a link with no host warns with its scheme`() { + assertEquals(LinkDestination.External("mailto"), classifyLink("mailto:someone@flipcash.com")) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index cc877d2e50..d977f51a2e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import android.net.Uri import androidx.core.net.toUri +import com.flipcash.app.core.links.FIRST_PARTY_HOSTS import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard @@ -122,17 +123,11 @@ internal class LinkCardClassifier @Inject constructor( private const val JUMP_SOURCE_PARAM = "source=" /** - * The union of the hosts the two apps claim — Android's manifest intent filters and iOS's - * associated-domains entitlement. `www.flipcash.com` is Android-only for routing and is - * here anyway: whether a link *is* a Flipcash link is not a question about which app opens - * it, and the cross-platform fixture has to agree on one answer. + * Flipcash's own hosts, shared with the external-link warning. `www.flipcash.com` is + * Android-only for routing and is here anyway: whether a link *is* a Flipcash link is not a + * question about which app opens it, and the cross-platform fixture has to agree on one + * answer. */ - val CARD_HOSTS = setOf( - "app.flipcash.com", - "send.flipcash.com", - "flipcash.com", - "www.flipcash.com", - "jump.flipcash.com", - ) + val CARD_HOSTS = FIRST_PARTY_HOSTS } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 97a8a422ab..a610dc8d03 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -4,11 +4,13 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp @@ -17,6 +19,7 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.links.ExternalLinkUriHandler import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel @@ -42,6 +45,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null) val navigator = LocalCodeNavigator.current val uriHandler = LocalUriHandler.current + val context = LocalContext.current + // Links a sender typed are the ones that can lead anywhere, so only the transcript asks before + // leaving. The cash card goes through [uriHandler] above: it is ours, and opens directly. + val transcriptUriHandler = remember(context, uriHandler) { + ExternalLinkUriHandler(context, uriHandler) + } val hazeState = rememberHazeState() // Measured by the bar and read by the transcript: the blur has to cover the bar's own @@ -220,22 +229,24 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { modifier = Modifier.fillMaxSize(), enabled = state.obscuresTranscript, ) { - MessageList( - modifier = Modifier - .fillMaxSize() - .testTag("chat_message_list") - .softTopEdge(ChatTopEdge.blurHold(barHeight)) - .hazeSource(hazeState), - state = state, - contentPadding = overlapPadding, - messages = messages, - separatorConfig = state.separatorConfig, - otherReadPointer = otherReadPointer, - onAction = chatActionHandler, - linkCardResolution = viewModel.linkCardResolution, - canViewProfile = state.canViewProfile, - onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, - ) + CompositionLocalProvider(LocalUriHandler provides transcriptUriHandler) { + MessageList( + modifier = Modifier + .fillMaxSize() + .testTag("chat_message_list") + .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .hazeSource(hazeState), + state = state, + contentPadding = overlapPadding, + messages = messages, + separatorConfig = state.separatorConfig, + otherReadPointer = otherReadPointer, + onAction = chatActionHandler, + linkCardResolution = viewModel.linkCardResolution, + canViewProfile = state.canViewProfile, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + ) + } } } }