mirror of
https://github.com/bitwarden/android.git
synced 2026-06-07 06:49:07 -05:00
BIT-621: Add URI matching for autofill (#842)
This commit is contained in:
committed by
Álison Fernandes
parent
0e5e6b4444
commit
0c6ea8d18d
@@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
|
||||
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
|
||||
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
|
||||
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
@@ -60,10 +61,12 @@ object AutofillModule {
|
||||
@Provides
|
||||
fun providesAutofillCipherProvider(
|
||||
authRepository: AuthRepository,
|
||||
cipherMatchingManager: CipherMatchingManager,
|
||||
vaultRepository: VaultRepository,
|
||||
): AutofillCipherProvider =
|
||||
AutofillCipherProviderImpl(
|
||||
authRepository = authRepository,
|
||||
cipherMatchingManager = cipherMatchingManager,
|
||||
vaultRepository = vaultRepository,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
|
||||
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
|
||||
import com.x8bit.bitwarden.data.platform.util.subtitle
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first
|
||||
*/
|
||||
class AutofillCipherProviderImpl(
|
||||
private val authRepository: AuthRepository,
|
||||
private val cipherMatchingManager: CipherMatchingManager,
|
||||
private val vaultRepository: VaultRepository,
|
||||
) : AutofillCipherProvider {
|
||||
private val activeUserId: String? get() = authRepository.activeUserId
|
||||
@@ -35,7 +36,8 @@ class AutofillCipherProviderImpl(
|
||||
return cipherViews
|
||||
.mapNotNull { cipherView ->
|
||||
cipherView
|
||||
.takeIf { cipherView.type == CipherType.CARD }
|
||||
// We only care about non-deleted card ciphers.
|
||||
.takeIf { cipherView.type == CipherType.CARD && cipherView.deletedDate == null }
|
||||
?.let { nonNullCipherView ->
|
||||
AutofillCipher.Card(
|
||||
name = nonNullCipherView.name,
|
||||
@@ -54,24 +56,23 @@ class AutofillCipherProviderImpl(
|
||||
uri: String,
|
||||
): List<AutofillCipher.Login> {
|
||||
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
|
||||
// We only care about non-deleted login ciphers.
|
||||
val loginCiphers = cipherViews
|
||||
.filter { it.type == CipherType.LOGIN && it.deletedDate == null }
|
||||
|
||||
return cipherViews
|
||||
.mapNotNull { cipherView ->
|
||||
cipherView
|
||||
.takeIf { cipherView.type == CipherType.LOGIN }
|
||||
// TODO: Get global URI matching value from settings repo and
|
||||
// TODO: perform more complex URI matching here (BIT-1461).
|
||||
?.takeIfUriMatches(
|
||||
uri = uri,
|
||||
)
|
||||
?.let { nonNullCipherView ->
|
||||
AutofillCipher.Login(
|
||||
name = nonNullCipherView.name,
|
||||
password = nonNullCipherView.login?.password.orEmpty(),
|
||||
subtitle = nonNullCipherView.subtitle.orEmpty(),
|
||||
username = nonNullCipherView.login?.username.orEmpty(),
|
||||
)
|
||||
}
|
||||
return cipherMatchingManager
|
||||
// Filter for ciphers that match the uri in some way.
|
||||
.filterCiphersForMatches(
|
||||
ciphers = loginCiphers,
|
||||
matchUri = uri,
|
||||
)
|
||||
.map { cipherView ->
|
||||
AutofillCipher.Login(
|
||||
name = cipherView.name,
|
||||
password = cipherView.login?.password.orEmpty(),
|
||||
subtitle = cipherView.subtitle.orEmpty(),
|
||||
username = cipherView.login?.username.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
|
||||
|
||||
import com.bitwarden.core.CipherView
|
||||
|
||||
/**
|
||||
* A manager for matching ciphers based on special criteria.
|
||||
*/
|
||||
interface CipherMatchingManager {
|
||||
/**
|
||||
* Filter [ciphers] for entries that match the [matchUri] in some fashion.
|
||||
*/
|
||||
suspend fun filterCiphersForMatches(
|
||||
ciphers: List<CipherView>,
|
||||
matchUri: String,
|
||||
): List<CipherView>
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.bitwarden.core.UriMatchType
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
|
||||
import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull
|
||||
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
|
||||
import com.x8bit.bitwarden.data.platform.util.isAndroidApp
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.toSdkUriMatchType
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlin.text.Regex
|
||||
import kotlin.text.RegexOption
|
||||
import kotlin.text.isNullOrBlank
|
||||
import kotlin.text.lowercase
|
||||
import kotlin.text.matches
|
||||
import kotlin.text.startsWith
|
||||
|
||||
/**
|
||||
* The default [CipherMatchingManager] implementation. This class is responsible for matching
|
||||
* ciphers based on special criteria.
|
||||
*/
|
||||
class CipherMatchingManagerImpl(
|
||||
private val context: Context,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
) : CipherMatchingManager {
|
||||
override suspend fun filterCiphersForMatches(
|
||||
ciphers: List<CipherView>,
|
||||
matchUri: String,
|
||||
): List<CipherView> {
|
||||
val equivalentDomainsData = vaultRepository
|
||||
.domainsStateFlow
|
||||
.mapNotNull { it.data }
|
||||
.first()
|
||||
|
||||
val isAndroidApp = matchUri.isAndroidApp()
|
||||
val defaultUriMatchType = settingsRepository.defaultUriMatchType.toSdkUriMatchType()
|
||||
val domain = matchUri
|
||||
.getDomainOrNull(context = context)
|
||||
?.lowercase()
|
||||
|
||||
// Retrieve domains that are considered equivalent to the specified matchUri for cipher
|
||||
// comparison. If a cipher doesn't have a URI matching the matchUri, but matches a domain in
|
||||
// matchingDomains, it's considered a match.
|
||||
val matchingDomains = getMatchingDomains(
|
||||
domainsData = equivalentDomainsData,
|
||||
isAndroidApp = isAndroidApp,
|
||||
matchDomain = domain,
|
||||
matchUri = matchUri,
|
||||
)
|
||||
|
||||
val exactMatchingCiphers = mutableListOf<CipherView>()
|
||||
val fuzzyMatchingCiphers = mutableListOf<CipherView>()
|
||||
|
||||
ciphers
|
||||
.forEach { cipherView ->
|
||||
val matchResult = checkForCipherMatch(
|
||||
cipherView = cipherView,
|
||||
context = context,
|
||||
defaultUriMatchType = defaultUriMatchType,
|
||||
isAndroidApp = isAndroidApp,
|
||||
matchUri = matchUri,
|
||||
matchingDomains = matchingDomains,
|
||||
)
|
||||
|
||||
when (matchResult) {
|
||||
MatchResult.EXACT -> exactMatchingCiphers.add(cipherView)
|
||||
MatchResult.FUZZY -> fuzzyMatchingCiphers.add(cipherView)
|
||||
MatchResult.NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return exactMatchingCiphers + fuzzyMatchingCiphers
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of domains that match the specified domain. If the domain is contained within
|
||||
* the [DomainsData], this will return all matching domains. Otherwise, it will return
|
||||
* [matchDomain] or [matchUri] depending on [isAndroidApp].
|
||||
*/
|
||||
private fun getMatchingDomains(
|
||||
domainsData: DomainsData,
|
||||
isAndroidApp: Boolean,
|
||||
matchDomain: String?,
|
||||
matchUri: String,
|
||||
): MatchingDomains {
|
||||
val androidAppWebHost = matchUri.getWebHostFromAndroidUriOrNull()
|
||||
val equivalentDomainsList = domainsData
|
||||
.equivalentDomains
|
||||
.plus(
|
||||
elements = domainsData
|
||||
.globalEquivalentDomains
|
||||
.map { it.domains },
|
||||
)
|
||||
|
||||
val exactMatchDomains = mutableListOf<String>()
|
||||
val fuzzyMatchDomains = mutableListOf<String>()
|
||||
equivalentDomainsList
|
||||
.forEach { equivalentDomains ->
|
||||
when {
|
||||
isAndroidApp && equivalentDomains.contains(matchUri) -> {
|
||||
exactMatchDomains.addAll(equivalentDomains)
|
||||
}
|
||||
|
||||
isAndroidApp && equivalentDomains.contains(androidAppWebHost) -> {
|
||||
fuzzyMatchDomains.addAll(equivalentDomains)
|
||||
}
|
||||
|
||||
!isAndroidApp && equivalentDomains.contains(matchDomain) -> {
|
||||
exactMatchDomains.addAll(equivalentDomains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no equivalent domains, add a version of the original URI to the list.
|
||||
when {
|
||||
exactMatchDomains.isEmpty() && isAndroidApp -> exactMatchDomains.add(matchUri)
|
||||
exactMatchDomains.isEmpty() && matchDomain != null -> exactMatchDomains.add(matchDomain)
|
||||
}
|
||||
|
||||
return MatchingDomains(
|
||||
exactMatches = exactMatchDomains,
|
||||
fuzzyMatches = fuzzyMatchDomains,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if [cipherView] matches [matchUri] in some way. The returned [MatchResult] will
|
||||
* provide details on the match quality.
|
||||
*
|
||||
* @param cipherView The cipher to be judged for a match.
|
||||
* @param context A context for getting string resources.
|
||||
* @param defaultUriMatchType The global default [UriMatchType].
|
||||
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
|
||||
* @param matchingDomains The set of domains that match the domain of [matchUri].
|
||||
* @param matchUri The uri that this cipher is being matched to.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private fun checkForCipherMatch(
|
||||
cipherView: CipherView,
|
||||
context: Context,
|
||||
defaultUriMatchType: UriMatchType,
|
||||
isAndroidApp: Boolean,
|
||||
matchingDomains: MatchingDomains,
|
||||
matchUri: String,
|
||||
): MatchResult {
|
||||
val matchResults = cipherView
|
||||
.login
|
||||
?.uris
|
||||
?.map { loginUriView ->
|
||||
loginUriView.checkForMatch(
|
||||
context = context,
|
||||
defaultUriMatchType = defaultUriMatchType,
|
||||
isAndroidApp = isAndroidApp,
|
||||
matchingDomains = matchingDomains,
|
||||
matchUri = matchUri,
|
||||
)
|
||||
}
|
||||
|
||||
return matchResults
|
||||
?.firstOrNull { it == MatchResult.EXACT }
|
||||
?: matchResults
|
||||
?.firstOrNull { it == MatchResult.FUZZY }
|
||||
?: MatchResult.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see how well this [LoginUriView] matches [matchUri].
|
||||
*
|
||||
* @param context A context for getting app information.
|
||||
* @param defaultUriMatchType The global default [UriMatchType].
|
||||
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
|
||||
* @param matchingDomains The set of domains that match the domain of [matchUri].
|
||||
* @param matchUri The uri that this [LoginUriView] is being matched to.
|
||||
*/
|
||||
private fun LoginUriView.checkForMatch(
|
||||
context: Context,
|
||||
defaultUriMatchType: UriMatchType,
|
||||
isAndroidApp: Boolean,
|
||||
matchingDomains: MatchingDomains,
|
||||
matchUri: String,
|
||||
): MatchResult {
|
||||
val matchType = this.match ?: defaultUriMatchType
|
||||
val loginViewUri = this.uri
|
||||
|
||||
return if (!loginViewUri.isNullOrBlank()) {
|
||||
when (matchType) {
|
||||
UriMatchType.DOMAIN -> {
|
||||
checkUriForDomainMatch(
|
||||
context = context,
|
||||
isAndroidApp = isAndroidApp,
|
||||
matchingDomains = matchingDomains,
|
||||
uri = loginViewUri,
|
||||
)
|
||||
}
|
||||
|
||||
UriMatchType.EXACT -> exactIfTrue(loginViewUri == matchUri)
|
||||
|
||||
UriMatchType.HOST -> {
|
||||
val loginUriHost = loginViewUri.getHostWithPortOrNull()
|
||||
val matchUriHost = matchUri.getHostWithPortOrNull()
|
||||
exactIfTrue(matchUriHost != null && loginUriHost == matchUriHost)
|
||||
}
|
||||
|
||||
UriMatchType.NEVER -> MatchResult.NONE
|
||||
|
||||
UriMatchType.REGULAR_EXPRESSION -> {
|
||||
val pattern = Regex(loginViewUri, RegexOption.IGNORE_CASE)
|
||||
exactIfTrue(matchUri.matches(pattern))
|
||||
}
|
||||
|
||||
UriMatchType.STARTS_WITH -> exactIfTrue(matchUri.startsWith(loginViewUri))
|
||||
}
|
||||
} else {
|
||||
MatchResult.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if [uri] matches [matchingDomains] in some way.
|
||||
*/
|
||||
private fun checkUriForDomainMatch(
|
||||
isAndroidApp: Boolean,
|
||||
context: Context,
|
||||
matchingDomains: MatchingDomains,
|
||||
uri: String,
|
||||
): MatchResult = when {
|
||||
matchingDomains.exactMatches.contains(uri) -> MatchResult.EXACT
|
||||
isAndroidApp && matchingDomains.fuzzyMatches.contains(uri) -> MatchResult.FUZZY
|
||||
else -> {
|
||||
val domain = uri
|
||||
.getDomainOrNull(context = context)
|
||||
?.lowercase()
|
||||
|
||||
// We only care about fuzzy matches if we are isAndroidApp is true because the fuzzu
|
||||
// matches are generated using a app URI derived host.
|
||||
when {
|
||||
matchingDomains.exactMatches.contains(domain) -> MatchResult.EXACT
|
||||
isAndroidApp && matchingDomains.fuzzyMatches.contains(domain) -> MatchResult.FUZZY
|
||||
else -> MatchResult.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple function to return [MatchResult.EXACT] if [condition] is true, and
|
||||
* [MatchResult.NONE] otherwise.
|
||||
*/
|
||||
private fun exactIfTrue(condition: Boolean): MatchResult =
|
||||
if (condition) MatchResult.EXACT else MatchResult.NONE
|
||||
|
||||
/**
|
||||
* A convenience data class for holding domain matches.
|
||||
*/
|
||||
private data class MatchingDomains(
|
||||
val exactMatches: List<String>,
|
||||
val fuzzyMatches: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* A enum to represent the quality of a match.
|
||||
*/
|
||||
private enum class MatchResult {
|
||||
EXACT,
|
||||
FUZZY,
|
||||
NONE,
|
||||
}
|
||||
@@ -22,11 +22,15 @@ import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PushManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -48,6 +52,19 @@ object PlatformManagerModule {
|
||||
fun provideAppForegroundManager(): AppForegroundManager =
|
||||
AppForegroundManagerImpl()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesCipherMatchingManager(
|
||||
@ApplicationContext context: Context,
|
||||
settingsRepository: SettingsRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
): CipherMatchingManager =
|
||||
CipherMatchingManagerImpl(
|
||||
context = context,
|
||||
settingsRepository = settingsRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideClock(): Clock = Clock.systemDefaultZone()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
/**
|
||||
* A data class containing the result of parsing the URL.
|
||||
*
|
||||
* Example:
|
||||
* - URL: m.google.com
|
||||
* - domain: google.com
|
||||
* - secondLevelDomain: google
|
||||
* - subDomain: m
|
||||
* - topLevelDomain: com
|
||||
*
|
||||
* @property secondLevelDomain The second-level domain of the URL, if it exists.
|
||||
* @property subDomain The subdomain of the URL.
|
||||
* @property topLevelDomain The top-level domain (TLD) of the URL.
|
||||
*/
|
||||
data class DomainName(
|
||||
val secondLevelDomain: String?,
|
||||
val subDomain: String?,
|
||||
val topLevelDomain: String,
|
||||
) {
|
||||
/**
|
||||
* The domain of the URL, constructed from the second-level and top-level domains.
|
||||
*/
|
||||
val domain: String
|
||||
get() = "$secondLevelDomain.$topLevelDomain"
|
||||
}
|
||||
@@ -68,18 +68,3 @@ private val CardView.subtitleCardNumber: String?
|
||||
*/
|
||||
private val String?.isAmEx: Boolean
|
||||
get() = this?.startsWith("34") == true || this?.startsWith("37") == true
|
||||
|
||||
/**
|
||||
* Take this [CipherView] if its uri matches [uri]. Otherwise, return null.
|
||||
*/
|
||||
fun CipherView.takeIfUriMatches(
|
||||
uri: String,
|
||||
): CipherView? =
|
||||
// TODO: Pass global URI matching value from settings (BIT-1461)
|
||||
this
|
||||
.takeIf {
|
||||
// TODO: perform comprehensive URI matching (BIT-1461)
|
||||
login
|
||||
?.uris
|
||||
?.any { it.uri == uri } == true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import android.content.Context
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* The protocol for and Android app URI.
|
||||
*/
|
||||
private const val ANDROID_APP_PROTOCOL: String = "androidapp://"
|
||||
|
||||
/**
|
||||
* Try creating a [URI] out of this [String]. If it fails, return null.
|
||||
*/
|
||||
fun String.toUriOrNull(): URI? =
|
||||
try {
|
||||
URI(this)
|
||||
} catch (e: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this [String] represents an android app URI.
|
||||
*/
|
||||
fun String.isAndroidApp(): Boolean =
|
||||
this.startsWith(ANDROID_APP_PROTOCOL)
|
||||
|
||||
/**
|
||||
* Try and extract the web host from this [String] if it represents an Android app.
|
||||
*/
|
||||
fun String.getWebHostFromAndroidUriOrNull(): String? =
|
||||
if (this.isAndroidApp()) {
|
||||
val components = this
|
||||
.replace(ANDROID_APP_PROTOCOL, "")
|
||||
.split('.')
|
||||
|
||||
if (components.size > 1) {
|
||||
"${components[1]}.${components[0]}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the domain name from this [String] if possible, otherwise return null.
|
||||
*/
|
||||
fun String.getDomainOrNull(context: Context): String? =
|
||||
this
|
||||
.toUriOrNull()
|
||||
?.parseDomainOrNull(context = context)
|
||||
|
||||
/**
|
||||
* Extract the host with port from this [String] if possible, otherwise return null.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
fun String.getHostWithPortOrNull(): String? =
|
||||
this
|
||||
.toUriOrNull()
|
||||
?.let { uri ->
|
||||
val host = uri.host
|
||||
val port = uri.port
|
||||
|
||||
if (host != null && port != -1) {
|
||||
"$host:$port"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the indices of the last occurrences of [substring] within this [String]. Return null if no
|
||||
* occurrences are found.
|
||||
*/
|
||||
fun String.findLastSubstringIndicesOrNull(substring: String): IntRange? {
|
||||
val lastIndex = this.lastIndexOf(substring)
|
||||
|
||||
return if (lastIndex != -1) {
|
||||
val endIndex = lastIndex + substring.length - 1
|
||||
IntRange(lastIndex, endIndex)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import android.content.Context
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.DomainName
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* A regular expression that matches IP addresses.
|
||||
*/
|
||||
private const val IP_REGEX: String =
|
||||
"^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
|
||||
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
|
||||
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\." +
|
||||
"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
||||
|
||||
/**
|
||||
* Parses the base domain from the URL. Returns null if unavailable.
|
||||
*/
|
||||
fun URI.parseDomainOrNull(context: Context): String? {
|
||||
val host = this?.host ?: return null
|
||||
val isIpAddress = host.matches(IP_REGEX.toRegex())
|
||||
|
||||
return if (host == "localhost" || isIpAddress) {
|
||||
host
|
||||
} else {
|
||||
parseDomainNameOrNullInternal(
|
||||
context = context,
|
||||
host = host,
|
||||
)
|
||||
?.domain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a URL to get the breakdown of a URL's domain. Returns null if invalid.
|
||||
*/
|
||||
fun URI.parseDomainNameOrNull(context: Context): DomainName? =
|
||||
this
|
||||
// URI is a platform type and host can be null.
|
||||
?.host
|
||||
?.let { nonNullHost ->
|
||||
parseDomainNameOrNullInternal(
|
||||
context = context,
|
||||
host = nonNullHost,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal implementation of [parseDomainNameOrNull]. This doesn't extend URI and has a
|
||||
* non-null [host] parameter. Technically, URI.host could be null and we want to avoid issues with
|
||||
* that.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
private fun parseDomainNameOrNullInternal(
|
||||
context: Context,
|
||||
host: String,
|
||||
): DomainName? {
|
||||
val exceptionSuffixes = context
|
||||
.resources
|
||||
.getStringArray(R.array.exception_suffixes)
|
||||
.toList()
|
||||
val normalSuffixes = context
|
||||
.resources
|
||||
.getStringArray(R.array.normal_suffixes)
|
||||
.toList()
|
||||
val wildCardSuffixes = context
|
||||
.resources
|
||||
.getStringArray(R.array.wild_card_suffixes)
|
||||
.toList()
|
||||
|
||||
// Split the host into parts separated by a period. Start with the last part and incrementally
|
||||
// add back the earlier parts to build a list of any matching domains in the data set.
|
||||
val hostParts = host
|
||||
.split(".")
|
||||
.reversed()
|
||||
var partialDomain = ""
|
||||
val ruleMatches: MutableList<SuffixMatchType> = mutableListOf()
|
||||
|
||||
// Check to see if this part of the host belongs to any of the suffix lists.
|
||||
hostParts
|
||||
.forEach { hostPart ->
|
||||
partialDomain = if (partialDomain.isBlank()) {
|
||||
hostPart
|
||||
} else {
|
||||
"$hostPart.$partialDomain"
|
||||
}
|
||||
|
||||
when {
|
||||
// Normal suffixes first.
|
||||
normalSuffixes.contains(partialDomain) -> {
|
||||
ruleMatches.add(
|
||||
SuffixMatchType.Normal(
|
||||
partialDomain = partialDomain,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Then wild cards.
|
||||
wildCardSuffixes.contains(partialDomain) -> {
|
||||
ruleMatches.add(
|
||||
SuffixMatchType.WildCard(
|
||||
partialDomain = partialDomain,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// And finally, exceptions.
|
||||
exceptionSuffixes.contains(partialDomain) -> {
|
||||
ruleMatches.add(
|
||||
SuffixMatchType.Exception(
|
||||
partialDomain = partialDomain,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take only the largest public suffix match that occurs within our URI's host. We want the
|
||||
// largest because if the URI was "airbnb.co.uk" we our list would contain "uk" and "co.uk",
|
||||
// which are both valid top level domains. In this case, "uk" is just simply not the top level
|
||||
// domain.
|
||||
val largestMatch = ruleMatches.maxByOrNull {
|
||||
it
|
||||
.partialDomain
|
||||
.split('.')
|
||||
.size
|
||||
}
|
||||
|
||||
// Determine the position of the top level domain within the host.
|
||||
val tldRange: IntRange? = when (largestMatch) {
|
||||
is SuffixMatchType.Exception,
|
||||
is SuffixMatchType.Normal,
|
||||
-> {
|
||||
host.findLastSubstringIndicesOrNull(largestMatch.partialDomain)
|
||||
}
|
||||
|
||||
is SuffixMatchType.WildCard -> {
|
||||
// This gets the last portion of the top level domain.
|
||||
val nonWildcardTldIndex = host.lastIndexOf(".${largestMatch.partialDomain}")
|
||||
|
||||
if (nonWildcardTldIndex != -1) {
|
||||
val nonWildcardTld = host.substring(0, nonWildcardTldIndex)
|
||||
|
||||
// But we need to also match the wildcard portion.
|
||||
val dotIndex = nonWildcardTld.lastIndexOf(".")
|
||||
|
||||
if (dotIndex != -1) {
|
||||
IntRange(dotIndex + 1, nonWildcardTldIndex - 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
null -> null
|
||||
}
|
||||
|
||||
return tldRange
|
||||
?.first
|
||||
?.let { firstIndex ->
|
||||
val topLevelDomain = host.substring(firstIndex)
|
||||
|
||||
// Parse the remaining parts prior to the TLD.
|
||||
// - If there's 0 parts left, there is just a TLD and no domain or subdomain.
|
||||
// - If there's 1 part, it's the domain, and there is no subdomain.
|
||||
// - If there's 2+ parts, the last part is the domain, the other parts (combined) are
|
||||
// the subdomain.
|
||||
val possibleSubDomainAndDomain = if (firstIndex > 0) {
|
||||
host.substring(0, firstIndex - 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val subDomainAndDomainParts = possibleSubDomainAndDomain?.split(".")
|
||||
val secondLevelDomain = subDomainAndDomainParts?.lastOrNull()
|
||||
val subDomain = subDomainAndDomainParts
|
||||
?.dropLast(1)
|
||||
?.joinToString(separator = ".")
|
||||
// joinToString leaves white space if called on an empty list.
|
||||
// So only take the string if it wasn't empty after the dropLast(1).
|
||||
.takeIf { (subDomainAndDomainParts?.size ?: 0) > 1 }
|
||||
|
||||
DomainName(
|
||||
secondLevelDomain = secondLevelDomain,
|
||||
topLevelDomain = topLevelDomain,
|
||||
subDomain = subDomain,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of domain suffix match.
|
||||
*/
|
||||
private sealed class SuffixMatchType {
|
||||
|
||||
/**
|
||||
* The partial domain that was actually matched.
|
||||
*/
|
||||
abstract val partialDomain: String
|
||||
|
||||
/**
|
||||
* The match occurred with an exception suffix that starts with '!'.
|
||||
*/
|
||||
data class Exception(
|
||||
override val partialDomain: String,
|
||||
) : SuffixMatchType()
|
||||
|
||||
/**
|
||||
* The match occurred with a normal suffix.
|
||||
*/
|
||||
data class Normal(
|
||||
override val partialDomain: String,
|
||||
) : SuffixMatchType()
|
||||
|
||||
/**
|
||||
* The match occurred with a wildcard suffix that starts with '*'.
|
||||
*/
|
||||
data class WildCard(
|
||||
override val partialDomain: String,
|
||||
) : SuffixMatchType()
|
||||
}
|
||||
@@ -18,3 +18,16 @@ val UriMatchType.displayLabel: Text
|
||||
UriMatchType.NEVER -> R.string.never
|
||||
}
|
||||
.asText()
|
||||
|
||||
/**
|
||||
* Convert this internal [UriMatchType] to the sdk model.
|
||||
*/
|
||||
fun UriMatchType.toSdkUriMatchType(): com.bitwarden.core.UriMatchType =
|
||||
when (this) {
|
||||
UriMatchType.DOMAIN -> com.bitwarden.core.UriMatchType.DOMAIN
|
||||
UriMatchType.EXACT -> com.bitwarden.core.UriMatchType.EXACT
|
||||
UriMatchType.HOST -> com.bitwarden.core.UriMatchType.HOST
|
||||
UriMatchType.NEVER -> com.bitwarden.core.UriMatchType.NEVER
|
||||
UriMatchType.REGULAR_EXPRESSION -> com.bitwarden.core.UriMatchType.REGULAR_EXPRESSION
|
||||
UriMatchType.STARTS_WITH -> com.bitwarden.core.UriMatchType.STARTS_WITH
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user