From 10c126401c9e5bc45c9927ece0457d627b8afed4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 14:10:36 -0400 Subject: [PATCH] feat(chat): warn before opening a link from a message A link in a chat message opens whatever URL the sender typed, straight into the browser. A drainer site reached that way gets no domain check first. The chat transcript now provides a UriHandler that runs each LinkAnnotation.Url through one host check. A host that exactly matches the first-party list opens directly; anything else shows "You're leaving Flipcash" with the host lowercased and in punycode, Cancel as the primary button and a text-only Open Link as the secondary. The list moves from LinkCardClassifier.CARD_HOSTS to core so the card classifier and the warning read the same set. Links the app opens on purpose, such as token socials, terms and the cash card, don't go through the check. --- .../flipcash/app/core/links/ExternalLinks.kt | 137 ++++++++++++++++++ .../core/src/main/res/values/strings.xml | 4 + .../app/core/links/ExternalLinksTest.kt | 77 ++++++++++ .../internal/link/LinkCardClassifier.kt | 17 +-- .../internal/screens/MessengerScreen.kt | 43 ++++-- 5 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt 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) }, + ) + } } } }