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
Expand Up @@ -22,6 +22,8 @@ import com.flipcash.app.billing.BillingClient
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.LocalContactCoordinator
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.links.LocalTrustedWebsites
import com.flipcash.app.core.links.TrustedWebsites
import com.flipcash.app.core.media.LocalMediaUrlResolver
import com.flipcash.app.core.media.MediaUrlResolver
import com.flipcash.app.core.tipping.LocalTipCoordinator
Expand Down Expand Up @@ -147,6 +149,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var mediaUrlResolver: MediaUrlResolver

@Inject
lateinit var trustedWebsites: TrustedWebsites

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUncaughtException()
Expand All @@ -169,6 +174,7 @@ class MainActivity : FragmentActivity() {
LocalVibrator provides vibrator,
LocalRouter provides router,
LocalUserManager provides userManager,
LocalTrustedWebsites provides trustedWebsites,
LocalSessionController provides sessionController,
LocalShareController provides shareController,
LocalInviteController provides inviteController,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import com.flipcash.app.myaccount.BlocklistScreen
import com.flipcash.app.myaccount.UserProfileScreen
import com.flipcash.app.myaccount.EditProfileScreen
import com.flipcash.app.myaccount.SettingsScreen
import com.flipcash.app.myaccount.TrustedWebsitesScreen
import com.flipcash.app.scanner.ScannerScreen
import com.flipcash.app.shareapp.ShareAppScreen
import com.flipcash.app.tokens.SwapFlowScreen
Expand Down Expand Up @@ -186,6 +187,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Menu.EditProfile> { EditProfileScreen() }
annotatedEntry<AppRoute.Menu.ProfileCard> { key -> ProfileCardScreen(key) }
annotatedEntry<AppRoute.Menu.Blocklist> { BlocklistScreen() }
annotatedEntry<AppRoute.Menu.TrustedWebsites> { TrustedWebsitesScreen() }
annotatedEntry<AppRoute.Menu.BackupKey> { BackupKeyScreen() }
annotatedEntry<AppRoute.Menu.DeviceLogs> { DeviceLogsScreen() }
annotatedEntry<AppRoute.Menu.AccountSelection> { AccountSelectionScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ sealed interface AppRoute : NavKey, Parcelable {
@Serializable
data object Blocklist: Menu
@Serializable
data object TrustedWebsites : Menu
@Serializable
data object DeviceLogs : Menu
@Serializable
data object AccountSelection : Menu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.ui.platform.UriHandler
import com.flipcash.core.R
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarCheckbox
import com.getcode.manager.BottomBarManager
import java.net.IDN

Expand All @@ -25,30 +26,42 @@ sealed interface LinkDestination {
/** The host is exactly one of [FIRST_PARTY_HOSTS]; open without asking. */
data object FirstParty : LinkDestination

/** The host is exactly one the user chose to trust from the warning; open without asking. */
data class Trusted(val host: String) : 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.
* it is the scheme, and [trustable] is false: there is no website to stop asking about.
*/
data class External(val host: String) : LinkDestination
data class External(val host: String, val trustable: Boolean = true) : 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.
* Classifies [url] by its host, on an exact match against [FIRST_PARTY_HOSTS] and then
* [trustedHosts] — never a suffix match, so `evilflipcash.com` and `flipcash.com.evil.tld` both
* warn, and trusting `x.com` does not cover `mail.x.com`. [trustedHosts] holds hosts in the form
* [LinkDestination.External.host] gives them.
*
* 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 {
fun classifyLink(url: String, trustedHosts: Set<String> = emptySet()): 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) })
return LinkDestination.External(
host = scheme.ifEmpty { url.trim().take(MAX_FALLBACK_LENGTH) },
trustable = false,
)
}
val host = asciiHost(raw)
return if (host in FIRST_PARTY_HOSTS) LinkDestination.FirstParty else LinkDestination.External(host)
return when (host) {
in FIRST_PARTY_HOSTS -> LinkDestination.FirstParty
in trustedHosts -> LinkDestination.Trusted(host)
else -> LinkDestination.External(host)
}
}

private const val MAX_FALLBACK_LENGTH = 64
Expand Down Expand Up @@ -94,29 +107,74 @@ private fun asciiHost(host: String): String {
}

