Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> = 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) }
}
}
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@
<string name="subtitle_noSocialAccounts">No social accounts linked</string>
<string name="prompt_title_unlinkSocialAccount">Unlink Account?</string>
<string name="prompt_description_unlinkSocialAccount">This social account will be removed from your profile.</string>
<string name="prompt_title_externalLink">You\'re leaving Flipcash</string>
<!-- %1$s is the link's host. -->
<string name="prompt_description_externalLink">This link opens %1$s. Flipcash will never ask for your Access Key on a website</string>
<string name="action_openLink">Open Link</string>
<string name="action_unlinkAccount">Unlink Account</string>

<string name="title_verificationFlow">Verify Your Phone Number And Email To Continue</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) },
)
}
}
}
}
Loading