Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add blocking algorithm (isMalicious) #5376

Open
wants to merge 9 commits into
base: feature/cris/malicious-site-protection/load-initial-dataset
Choose a base branch
from
Open
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 @@ -126,10 +126,6 @@ class BrowserWebViewClient @Inject constructor(

private var shouldOpenDuckPlayerInNewTab: Boolean = true

private val confirmationCallback: (isMalicious: Boolean) -> Unit = {
// TODO (cbarreiro): Handle site blocked asynchronously
}

init {
appCoroutineScope.launch {
duckPlayer.observeShouldOpenInNewTab().collect {
Expand Down Expand Up @@ -162,7 +158,7 @@ class BrowserWebViewClient @Inject constructor(
try {
Timber.v("shouldOverride webViewUrl: ${webView.url} URL: $url")
webViewClientListener?.onShouldOverride()
if (requestInterceptor.shouldOverrideUrlLoading(url, isForMainFrame)) {
if (requestInterceptor.shouldOverrideUrlLoading(webView, url, isForMainFrame)) {
return true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.duckduckgo.httpsupgrade.api.HttpsUpgrader
import com.duckduckgo.privacy.config.api.Gpc
import com.duckduckgo.request.filterer.api.RequestFilterer
import com.duckduckgo.user.agent.api.UserAgentProvider
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.withContext
import timber.log.Timber

Expand All @@ -62,6 +63,7 @@ interface RequestInterceptor {

@WorkerThread
fun shouldOverrideUrlLoading(
webView: WebView,
url: Uri,
isForMainFrame: Boolean,
): Boolean
Expand Down Expand Up @@ -106,9 +108,9 @@ class WebViewRequestInterceptor(
val url: Uri? = request.url

maliciousSiteBlockerWebViewIntegration.shouldIntercept(request, documentUri) {
handleSiteBlocked()
handleSiteBlocked(webView)
}?.let {
handleSiteBlocked()
handleSiteBlocked(webView)
return it
}

Expand Down Expand Up @@ -177,22 +179,22 @@ class WebViewRequestInterceptor(
return getWebResourceResponse(request, documentUrl, null)
}

override fun shouldOverrideUrlLoading(url: Uri, isForMainFrame: Boolean): Boolean {
override fun shouldOverrideUrlLoading(webView: WebView, url: Uri, isForMainFrame: Boolean): Boolean {
if (maliciousSiteBlockerWebViewIntegration.shouldOverrideUrlLoading(
url,
isForMainFrame,
) {
handleSiteBlocked()
handleSiteBlocked(webView)
}
) {
handleSiteBlocked()
handleSiteBlocked(webView)
return true
}
return false
}

private fun handleSiteBlocked() {
// TODO (cbarreiro): Handle site blocked
private fun handleSiteBlocked(webView: WebView) {
Snackbar.make(webView, "Site blocked", Snackbar.LENGTH_SHORT).show()
}

private fun getWebResourceResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest {
assertFalse(result)
}

@Test
fun `shouldIntercept returns null when feature is enabled, is malicious, and is mainframe but webView has different host`() = runTest {
whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS)
val request = mock(WebResourceRequest::class.java)
whenever(request.url).thenReturn(maliciousUri)
whenever(request.isForMainFrame).thenReturn(false)

val result = testee.shouldIntercept(request, exampleUri) {}
assertNull(result)
}

@Test
fun `onPageLoadStarted clears processedUrls`() = runTest {
testee.processedUrls.add(exampleUri.toString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import dagger.SingleInstanceIn
import java.security.MessageDigest

@Module
@ContributesTo(AppScope::class)
Expand All @@ -45,4 +46,10 @@ class MaliciousSiteModule {
fun provideMaliciousSiteDao(database: MaliciousSitesDatabase): MaliciousSiteDao {
return database.maliciousSiteDao()
}

@Provides
@SingleInstanceIn(AppScope::class)
fun provideMessageDigest(): MessageDigest {
return MessageDigest.getInstance("SHA-256")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.malicioussiteprotection.impl.MaliciousSiteProtectionFeature
import com.duckduckgo.malicioussiteprotection.impl.data.db.MaliciousSiteDao
import com.duckduckgo.malicioussiteprotection.impl.data.embedded.MaliciousSiteProtectionEmbeddedDataProvider
import com.duckduckgo.malicioussiteprotection.impl.data.network.MaliciousSiteService
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

interface MaliciousSiteRepository {
suspend fun containsHashPrefix(hashPrefix: String): Boolean
suspend fun getFilter(hash: String): Filter?
suspend fun getFilters(hash: String): List<Filter>?
suspend fun matches(hashPrefix: String): List<Match>
}

@ContributesBinding(AppScope::class)
Expand All @@ -41,6 +44,7 @@ class RealMaliciousSiteRepository @Inject constructor(
private val maliciousSiteDao: MaliciousSiteDao,
@IsMainProcess private val isMainProcess: Boolean,
maliciousSiteProtectionFeature: MaliciousSiteProtectionFeature,
private val maliciousSiteService: MaliciousSiteService,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
dispatcherProvider: DispatcherProvider,
) : MaliciousSiteRepository {
Expand Down Expand Up @@ -82,9 +86,22 @@ class RealMaliciousSiteRepository @Inject constructor(
return maliciousSiteDao.getHashPrefix(hashPrefix) != null
}

override suspend fun getFilter(hash: String): Filter? {
override suspend fun getFilters(hash: String): List<Filter>? {
return maliciousSiteDao.getFilter(hash)?.let {
Filter(it.hash, it.regex)
it.map {
Filter(it.hash, it.regex)
}
}
}

override suspend fun matches(hashPrefix: String): List<Match> {
return try {
maliciousSiteService.getMatches(hashPrefix).matches.also {
Timber.d("\uD83D\uDFE2 Cris: Fetched $it matches for hash prefix $hashPrefix")
}
} catch (e: Exception) {
Timber.d("\uD83D\uDD34 Cris: Failed to fetch matches for hash prefix $hashPrefix")
listOf()
}
}
}
Expand All @@ -96,20 +113,6 @@ data class Match(
val hash: String,
)

data class HashPrefixResponse(
val insert: Set<String>,
val delete: Set<String>,
val revision: Int,
val replace: String,
)

data class FilterSetResponse(
val insert: Set<Filter>,
val delete: Set<Filter>,
val revision: Int,
val replace: String,
)

data class Filter(
val hash: String,
val regex: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface MaliciousSiteDao {
suspend fun getHashPrefix(hashPrefix: String): HashPrefixEntity?

@Query("SELECT * FROM filters WHERE hash = :hash")
suspend fun getFilter(hash: String): FilterEntity?
suspend fun getFilter(hash: String): List<FilterEntity>?

@Transaction
suspend fun insertData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import android.content.Context
import androidx.annotation.RawRes
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.malicioussiteprotection.impl.R
import com.duckduckgo.malicioussiteprotection.impl.data.FilterSetResponse
import com.duckduckgo.malicioussiteprotection.impl.data.HashPrefixResponse
import com.duckduckgo.malicioussiteprotection.impl.data.network.FilterSetResponse
import com.duckduckgo.malicioussiteprotection.impl.data.network.HashPrefixResponse
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
import javax.inject.Inject
Expand Down Expand Up @@ -50,6 +50,7 @@ class RealMaliciousSiteProtectionEmbeddedDataProvider @Inject constructor(
val adapter = moshi.adapter(FilterSetResponse::class.java)
adapter.fromJson(String(filterSetData))
} catch (e: Exception) {
Timber.d("\uD83D\uDD34 Failed to fetch embedded phishing filter set")
null
}
}
Expand Down Expand Up @@ -82,7 +83,7 @@ class RealMaliciousSiteProtectionEmbeddedDataProvider @Inject constructor(
val adapter = moshi.adapter(HashPrefixResponse::class.java)
adapter.fromJson(String(hashPrefixData))
} catch (e: Exception) {
Timber.d("\uD83D\uDD34 Cris: Failed to fetch embedded malware hash prefixes")
Timber.d("\uD83D\uDD34 Failed to fetch embedded malware hash prefixes")
null
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.malicioussiteprotection.impl.data.network

import com.duckduckgo.anvil.annotations.ContributesServiceApi
import com.duckduckgo.common.utils.AppUrl.Url.API
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.malicioussiteprotection.impl.data.Filter
import com.duckduckgo.malicioussiteprotection.impl.data.Match
import retrofit2.http.GET
import retrofit2.http.Query

private const val BASE_URL = "$API/api/protection"
private const val HASH_PREFIX_PATH = "/hashPrefix"
private const val FILTER_SET_PATH = "/filterSet"
private const val CATEGORY = "category"
private const val PHISHING = "phishing"
private const val MALWARE = "malware"

@ContributesServiceApi(AppScope::class)
interface MaliciousSiteService {
@GET("$BASE_URL$HASH_PREFIX_PATH?$CATEGORY=$PHISHING")
suspend fun getPhishingHashPrefixes(@Query("revision") revision: Int): HashPrefixResponse

@GET("$BASE_URL$HASH_PREFIX_PATH?$CATEGORY=$MALWARE")
suspend fun getMalwareHashPrefixes(@Query("revision") revision: Int): HashPrefixResponse

@GET("$BASE_URL$FILTER_SET_PATH?$CATEGORY=$PHISHING")
suspend fun getPhishingFilterSet(@Query("revision") revision: Int): FilterSetResponse

@GET("$BASE_URL$FILTER_SET_PATH?$CATEGORY=$MALWARE")
suspend fun getMalwareFilterSet(@Query("revision") revision: Int): FilterSetResponse

@GET("$BASE_URL/matches")
suspend fun getMatches(@Query("hashPrefix") hashPrefix: String): MatchesResponse
}

data class MatchesResponse(
val matches: List<Match>,
)

data class HashPrefixResponse(
val insert: Set<String>,
val delete: Set<String>,
val revision: Int,
val replace: Boolean,
)

data class FilterSetResponse(
val insert: Set<Filter>,
val delete: Set<Filter>,
val revision: Int,
val replace: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,75 @@
package com.duckduckgo.malicioussiteprotection.impl.domain

import android.net.Uri
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult
import com.duckduckgo.malicioussiteprotection.impl.MaliciousSiteProtectionRCFeature
import com.duckduckgo.malicioussiteprotection.impl.data.MaliciousSiteRepository
import com.squareup.anvil.annotations.ContributesBinding
import java.security.MessageDigest
import java.util.regex.Pattern
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

@ContributesBinding(AppScope::class, MaliciousSiteProtection::class)
class RealMaliciousSiteProtection @Inject constructor(
maliciousSiteProtectionRCFeature: MaliciousSiteProtectionRCFeature,
private val dispatchers: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val maliciousSiteRepository: MaliciousSiteRepository,
private val messageDigest: MessageDigest,
private val maliciousSiteProtectionRCFeature: MaliciousSiteProtectionRCFeature,
) : MaliciousSiteProtection {

override suspend fun isMalicious(url: Uri, confirmationCallback: (isMalicious: Boolean) -> Unit): IsMaliciousResult {
Timber.tag("MaliciousSiteProtection").d("isMalicious $url")
// TODO (cbarreiro): Implement the logic to check if the URL is malicious
return IsMaliciousResult.SAFE

if (!maliciousSiteProtectionRCFeature.isFeatureEnabled()) {
Timber.d("\uD83D\uDFE2 Cris: should not block (feature disabled) $url")
return IsMaliciousResult.SAFE
}

val hostname = url.host ?: return IsMaliciousResult.SAFE
val hash = messageDigest
.digest(hostname.toByteArray())
.joinToString("") { "%02x".format(it) }
val hashPrefix = hash.substring(0, 8)

if (!maliciousSiteRepository.containsHashPrefix(hashPrefix)) {
Timber.d("\uD83D\uDFE2 Cris: should not block (no hash) $hashPrefix, $url")
return IsMaliciousResult.SAFE
}
maliciousSiteRepository.getFilters(hash)?.let {
for (filter in it) {
if (Pattern.compile(filter.regex).matcher(url.toString()).find()) {
Timber.d("\uD83D\uDFE2 Cris: shouldBlock $url")
return IsMaliciousResult.MALICIOUS
}
}
}
appCoroutineScope.launch(dispatchers.io()) {
confirmationCallback(matches(hashPrefix, url, hostname, hash))
}
return IsMaliciousResult.WAIT_FOR_CONFIRMATION
}

private suspend fun matches(
hashPrefix: String,
url: Uri,
hostname: String,
hash: String,
): Boolean {
val matches = maliciousSiteRepository.matches(hashPrefix.substring(0, 4))
return matches.any { match ->
Pattern.compile(match.regex).matcher(url.toString()).find() &&
(hostname == match.hostname) &&
(hash == match.hash)
}.also { matched ->
Timber.d("\uD83D\uDFE2 Cris: should block $matched")
}
}
}
Loading
Loading