/**
* Runs [open] straight away for a first-party link, and otherwise asks first: "You're Leaving
* Flipcash", naming the host, with Open Website as the primary button and Cancel as the secondary.
* Runs [open] straight away for a first-party or trusted link, and otherwise asks first: "You're
* Leaving Flipcash", naming the host, with Open Website as the primary button and Cancel as the
* secondary. Above the button sits "Don't ask again for <host>", unchecked; [trusted] keeps the host
* only when Open Website is tapped with it checked.
*
* 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.showInfo(
title = context.getString(R.string.prompt_title_externalLink),
message = context.getString(R.string.prompt_description_externalLink, destination.host),
actions = listOf(
BottomBarAction(
text = context.getString(R.string.action_openWebsite),
onClick = open,
),
),
showCancel = true,
fun openWithExternalLinkCheck(
context: Context,
url: String,
trusted: TrustedWebsites,
open: () -> Unit,
) {
val trustedHosts = trusted.websites.value.mapTo(mutableSetOf()) { it.host }
when (val destination = classifyLink(url, trustedHosts)) {
LinkDestination.FirstParty,
is LinkDestination.Trusted -> open()
is LinkDestination.External -> BottomBarManager.showMessage(
externalLinkWarning(
destination = destination,
title = context.getString(R.string.prompt_title_externalLink),
message = context.getString(R.string.prompt_description_externalLink, destination.host),
openWebsite = context.getString(R.string.action_openWebsite),
dontAskAgain = context.getString(R.string.action_dontAskAgainForHost, destination.host),
onTrust = trusted::trust,
open = open,
)
)
}
}

/**
* The warning for [destination]. Separate from [openWithExternalLinkCheck] so the rule for when
* [onTrust] runs can be tested without resources: only from Open Website, and only with the box
* checked at that moment.
*/
internal fun externalLinkWarning(
destination: LinkDestination.External,
title: String,
message: String,
openWebsite: String,
dontAskAgain: String,
onTrust: (host: String) -> Unit,
open: () -> Unit,
): BottomBarManager.BottomBarMessage {
var dontAsk = false
return BottomBarManager.BottomBarMessage(
title = title,
subtitle = message,
type = BottomBarManager.BottomBarMessageType.INFO,
checkbox = BottomBarCheckbox(
label = dontAskAgain,
onCheckedChange = { dontAsk = it },
).takeIf { destination.trustable },
actions = listOf(
BottomBarAction(
text = openWebsite,
onClick = {
if (dontAsk) onTrust(destination.host)
open()
},
),
),
showCancel = true,
)
}

/**
* A `LocalUriHandler` that runs [openWithExternalLinkCheck] before [delegate] opens the link.
* Provided around the chat transcript, so the `LinkAnnotation.Url` spans in message text pass
Expand All @@ -125,8 +183,9 @@ fun openWithExternalLinkCheck(context: Context, url: String, open: () -> Unit) {
class ExternalLinkUriHandler(
private val context: Context,
private val delegate: UriHandler,
private val trusted: TrustedWebsites,
) : UriHandler {
override fun openUri(uri: String) {
openWithExternalLinkCheck(context, uri) { delegate.openUri(uri) }
openWithExternalLinkCheck(context, uri, trusted) { delegate.openUri(uri) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.flipcash.app.core.links

import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

/**
* A host the user chose not to be warned about again, from the "You're Leaving Flipcash" prompt.
* [host] is exactly what the prompt showed (see [LinkDestination.External.host]); [addedAtMillis] is
* epoch milliseconds.
*/
data class TrustedWebsite(val host: String, val addedAtMillis: Long)

/**
* The hosts that skip the external-link warning. One list for the device, shared by every account
* on it and kept across Log Out and Switch Accounts; it lives only on this device.
*
* Matching is exact: a host here covers that host and nothing under it.
*/
interface TrustedWebsites {
/** Every trusted host, newest first. Current as of the last write, so a tap can read it. */
val websites: StateFlow<List<TrustedWebsite>>

fun trust(host: String)

fun remove(host: String)
}

/** No hosts, and nothing kept. For previews and tests that don't provide the real list. */
object NoTrustedWebsites : TrustedWebsites {
override val websites: StateFlow<List<TrustedWebsite>> = MutableStateFlow(emptyList())

override fun trust(host: String) = Unit

override fun remove(host: String) = Unit
}

val LocalTrustedWebsites = staticCompositionLocalOf<TrustedWebsites> { NoTrustedWebsites }
8 changes: 8 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@
<!-- %1$s is the link's host. -->
<string name="prompt_description_externalLink">This will open %1$s. Never share your Access Key with a website</string>
<string name="action_openWebsite">Open Website</string>
<string name="action_dontAskAgainForHost">Don\'t ask again for %1$s</string>
<string name="action_unlinkAccount">Unlink Account</string>

<string name="title_verificationFlow">Verify Your Phone Number And Email To Continue</string>
Expand Down Expand Up @@ -1300,6 +1301,13 @@
<string name="prompt_description_unblockUser">The conversation with them will reappear in Tips</string>
<string name="title_blocklistEmpty">No One Blocked</string>
<string name="description_blocklistEmpty">Block people from sending you messages by tapping their profile and selecting block</string>
<!-- Settings › Privacy: the hosts ticked "Don't ask again" on the external-link warning. -->
<string name="title_trustedWebsites">Trusted Websites</string>
<!-- Under a trusted host; the argument is a short date such as "Oct 7". -->
<string name="subtitle_trustedWebsiteAdded">Added %1$s</string>
<string name="description_trustedWebsites">Links to these sites open without the “You\'re Leaving Flipcash” warning. Each entry is an exact match: x.com does not cover mail.x.com.</string>
<string name="title_trustedWebsitesEmpty">No Trusted Websites</string>
<string name="description_trustedWebsitesEmpty">Skip the “You\'re Leaving Flipcash” warning for a website by checking “Don\'t ask again” when you open its link</string>
<string name="subtitle_joinedDate">Joined %1$s</string>
<!-- The profile's two shortcuts into the DM with this person. "Send Cash" beside it is
action_sendCash. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ class ExternalLinksTest {

@Test
fun `a link with no host warns with its scheme`() {
assertEquals(LinkDestination.External("mailto"), classifyLink("mailto:someone@flipcash.com"))
assertEquals(LinkDestination.External("mailto", trustable = false), classifyLink("mailto:someone@flipcash.com"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.flipcash.app.core.links

import com.getcode.manager.SelectedBottomBarAction
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

class TrustedLinksTest {

@Test
fun `a trusted host opens without a warning`() {
assertEquals(LinkDestination.Trusted("x.com"), classifyLink("https://x.com/flipcash", setOf("x.com")))
assertEquals(LinkDestination.Trusted("x.com"), classifyLink("HTTPS://X.com:443/", setOf("x.com")))
}

@Test
fun `trusting a host does not cover its subdomains or lookalikes`() {
val trusted = setOf("x.com")
assertEquals(LinkDestination.External("mail.x.com"), classifyLink("https://mail.x.com", trusted))
assertEquals(LinkDestination.External("x.com.evil.tld"), classifyLink("https://x.com.evil.tld", trusted))
assertEquals(LinkDestination.External("evilx.com"), classifyLink("https://evilx.com", trusted))
}

@Test
fun `a trusted parent does not cover its own subdomain the other way round`() {
assertEquals(LinkDestination.External("x.com"), classifyLink("https://x.com", setOf("mail.x.com")))
}

@Test
fun `a homograph is matched on its punycode, not on how it looks`() {
// Cyrillic "а" in place of the Latin one.
val homograph = "https://flipcаsh.io/"
val ascii = (classifyLink(homograph) as LinkDestination.External).host
assertEquals(LinkDestination.External(ascii), classifyLink(homograph, setOf("flipcash.io")))
assertEquals(LinkDestination.Trusted(ascii), classifyLink(homograph, setOf(ascii)))
}

@Test
fun `first-party hosts keep their own handling`() {
assertEquals(LinkDestination.FirstParty, classifyLink("https://flipcash.com", setOf("flipcash.com")))
}

@Test
fun `a link with no host is never offered for trust`() {
val warning = warning(LinkDestination.External("mailto", trustable = false))
assertNull(warning.message.checkbox)
}

@Test
fun `open website with the box checked trusts the host and opens`() {
val warning = warning()
assertNotNull(warning.message.checkbox).onCheckedChange(true)
warning.message.actions.single().onClick()

assertEquals(listOf("x.com"), warning.trusted)
assertEquals(1, warning.opened)
}

@Test
fun `open website with the box unchecked opens without trusting`() {
val warning = warning()
warning.message.actions.single().onClick()

assertEquals(emptyList(), warning.trusted)
assertEquals(1, warning.opened)
}

@Test
fun `unticking before open website saves nothing`() {
val warning = warning()
val checkbox = assertNotNull(warning.message.checkbox)
checkbox.onCheckedChange(true)
checkbox.onCheckedChange(false)
warning.message.actions.single().onClick()

assertEquals(emptyList(), warning.trusted)
}

@Test
fun `cancel or dismiss with the box checked saves nothing`() {
val warning = warning()
assertNotNull(warning.message.checkbox).onCheckedChange(true)
warning.message.onClose(SelectedBottomBarAction(-1))

assertEquals(emptyList(), warning.trusted)
assertEquals(0, warning.opened)
}

@Test
fun `each warning starts unchecked`() {
val first = warning()
assertNotNull(first.message.checkbox).onCheckedChange(true)

val second = warning()
second.message.actions.single().onClick()

assertEquals(emptyList(), second.trusted)
}

private class Warning(destination: LinkDestination.External) {
val trusted = mutableListOf<String>()
var opened = 0
val message = externalLinkWarning(
destination = destination,
title = "You're Leaving Flipcash",
message = "This will open ${destination.host}.",
openWebsite = "Open Website",
dontAskAgain = "Don't ask again for ${destination.host}",
onTrust = { trusted += it },
open = { opened++ },
)
}

private fun warning(destination: LinkDestination.External = LinkDestination.External("x.com")) =
Warning(destination)
}
Loading
Loading