diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 714b312e63fb..0ed991663979 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -218,6 +218,7 @@ import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks @@ -677,7 +678,6 @@ class BrowserTabViewModelTest { toggleReports = mockToggleReports, brokenSitePrompt = mockBrokenSitePrompt, tabStatsBucketing = mockTabStatsBucketing, - maliciousSiteBlockerWebViewIntegration = mock(), defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment, swipingTabsFeature = swipingTabsFeatureProvider, ) @@ -5085,7 +5085,7 @@ class BrowserTabViewModelTest { @Test fun whenMaliciousSiteActionLeaveSiteAndCustomTabThenClose() { val url = "http://example.com".toUri() - testee.onMaliciousSiteUserAction(LeaveSite, url, true) + testee.onMaliciousSiteUserAction(LeaveSite, url, MALWARE, true) verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertTrue(commandCaptor.allValues.any { it is Command.CloseCustomTab }) } @@ -5093,7 +5093,7 @@ class BrowserTabViewModelTest { @Test fun whenMaliciousSiteActionLeaveSiteAndCustomTabFalseThenHideSSLError() { val url = "http://example.com".toUri() - testee.onMaliciousSiteUserAction(LeaveSite, url, false) + testee.onMaliciousSiteUserAction(LeaveSite, url, MALWARE, false) verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertTrue(commandCaptor.allValues.any { it is Command.EscapeMaliciousSite }) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 6dadd2047d77..3b196783e93d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -61,7 +61,6 @@ import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin import com.duckduckgo.app.browser.trafficquality.CustomHeaderAllowedChecker import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider import com.duckduckgo.app.browser.uriloaded.UriLoadedManager -import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.statistics.pixels.Pixel @@ -164,7 +163,6 @@ class BrowserWebViewClientTest { mockFeaturesHeaderProvider, mock(), ) - private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() private val mockDuckChat: DuckChat = mock() @UiThreadTest @@ -211,7 +209,6 @@ class BrowserWebViewClientTest { whenever(currentTimeProvider.elapsedRealtime()).thenReturn(0) whenever(webViewVersionProvider.getMajorVersion()).thenReturn("1") whenever(deviceInfo.appVersion).thenReturn("1") - whenever(mockMaliciousSiteProtection.shouldOverrideUrlLoading(any(), any(), any())).thenReturn(false) } @UiThreadTest diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt index c1aca6bcaf18..754f063c3dee 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt @@ -30,6 +30,7 @@ import androidx.test.annotation.UiThreadTest import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.useragent.provideUserAgentOverridePluginPoint import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration +import com.duckduckgo.app.fakes.FakeMaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.fakes.FeatureToggleFake import com.duckduckgo.app.fakes.UserAgentFake import com.duckduckgo.app.fakes.UserAllowListRepositoryFake @@ -98,7 +99,7 @@ class WebViewRequestInterceptorTest { fakeToggle, fakeUserAllowListRepository, ) - private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() + private val mockMaliciousSiteBlockerWebViewIntegration: MaliciousSiteBlockerWebViewIntegration = FakeMaliciousSiteBlockerWebViewIntegration() private var webView: WebView = mock() @@ -119,7 +120,7 @@ class WebViewRequestInterceptorTest { cloakedCnameDetector = mockCloakedCnameDetector, requestFilterer = mockRequestFilterer, duckPlayer = mockDuckPlayer, - maliciousSiteBlockerWebViewIntegration = mockMaliciousSiteProtection, + maliciousSiteBlockerWebViewIntegration = mockMaliciousSiteBlockerWebViewIntegration, ) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/urlextraction/UrlExtractingWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/urlextraction/UrlExtractingWebViewClientTest.kt index df4281b4a21b..52df9a0b1ecb 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/urlextraction/UrlExtractingWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/urlextraction/UrlExtractingWebViewClientTest.kt @@ -24,7 +24,6 @@ import com.duckduckgo.app.browser.* import com.duckduckgo.app.browser.certificates.rootstore.TrustedCertificateStore import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore -import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.cookies.api.CookieManagerProvider import kotlinx.coroutines.test.TestScope @@ -51,7 +50,6 @@ class UrlExtractingWebViewClientTest { private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() private val urlExtractor: DOMUrlExtractor = mock() private val mockWebView: WebView = mock() - private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() @UiThreadTest @Before diff --git a/app/src/androidTest/java/com/duckduckgo/app/fakes/FakeMaliciousSiteBlockerWebViewIntegration.kt b/app/src/androidTest/java/com/duckduckgo/app/fakes/FakeMaliciousSiteBlockerWebViewIntegration.kt new file mode 100644 index 000000000000..501245b79094 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fakes/FakeMaliciousSiteBlockerWebViewIntegration.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 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.app.fakes + +import android.net.Uri +import android.webkit.WebResourceRequest +import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.Safe +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus + +class FakeMaliciousSiteBlockerWebViewIntegration : MaliciousSiteBlockerWebViewIntegration { + override suspend fun shouldIntercept( + request: WebResourceRequest, + documentUri: Uri?, + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData { + return Safe + } + + override fun shouldOverrideUrlLoading( + url: Uri, + isForMainFrame: Boolean, + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData { + return Safe + } + + override fun onPageLoadStarted() { + // no-op + } + + override fun onSiteExempted( + url: Uri, + feed: Feed, + ) { + TODO("Not yet implemented") + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt index f98a8e43cce8..4ce2a52d1817 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt @@ -29,6 +29,7 @@ import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.WebViewRequestInterceptor import com.duckduckgo.app.browser.useragent.provideUserAgentOverridePluginPoint import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration +import com.duckduckgo.app.fakes.FakeMaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.fakes.FeatureToggleFake import com.duckduckgo.app.fakes.UserAgentFake import com.duckduckgo.app.fakes.UserAllowListRepositoryFake @@ -120,7 +121,7 @@ class DomainsReferenceTest(private val testCase: TestCase) { ) private val mockGpc: Gpc = mock() private val mockAdClickManager: AdClickManager = mock() - private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() + private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = FakeMaliciousSiteBlockerWebViewIntegration() companion object { private val moshi = Moshi.Builder().add(ActionJsonAdapter()).build() diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt index 98673c5f285a..a6e67d305726 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt @@ -28,6 +28,7 @@ import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.WebViewRequestInterceptor import com.duckduckgo.app.browser.useragent.provideUserAgentOverridePluginPoint import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration +import com.duckduckgo.app.fakes.FakeMaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.fakes.FeatureToggleFake import com.duckduckgo.app.fakes.UserAgentFake import com.duckduckgo.app.fakes.UserAllowListRepositoryFake @@ -116,7 +117,7 @@ class SurrogatesReferenceTest(private val testCase: TestCase) { private val mockGpc: Gpc = mock() private val mockAdClickManager: AdClickManager = mock() private val mockCloakedCnameDetector: CloakedCnameDetector = mock() - private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() + private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = FakeMaliciousSiteBlockerWebViewIntegration() companion object { private val moshi = Moshi.Builder().add(ActionJsonAdapter()).build() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index ce70fe52eb53..e3cf59048253 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -272,6 +272,7 @@ import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.navigation.api.GlobalActivityStarter.DeeplinkActivityParams @@ -1385,7 +1386,7 @@ class BrowserTabFragment : errorView.errorLayout.show() } - private fun showMaliciousWarning(url: Uri) { + private fun showMaliciousWarning(url: Uri, feed: Feed) { webViewContainer.gone() newBrowserTab.newTabLayout.gone() newBrowserTab.newTabContainerLayout.gone() @@ -1396,8 +1397,8 @@ class BrowserTabFragment : webView?.onPause() webView?.hide() webView?.stopLoading() - maliciousWarningView.bind { action -> - viewModel.onMaliciousSiteUserAction(action, url, isActiveCustomTab()) + maliciousWarningView.bind(feed) { action -> + viewModel.onMaliciousSiteUserAction(action, url, feed, isActiveCustomTab()) } maliciousWarningView.show() binding.focusDummy.requestFocus() @@ -1427,8 +1428,9 @@ class BrowserTabFragment : (activity as? CustomTabActivity)?.finishAndRemoveTask() } - private fun onBypassMaliciousWarning(url: Uri) { + private fun onBypassMaliciousWarning(url: Uri, feed: Feed) { showBrowser() + webViewClient.addExemptedMaliciousSite(url, feed) webView?.loadUrl(url.toString()) } @@ -1801,11 +1803,12 @@ class BrowserTabFragment : ) is Command.WebViewError -> showError(it.errorType, it.url) - is Command.ShowWarningMaliciousSite -> showMaliciousWarning(it.url) + is Command.ShowWarningMaliciousSite -> showMaliciousWarning(it.url, it.feed) is Command.HideWarningMaliciousSite -> hideMaliciousWarning() is Command.EscapeMaliciousSite -> onEscapeMaliciousSite() is Command.CloseCustomTab -> closeCustomTab() - is Command.BypassMaliciousSiteWarning -> onBypassMaliciousWarning(it.url) + is Command.BypassMaliciousSiteWarning -> onBypassMaliciousWarning(it.url, it.feed) + is Command.BypassMaliciousSiteWarning -> onBypassMaliciousWarning(it.url, it.feed) is OpenBrokenSiteLearnMore -> openBrokenSiteLearnMore(it.url) is ReportBrokenSiteError -> openBrokenSiteReportError(it.url) is Command.SendResponseToJs -> contentScopeScripts.onResponse(it.data) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index a741d9b8c1a3..88c557c0b0bd 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -215,7 +215,6 @@ import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Acti import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Action.LeaveSite import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Action.ReportError import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Action.VisitSite -import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.browser.webview.SslWarningLayout.Action import com.duckduckgo.app.cta.ui.BrokenSitePromptDialogCta import com.duckduckgo.app.cta.ui.Cta @@ -231,6 +230,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler import com.duckduckgo.app.global.events.db.UserEventKey import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.model.MaliciousSiteStatus import com.duckduckgo.app.global.model.PrivacyShield import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory @@ -299,6 +299,9 @@ import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks @@ -457,7 +460,6 @@ class BrowserTabViewModel @Inject constructor( private val toggleReports: ToggleReports, private val brokenSitePrompt: BrokenSitePrompt, private val tabStatsBucketing: TabStatsBucketing, - private val maliciousSiteBlockerWebViewIntegration: MaliciousSiteBlockerWebViewIntegration, private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, private val swipingTabsFeature: SwipingTabsFeatureProvider, ) : WebViewClientListener, @@ -1875,6 +1877,7 @@ class BrowserTabViewModel @Inject constructor( fun onMaliciousSiteUserAction( action: MaliciousSiteBlockedWarningLayout.Action, url: Uri, + feed: Feed, activeCustomTab: Boolean, ) { when (action) { @@ -1889,12 +1892,11 @@ class BrowserTabViewModel @Inject constructor( } VisitSite -> { - command.postValue(BypassMaliciousSiteWarning(url)) + command.postValue(BypassMaliciousSiteWarning(url, feed)) browserViewState.value = currentBrowserViewState().copy( browserShowing = true, showPrivacyShield = HighlightableButton.Visible(enabled = true), ) - addExemptedMaliciousUrlToMemory(url) } LearnMore -> command.postValue(OpenBrokenSiteLearnMore(MALICIOUS_SITE_LEARN_MORE_URL)) ReportError -> command.postValue(ReportBrokenSiteError("$MALICIOUS_SITE_REPORT_ERROR_URL$url")) @@ -3195,17 +3197,25 @@ class BrowserTabViewModel @Inject constructor( command.postValue(WebViewError(errorType, url)) } - override fun onReceivedMaliciousSiteWarning(url: Uri) { + override fun onReceivedMaliciousSiteWarning(url: Uri, feed: Feed, exempted: Boolean) { // TODO (cbarreiro): Fire pixel - loadingViewState.postValue(currentLoadingViewState().copy(isLoading = false, progress = 100, url = url.toString())) - browserViewState.postValue( - currentBrowserViewState().copy( - browserShowing = false, - showPrivacyShield = HighlightableButton.Visible(enabled = false), - maliciousSiteDetected = true, - ), - ) - command.postValue(ShowWarningMaliciousSite(url)) + site?.maliciousSiteStatus = when (feed) { + MALWARE -> MaliciousSiteStatus.MALWARE + PHISHING -> MaliciousSiteStatus.PHISHING + } + if (!exempted) { + loadingViewState.postValue( + currentLoadingViewState().copy(isLoading = false, progress = 100, url = url.toString()), + ) + browserViewState.postValue( + currentBrowserViewState().copy( + browserShowing = false, + showPrivacyShield = HighlightableButton.Visible(enabled = false), + maliciousSiteDetected = true, + ), + ) + command.postValue(ShowWarningMaliciousSite(url, feed)) + } } override fun recordErrorCode( @@ -3788,10 +3798,6 @@ class BrowserTabViewModel @Inject constructor( command.value = SetOnboardingDialogBackground(getBackgroundResource(lightModeEnabled)) } - fun addExemptedMaliciousUrlToMemory(url: Uri) { - maliciousSiteBlockerWebViewIntegration.onSiteExempted(url) - } - private fun getBackgroundResource(lightModeEnabled: Boolean): Int = if (lightModeEnabled) { R.drawable.onboarding_background_bitmap_light diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index a3d83afcd712..ed0f587d914d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -78,6 +78,7 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab.On import com.duckduckgo.duckplayer.impl.DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH import com.duckduckgo.history.api.NavigationHistory +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.user.agent.api.ClientBrandHintProvider @@ -710,6 +711,10 @@ class BrowserWebViewClient @Inject constructor( else -> "ERROR_OTHER" } } + + fun addExemptedMaliciousSite(url: Uri, feed: Feed) { + requestInterceptor.addExemptedMaliciousSite(url, feed) + } } enum class WebViewPixelName(override val pixelName: String) : Pixel.PixelName { diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index b9d5eeb258fe..3f16933bdc57 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -29,6 +29,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.trackerdetection.model.TrackingEvent +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions interface WebViewClientListener { @@ -95,7 +96,7 @@ interface WebViewClientListener { fun linkOpenedInNewTab(): Boolean fun isActiveTab(): Boolean fun onReceivedError(errorType: WebViewErrorResponse, url: String) - fun onReceivedMaliciousSiteWarning(url: Uri) + fun onReceivedMaliciousSiteWarning(url: Uri, feed: Feed, exempted: Boolean) fun recordErrorCode(error: String, url: String) fun recordHttpErrorCode(statusCode: Int, url: String) diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt index 3025281f83d5..d33c8c40e2b7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -23,6 +23,10 @@ import android.webkit.WebView import androidx.annotation.WorkerThread import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.webview.MaliciousSiteBlockerWebViewIntegration +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.MaliciousSite +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.Safe +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.WaitForConfirmation import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.privacy.model.TrustedSites import com.duckduckgo.app.surrogates.ResourceSurrogates @@ -36,6 +40,9 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttp import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.httpsupgrade.api.HttpsUpgrader +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Malicious import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.request.filterer.api.RequestFilterer import com.duckduckgo.user.agent.api.UserAgentProvider @@ -66,6 +73,8 @@ interface RequestInterceptor { url: Uri, isForMainFrame: Boolean, ): Boolean + + fun addExemptedMaliciousSite(url: Uri, feed: Feed) } class WebViewRequestInterceptor( @@ -107,12 +116,9 @@ class WebViewRequestInterceptor( val url: Uri? = request.url maliciousSiteBlockerWebViewIntegration.shouldIntercept(request, documentUri) { isMalicious -> - if (isMalicious) { - handleSiteBlocked(webViewClientListener, url) - } - }?.let { - handleSiteBlocked(webViewClientListener, url) - return it + handleConfirmationCallback(isMalicious, webViewClientListener, url) + }.let { + if (shouldBlock(it, webViewClientListener, url)) return WebResourceResponse(null, null, null) } if (requestFilterer.shouldFilterOutRequest(request, documentUri.toString())) return WebResourceResponse(null, null, null) @@ -166,6 +172,17 @@ class WebViewRequestInterceptor( return getWebResourceResponse(request, documentUri, webViewClientListener) } + override fun shouldOverrideUrlLoading(webViewClientListener: WebViewClientListener?, url: Uri, isForMainFrame: Boolean): Boolean { + maliciousSiteBlockerWebViewIntegration.shouldOverrideUrlLoading( + url, + isForMainFrame, + ) { isMalicious -> + handleConfirmationCallback(isMalicious, webViewClientListener, url) + }.let { + return shouldBlock(it, webViewClientListener, url) + } + } + override suspend fun shouldInterceptFromServiceWorker( request: WebResourceRequest?, documentUrl: Uri?, @@ -180,24 +197,39 @@ class WebViewRequestInterceptor( return getWebResourceResponse(request, documentUrl, null) } - override fun shouldOverrideUrlLoading(webViewClientListener: WebViewClientListener?, url: Uri, isForMainFrame: Boolean): Boolean { - if (maliciousSiteBlockerWebViewIntegration.shouldOverrideUrlLoading( - url, - isForMainFrame, - ) { isMalicious -> - if (isMalicious) { - handleSiteBlocked(webViewClientListener, url) - } + private fun shouldBlock( + result: IsMaliciousViewData, + webViewClientListener: WebViewClientListener?, + url: Uri?, + ): Boolean { + when (result) { + WaitForConfirmation, Safe -> return false + is MaliciousSite -> { + handleSiteBlocked(webViewClientListener, url, result.feed, result.exempted) + return !result.exempted } - ) { - handleSiteBlocked(webViewClientListener, url) - return true } - return false } - private fun handleSiteBlocked(webViewClientListener: WebViewClientListener?, url: Uri?) { - url?.let { webViewClientListener?.onReceivedMaliciousSiteWarning(it) } + private fun handleConfirmationCallback( + isMalicious: MaliciousStatus, + webViewClientListener: WebViewClientListener?, + url: Uri?, + ) { + if (isMalicious is Malicious) { + /* + * If the site is exempted, we'll never get here, as we won't call isMalicious + */ + handleSiteBlocked(webViewClientListener, url, isMalicious.feed, false) + } + } + + private fun handleSiteBlocked(webViewClientListener: WebViewClientListener?, url: Uri?, feed: Feed, exempted: Boolean) { + url?.let { webViewClientListener?.onReceivedMaliciousSiteWarning(it, feed, exempted) } + } + + override fun addExemptedMaliciousSite(url: Uri, feed: Feed) { + maliciousSiteBlockerWebViewIntegration.onSiteExempted(url, feed) } private fun getWebResourceResponse( diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index b082eb7caa67..bff36d4f446c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -42,6 +42,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.privacy.dashboard.api.ui.DashboardOpener import com.duckduckgo.savedsites.api.models.SavedSite import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions @@ -219,6 +220,7 @@ sealed class Command { data class ShowWarningMaliciousSite( val url: Uri, + val feed: Feed, ) : Command() data object HideWarningMaliciousSite : Command() @@ -227,6 +229,7 @@ sealed class Command { data class BypassMaliciousSiteWarning( val url: Uri, + val feed: Feed, ) : Command() data class OpenBrokenSiteLearnMore(val url: String) : Command() diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockedWarningLayout.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockedWarningLayout.kt index 1d5377fa53a0..2d6981023ce4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockedWarningLayout.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockedWarningLayout.kt @@ -37,6 +37,9 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.extensions.html +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING class MaliciousSiteBlockedWarningLayout @JvmOverloads constructor( context: Context, @@ -54,11 +57,12 @@ class MaliciousSiteBlockedWarningLayout @JvmOverloads constructor( private val binding: ViewMaliciousSiteBlockedWarningBinding by viewBinding() fun bind( + feed: Feed, actionHandler: (Action) -> Unit, ) { resetViewState() - formatCopy(actionHandler) + formatCopy(feed, actionHandler) setListeners(actionHandler) } @@ -70,10 +74,19 @@ class MaliciousSiteBlockedWarningLayout @JvmOverloads constructor( } private fun formatCopy( + feed: Feed, actionHandler: (Action) -> Unit, ) { with(binding) { - errorHeadline.setSpannable(R.string.maliciousSiteMalwareHeadline) { actionHandler(LearnMore) } + val errorResource = when (feed) { + MALWARE -> { + R.string.maliciousSiteMalwareHeadline + } + PHISHING -> { + R.string.maliciousSitePhishingHeadline + } + } + errorHeadline.setSpannable(errorResource) { actionHandler(LearnMore) } expandedHeadline.setSpannable(R.string.maliciousSiteExpandedHeadline) { actionHandler(ReportError) } expandedCTA.text = HtmlCompat.fromHtml(context.getString(R.string.maliciousSiteExpandedCTA), HtmlCompat.FROM_HTML_MODE_LEGACY) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockerWebViewIntegration.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockerWebViewIntegration.kt index 1401c4b6fb71..021d4881b64a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockerWebViewIntegration.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockerWebViewIntegration.kt @@ -18,20 +18,26 @@ package com.duckduckgo.app.browser.webview import android.net.Uri import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse import androidx.annotation.VisibleForTesting import androidx.core.net.toUri +import com.duckduckgo.app.browser.webview.ExemptedUrlsHolder.ExemptedUrl +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature 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.MALICIOUS +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.ConfirmedResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.WaitForConfirmation +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Malicious +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Safe import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.SingleInstanceIn import java.net.URLDecoder import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -45,23 +51,39 @@ interface MaliciousSiteBlockerWebViewIntegration { suspend fun shouldIntercept( request: WebResourceRequest, documentUri: Uri?, - confirmationCallback: (isMalicious: Boolean) -> Unit, - ): WebResourceResponse? + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData fun shouldOverrideUrlLoading( url: Uri, isForMainFrame: Boolean, - confirmationCallback: (isMalicious: Boolean) -> Unit, - ): Boolean + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData fun onPageLoadStarted() - fun onSiteExempted(url: Uri) + fun onSiteExempted( + url: Uri, + feed: Feed, + ) } -@SingleInstanceIn(AppScope::class) -class ExemptedUrlsHolder @Inject constructor() { - val exemptedMaliciousUrls = mutableSetOf() +interface ExemptedUrlsHolder { + data class ExemptedUrl(val url: Uri, val feed: Feed) + + fun addExemptedMaliciousUrl(url: ExemptedUrl) + val exemptedMaliciousUrls: Set +} + +@ContributesBinding(AppScope::class) +class RealExemptedUrlsHolder @Inject constructor() : ExemptedUrlsHolder { + override val exemptedMaliciousUrls: Set + get() = _exemptedMaliciousUrls + private val _exemptedMaliciousUrls = mutableSetOf() + + override fun addExemptedMaliciousUrl(url: ExemptedUrl) { + _exemptedMaliciousUrls.add(url) + } } @ContributesMultibinding(AppScope::class, PrivacyConfigCallbackPlugin::class) @@ -97,13 +119,19 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( loadToMemory() } + sealed class IsMaliciousViewData { + data object Safe : IsMaliciousViewData() + data object WaitForConfirmation : IsMaliciousViewData() + data class MaliciousSite(val url: Uri, val feed: Feed, val exempted: Boolean) : IsMaliciousViewData() + } + override suspend fun shouldIntercept( request: WebResourceRequest, documentUri: Uri?, - confirmationCallback: (isMalicious: Boolean) -> Unit, - ): WebResourceResponse? { + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData { if (!isFeatureEnabled) { - return null + return IsMaliciousViewData.Safe } val url = request.url.let { if (it.fragment != null) { @@ -118,74 +146,102 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( if (processedUrls.contains(decodedUrl)) { processedUrls.remove(decodedUrl) Timber.tag("PhishingAndMalwareDetector").d("Already intercepted, skipping $decodedUrl") - return null + return IsMaliciousViewData.Safe } - if (exemptedUrlsHolder.exemptedMaliciousUrls.contains(decodedUrl)) { - Timber.tag("MaliciousSiteDetector").d("Previously exempted, skipping $decodedUrl") - return null + val exemptedUrl = exemptedUrlsHolder.exemptedMaliciousUrls.firstOrNull { it.url.toString() == decodedUrl } + + if (exemptedUrl != null) { + Timber.tag("MaliciousSiteDetector").d("Previously exempted, skipping $decodedUrl as ${exemptedUrl.feed}") + return IsMaliciousViewData.MaliciousSite(url, exemptedUrl.feed, true) } val belongsToCurrentPage = documentUri?.host == request.requestHeaders["Referer"]?.toUri()?.host if (request.isForMainFrame || (isForIframe(request) && belongsToCurrentPage)) { - if (checkMaliciousUrl(decodedUrl, confirmationCallback)) { - return WebResourceResponse(null, null, null) - } else { - processedUrls.add(decodedUrl) + when (val result = checkMaliciousUrl(decodedUrl, confirmationCallback)) { + is ConfirmedResult -> { + when (val status = result.status) { + is Malicious -> { + return IsMaliciousViewData.MaliciousSite(url, status.feed, false) + } + is Safe -> { + processedUrls.add(decodedUrl) + return IsMaliciousViewData.Safe + } + } + } + is WaitForConfirmation -> { + processedUrls.add(decodedUrl) + return IsMaliciousViewData.WaitForConfirmation + } } } - return null + return IsMaliciousViewData.Safe } override fun shouldOverrideUrlLoading( url: Uri, isForMainFrame: Boolean, - confirmationCallback: (isMalicious: Boolean) -> Unit, - ): Boolean { + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousViewData { return runBlocking { if (!isFeatureEnabled) { - return@runBlocking false + return@runBlocking IsMaliciousViewData.Safe } val decodedUrl = URLDecoder.decode(url.toString(), "UTF-8").lowercase() if (processedUrls.contains(decodedUrl)) { processedUrls.remove(decodedUrl) Timber.tag("PhishingAndMalwareDetector").d("Already intercepted, skipping $decodedUrl") - return@runBlocking false + return@runBlocking IsMaliciousViewData.Safe } - if (exemptedUrlsHolder.exemptedMaliciousUrls.contains(decodedUrl)) { + val exemptedUrl = exemptedUrlsHolder.exemptedMaliciousUrls.firstOrNull { it.url.toString() == decodedUrl } + + if (exemptedUrl != null) { Timber.tag("MaliciousSiteDetector").d("Previously exempted, skipping $decodedUrl") - return@runBlocking false + return@runBlocking IsMaliciousViewData.MaliciousSite(url, exemptedUrl.feed, true) } // iframes always go through the shouldIntercept method, so we only need to check the main frame here if (isForMainFrame) { - if (checkMaliciousUrl(decodedUrl, confirmationCallback)) { - return@runBlocking true - } else { - processedUrls.add(decodedUrl) + when (val result = checkMaliciousUrl(decodedUrl, confirmationCallback)) { + is ConfirmedResult -> { + when (val status = result.status) { + is Malicious -> { + return@runBlocking IsMaliciousViewData.MaliciousSite(url, status.feed, false) + } + is Safe -> { + processedUrls.add(decodedUrl) + return@runBlocking IsMaliciousViewData.Safe + } + } + } + is WaitForConfirmation -> { + processedUrls.add(decodedUrl) + return@runBlocking IsMaliciousViewData.WaitForConfirmation + } } } - false + IsMaliciousViewData.Safe } } private suspend fun checkMaliciousUrl( url: String, - confirmationCallback: (isMalicious: Boolean) -> Unit, - ): Boolean { + confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit, + ): IsMaliciousResult { val checkId = currentCheckId.incrementAndGet() return maliciousSiteProtection.isMalicious(url.toUri()) { // if another load has started, we should ignore the result val isMalicious = if (checkId == currentCheckId.get()) { it } else { - false + Safe } processedUrls.clear() confirmationCallback(isMalicious) - } == MALICIOUS + } } private fun isForIframe(request: WebResourceRequest) = request.requestHeaders["Sec-Fetch-Dest"] == "iframe" || @@ -197,9 +253,12 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( processedUrls.clear() } - override fun onSiteExempted(url: Uri) { + override fun onSiteExempted( + url: Uri, + feed: Feed, + ) { val convertedUrl = URLDecoder.decode(url.toString(), "UTF-8").lowercase() - exemptedUrlsHolder.exemptedMaliciousUrls.add(convertedUrl) + exemptedUrlsHolder.addExemptedMaliciousUrl(ExemptedUrl(convertedUrl.toUri(), feed)) Timber.tag("MaliciousSiteDetector").d( "Added $url to exemptedUrls, contents: ${exemptedUrlsHolder.exemptedMaliciousUrls}", ) diff --git a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt index bbd3323bfdc9..e71740ca4e10 100644 --- a/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt +++ b/app/src/main/java/com/duckduckgo/app/global/model/SiteMonitor.kt @@ -209,6 +209,8 @@ class SiteMonitor( override val realBrokenSiteContext: BrokenSiteContext = brokenSiteContext + override var maliciousSiteStatus: MaliciousSiteStatus? = null + companion object { private val specialDomainTypes = setOf( TrackerStatus.AD_ALLOWED, diff --git a/app/src/test/java/com/duckduckgo/app/browser/webview/RealMaliciousSiteBlockerWebViewIntegrationTest.kt b/app/src/test/java/com/duckduckgo/app/browser/webview/RealMaliciousSiteBlockerWebViewIntegrationTest.kt index 3b97d16eff91..be2920d1c0fb 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/webview/RealMaliciousSiteBlockerWebViewIntegrationTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/webview/RealMaliciousSiteBlockerWebViewIntegrationTest.kt @@ -2,30 +2,33 @@ package com.duckduckgo.app.browser.webview import android.webkit.WebResourceRequest import androidx.core.net.toUri -import androidx.test.core.app.ActivityScenario.launch import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.webview.ExemptedUrlsHolder.ExemptedUrl +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.MaliciousSite +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.Safe import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection -import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.MALICIOUS -import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.WAIT_FOR_CONFIRMATION +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.ConfirmedResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.WaitForConfirmation +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Malicious import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.mockito.kotlin.any +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -35,6 +38,7 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { var coroutineRule = CoroutineTestRule() private val maliciousSiteProtection: MaliciousSiteProtection = mock(MaliciousSiteProtection::class.java) + private val mockExemptedUrlsHolder = mock(ExemptedUrlsHolder::class.java) private val fakeAndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val maliciousUri = "http://malicious.com".toUri() private val exampleUri = "http://example.com".toUri() @@ -44,38 +48,39 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { dispatchers = coroutineRule.testDispatcherProvider, appCoroutineScope = coroutineRule.testScope, isMainProcess = true, - exemptedUrlsHolder = ExemptedUrlsHolder(), + exemptedUrlsHolder = mockExemptedUrlsHolder, ) @Before fun setup() { updateFeatureEnabled(true) + whenever(mockExemptedUrlsHolder.exemptedMaliciousUrls).thenReturn(emptySet()) } @Test - fun `shouldOverrideUrlLoading returns false when feature is disabled`() = runTest { + fun `shouldOverrideUrlLoading returns safe when feature is disabled`() = runTest { updateFeatureEnabled(false) val result = testee.shouldOverrideUrlLoading(exampleUri, true) {} - assertFalse(result) + assertEquals(Safe, result) } @Test - fun `shouldInterceptRequest returns null when feature is disabled`() = runTest { + fun `shouldInterceptRequest returns safe when feature is disabled`() = runTest { val request = mock(WebResourceRequest::class.java) whenever(request.url).thenReturn(exampleUri) updateFeatureEnabled(false) val result = testee.shouldIntercept(request, null) {} - assertNull(result) + assertEquals(Safe, result) } @Test - fun `shouldOverrideUrlLoading returns false when url is already processed`() = runTest { + fun `shouldOverrideUrlLoading returns safe when url is already processed`() = runTest { testee.processedUrls.add(exampleUri.toString()) val result = testee.shouldOverrideUrlLoading(exampleUri, true) {} - assertFalse(result) + assertEquals(Safe, result) } @Test @@ -83,10 +88,10 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { val request = mock(WebResourceRequest::class.java) whenever(request.url).thenReturn(maliciousUri) whenever(request.isForMainFrame).thenReturn(true) - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldIntercept(request, maliciousUri) {} - assertNotNull(result) + assertEquals(MaliciousSite(maliciousUri, MALWARE, false), result) } @Test @@ -95,56 +100,56 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { whenever(request.url).thenReturn(maliciousUri) whenever(request.isForMainFrame).thenReturn(true) whenever(request.requestHeaders).thenReturn(mapOf("Sec-Fetch-Dest" to "iframe")) - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldIntercept(request, maliciousUri) {} - assertNotNull(result) + assertEquals(MaliciousSite(maliciousUri, MALWARE, false), result) } @Test - fun `shouldInterceptRequest returns null when feature is enabled, is malicious, and is not mainframe nor iframe`() = runTest { + fun `shouldInterceptRequest returns safe when feature is enabled, is malicious, and is not mainframe nor iframe`() = runTest { val request = mock(WebResourceRequest::class.java) whenever(request.url).thenReturn(maliciousUri) whenever(request.isForMainFrame).thenReturn(false) - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldIntercept(request, maliciousUri) {} - assertNull(result) + assertEquals(Safe, result) } @Test - fun `shouldOverride returns false when feature is enabled, is malicious, and is not mainframe`() = runTest { - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + fun `shouldOverride returns safe when feature is enabled, is malicious, and is not mainframe`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldOverrideUrlLoading(maliciousUri, false) {} - assertFalse(result) + assertEquals(Safe, result) } @Test - fun `shouldOverride returns true when feature is enabled, is malicious, and is mainframe`() = runTest { - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + fun `shouldOverride returns malicious when feature is enabled, is malicious, and is mainframe`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldOverrideUrlLoading(maliciousUri, true) {} - assertTrue(result) + assertEquals(MaliciousSite(maliciousUri, MALWARE, false), result) } @Test - fun `shouldOverride returns false when feature is enabled, is malicious, and not mainframe nor iframe`() = runTest { - whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(MALICIOUS) + fun `shouldOverride returns safe when feature is enabled, is malicious, and not mainframe nor iframe`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val result = testee.shouldOverrideUrlLoading(maliciousUri, false) {} - assertFalse(result) + assertEquals(Safe, 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) + fun `shouldIntercept returns safe when feature is enabled, is malicious, and is mainframe but webView has different host`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) val request = mock(WebResourceRequest::class.java) whenever(request.url).thenReturn(maliciousUri) whenever(request.isForMainFrame).thenReturn(false) - val result = testee.shouldIntercept(request, exampleUri) {} - assertNull(result) + val result = testee.shouldIntercept(request, maliciousUri) {} + assertEquals(Safe, result) } @Test @@ -165,21 +170,21 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { val secondCallbackDeferred = CompletableDeferred() whenever(maliciousSiteProtection.isMalicious(any(), any())).thenAnswer { invocation -> - val callback = invocation.getArgument<(Boolean) -> Unit>(1) + val callback = invocation.getArgument<(Malicious) -> Unit>(1) launch { callbackChannel.receive() - callback(true) + callback(Malicious(MALWARE)) } - WAIT_FOR_CONFIRMATION + WaitForConfirmation } testee.shouldOverrideUrlLoading(maliciousUri, true) { isMalicious -> - firstCallbackDeferred.complete(isMalicious) + firstCallbackDeferred.complete(isMalicious is Malicious) } testee.shouldOverrideUrlLoading(exampleUri, true) { isMalicious -> - secondCallbackDeferred.complete(isMalicious) + secondCallbackDeferred.complete(isMalicious is Malicious) } callbackChannel.send(Unit) @@ -203,23 +208,23 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { val secondCallbackDeferred = CompletableDeferred() whenever(maliciousSiteProtection.isMalicious(any(), any())).thenAnswer { invocation -> - val callback = invocation.getArgument<(Boolean) -> Unit>(1) + val callback = invocation.getArgument<(MaliciousStatus) -> Unit>(1) launch { callbackChannel.receive() - callback(true) + callback(Malicious(MALWARE)) } - WAIT_FOR_CONFIRMATION + WaitForConfirmation } testee.shouldOverrideUrlLoading(maliciousUri, true) { isMalicious -> - firstCallbackDeferred.complete(isMalicious) + firstCallbackDeferred.complete(isMalicious is Malicious) } callbackChannel.send(Unit) testee.shouldOverrideUrlLoading(exampleUri, true) { isMalicious -> - secondCallbackDeferred.complete(isMalicious) + secondCallbackDeferred.complete(isMalicious is Malicious) } callbackChannel.send(Unit) @@ -231,6 +236,37 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { assertEquals(true, secondCallbackResult) } + @Test + fun `onSiteExempted adds url to exemptedUrlsHolder`() = runTest { + val url = "http://example.com".toUri() + val feed = MALWARE + + testee.onSiteExempted(url, feed) + + verify(mockExemptedUrlsHolder).addExemptedMaliciousUrl(ExemptedUrl(url, feed)) + } + + @Test + fun `shouldIntercept returns malicious with exempted when feature is enabled and site is exempted`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) + val request = mock(WebResourceRequest::class.java) + whenever(request.url).thenReturn(maliciousUri) + whenever(request.isForMainFrame).thenReturn(true) + whenever(mockExemptedUrlsHolder.exemptedMaliciousUrls).thenReturn(setOf(ExemptedUrl(maliciousUri, MALWARE))) + + val result = testee.shouldIntercept(request, maliciousUri) {} + assertEquals(MaliciousSite(maliciousUri, MALWARE, true), result) + } + + @Test + fun `shouldOverride returns malicious with exempted when feature is enabled and site is exempted`() = runTest { + whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE))) + whenever(mockExemptedUrlsHolder.exemptedMaliciousUrls).thenReturn(setOf(ExemptedUrl(maliciousUri, MALWARE))) + + val result = testee.shouldOverrideUrlLoading(maliciousUri, true) {} + assertEquals(MaliciousSite(maliciousUri, MALWARE, true), result) + } + private fun updateFeatureEnabled(enabled: Boolean) { fakeAndroidBrowserConfigFeature.enableMaliciousSiteProtection().setRawStoredState(State(enabled)) testee.onPrivacyConfigDownloaded() diff --git a/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt b/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt index 4cb91005bba2..408238c35fa6 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt @@ -79,6 +79,12 @@ interface Site { var nextUrl: String val realBrokenSiteContext: BrokenSiteContext + + var maliciousSiteStatus: MaliciousSiteStatus? +} + +enum class MaliciousSiteStatus { + PHISHING, MALWARE } fun Site.orderedTrackerBlockedEntities(): List = trackingEvents diff --git a/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt b/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt index 7f5ad066d72d..89fecad171f9 100644 --- a/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt +++ b/malicious-site-protection/malicious-site-protection-api/src/main/kotlin/com/duckduckgo/malicioussiteprotection/api/MaliciousSiteProtection.kt @@ -20,11 +20,20 @@ import android.net.Uri interface MaliciousSiteProtection { - suspend fun isMalicious(url: Uri, confirmationCallback: (isMalicious: Boolean) -> Unit): IsMaliciousResult + suspend fun isMalicious(url: Uri, confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit): IsMaliciousResult - enum class IsMaliciousResult { - MALICIOUS, - SAFE, - WAIT_FOR_CONFIRMATION, + sealed class MaliciousStatus { + data class Malicious(val feed: Feed) : MaliciousStatus() + data object Safe : MaliciousStatus() + } + + enum class Feed { + PHISHING, + MALWARE, + } + + sealed class IsMaliciousResult { + data class ConfirmedResult(val status: MaliciousStatus) : IsMaliciousResult() + data object WaitForConfirmation : IsMaliciousResult() } } diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/MaliciousSiteRepository.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/MaliciousSiteRepository.kt index d6682d3ba872..494e9cbf8164 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/MaliciousSiteRepository.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/MaliciousSiteRepository.kt @@ -18,16 +18,17 @@ package com.duckduckgo.malicioussiteprotection.impl.data import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING import com.duckduckgo.malicioussiteprotection.impl.data.db.MaliciousSiteDao import com.duckduckgo.malicioussiteprotection.impl.data.db.RevisionEntity import com.duckduckgo.malicioussiteprotection.impl.data.network.FilterResponse import com.duckduckgo.malicioussiteprotection.impl.data.network.FilterSetResponse import com.duckduckgo.malicioussiteprotection.impl.data.network.HashPrefixResponse import com.duckduckgo.malicioussiteprotection.impl.data.network.MaliciousSiteService -import com.duckduckgo.malicioussiteprotection.impl.models.Feed -import com.duckduckgo.malicioussiteprotection.impl.models.Feed.MALWARE -import com.duckduckgo.malicioussiteprotection.impl.models.Feed.PHISHING import com.duckduckgo.malicioussiteprotection.impl.models.Filter +import com.duckduckgo.malicioussiteprotection.impl.models.FilterSet import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision.MalwareFilterSetWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision.PhishingFilterSetWithRevision @@ -45,7 +46,7 @@ import kotlinx.coroutines.withContext interface MaliciousSiteRepository { suspend fun containsHashPrefix(hashPrefix: String): Boolean - suspend fun getFilters(hash: String): List? + suspend fun getFilters(hash: String): List? suspend fun matches(hashPrefix: String): List suspend fun loadFilters(): Result suspend fun loadHashPrefixes(): Result @@ -63,18 +64,32 @@ class RealMaliciousSiteRepository @Inject constructor( return maliciousSiteDao.getHashPrefix(hashPrefix) != null } - override suspend fun getFilters(hash: String): List? { - return maliciousSiteDao.getFilter(hash)?.let { - it.map { - Filter(it.hash, it.regex) - } + override suspend fun getFilters(hash: String): List? { + return maliciousSiteDao.getFilter(hash)?.groupBy { it.type }?.map { (type, filters) -> + FilterSet( + filters = filters.map { Filter(it.hash, it.regex) }, + feed = when (type) { + PHISHING.name -> PHISHING + MALWARE.name -> MALWARE + else -> throw IllegalArgumentException("Unknown feed $type") + }, + ) } } override suspend fun matches(hashPrefix: String): List { return try { - maliciousSiteService.getMatches(hashPrefix).matches.map { - Match(it.hostname, it.url, it.regex, it.hash) + maliciousSiteService.getMatches(hashPrefix).matches.mapNotNull { + val feed = when (it.feed.uppercase()) { + PHISHING.name -> PHISHING + MALWARE.name -> MALWARE + else -> null + } + if (feed != null) { + Match(it.hostname, it.url, it.regex, it.hash, feed) + } else { + null + } } } catch (e: Exception) { listOf() diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDao.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDao.kt index 7c0dfa2bf7de..4e37e2e42e11 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDao.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDao.kt @@ -21,7 +21,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.duckduckgo.malicioussiteprotection.impl.models.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.HashPrefixesWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.Type diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/network/MaliciousSiteService.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/network/MaliciousSiteService.kt index b2c8c32d8a20..dfc5bd068149 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/network/MaliciousSiteService.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/network/MaliciousSiteService.kt @@ -19,6 +19,7 @@ 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.squareup.moshi.Json import retrofit2.http.GET import retrofit2.http.Query @@ -84,6 +85,8 @@ data class MatchResponse( val url: String, val regex: String, val hash: String, + @field:Json(name = "category") + val feed: String, ) data class RevisionResponse( diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtection.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtection.kt index b45c9519d527..b7c65dcb2929 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtection.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtection.kt @@ -21,7 +21,12 @@ 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.Feed import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.ConfirmedResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Malicious +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Safe import com.duckduckgo.malicioussiteprotection.impl.MaliciousSiteProtectionRCFeature import com.duckduckgo.malicioussiteprotection.impl.data.MaliciousSiteRepository import com.squareup.anvil.annotations.ContributesBinding @@ -41,42 +46,49 @@ class RealMaliciousSiteProtection @Inject constructor( private val maliciousSiteProtectionRCFeature: MaliciousSiteProtectionRCFeature, ) : MaliciousSiteProtection { - override suspend fun isMalicious(url: Uri, confirmationCallback: (isMalicious: Boolean) -> Unit): IsMaliciousResult { - Timber.tag("MaliciousSiteProtection").d("isMalicious $url") + private val timber = Timber.tag("MaliciousSiteProtection") + + override suspend fun isMalicious( + url: Uri, + confirmationCallback: (confirmedResult: MaliciousStatus) -> Unit, + ): IsMaliciousResult { + timber.d("isMalicious $url") if (!maliciousSiteProtectionRCFeature.isFeatureEnabled()) { - Timber.d("\uD83D\uDFE2 Cris: should not block (feature disabled) $url") - return IsMaliciousResult.SAFE + timber.d("should not block (feature disabled) $url") + return ConfirmedResult(Safe) } - val hostname = url.host ?: return IsMaliciousResult.SAFE + val hostname = url.host ?: return ConfirmedResult(Safe) val hash = messageDigest .digest(hostname.toByteArray(Charsets.UTF_8)) .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 + timber.d("should not block (no hash) $hashPrefix, $url") + return ConfirmedResult(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 - } + maliciousSiteRepository.getFilters(hash)?.forEach { filterSet -> + filterSet.filters.firstOrNull { + Pattern.compile(it.regex).matcher(url.toString()).find() + }?.let { + timber.d("should block $url") + return ConfirmedResult(Malicious(filterSet.feed)) } } appCoroutineScope.launch(dispatchers.io()) { try { - val matches = matches(hashPrefix, url, hostname, hash) - confirmationCallback(matches) + val result = matches(hashPrefix, url, hostname, hash)?.let { feed: Feed -> + Malicious(feed) + } ?: Safe + confirmationCallback(result) } catch (e: Exception) { - Timber.e(e, "\uD83D\uDD34 Cris: shouldBlock $url") - confirmationCallback(false) + timber.e(e, "shouldBlock $url") + confirmationCallback(Safe) } } - return IsMaliciousResult.WAIT_FOR_CONFIRMATION + return IsMaliciousResult.WaitForConfirmation } private suspend fun matches( @@ -84,14 +96,12 @@ class RealMaliciousSiteProtection @Inject constructor( url: Uri, hostname: String, hash: String, - ): Boolean { + ): Feed? { val matches = maliciousSiteRepository.matches(hashPrefix.substring(0, 4)) - return matches.any { match -> + return matches.firstOrNull { 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") - } + }?.feed } } diff --git a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/models/MaliciousSiteProtectionDataModels.kt b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/models/MaliciousSiteProtectionDataModels.kt index 55543ef51687..df41b9755117 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/models/MaliciousSiteProtectionDataModels.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/models/MaliciousSiteProtectionDataModels.kt @@ -16,14 +16,16 @@ package com.duckduckgo.malicioussiteprotection.impl.models -import com.duckduckgo.malicioussiteprotection.impl.models.Feed.MALWARE -import com.duckduckgo.malicioussiteprotection.impl.models.Feed.PHISHING +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING data class Match( val hostname: String, val url: String, val regex: String, val hash: String, + val feed: Feed, ) data class Filter( @@ -31,6 +33,11 @@ data class Filter( val regex: String, ) +class FilterSet( + val filters: List, + val feed: Feed, +) + sealed class FilterSetWithRevision( open val insert: Set, open val delete: Set, @@ -77,11 +84,6 @@ sealed class HashPrefixesWithRevision( ) : HashPrefixesWithRevision(insert, delete, revision, replace, MALWARE) } -enum class Feed { - PHISHING, - MALWARE, -} - enum class Type { HASH_PREFIXES, FILTER_SET, diff --git a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/RealMaliciousSiteRepositoryTest.kt b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/RealMaliciousSiteRepositoryTest.kt index 68c12669e3d8..f5befe477ed3 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/RealMaliciousSiteRepositoryTest.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/RealMaliciousSiteRepositoryTest.kt @@ -1,5 +1,6 @@ package com.duckduckgo.malicioussiteprotection.impl.data +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING import com.duckduckgo.malicioussiteprotection.impl.data.db.FilterEntity import com.duckduckgo.malicioussiteprotection.impl.data.db.HashPrefixEntity import com.duckduckgo.malicioussiteprotection.impl.data.db.MaliciousSiteDao @@ -10,8 +11,8 @@ import com.duckduckgo.malicioussiteprotection.impl.data.network.MaliciousSiteSer import com.duckduckgo.malicioussiteprotection.impl.data.network.MatchResponse import com.duckduckgo.malicioussiteprotection.impl.data.network.MatchesResponse import com.duckduckgo.malicioussiteprotection.impl.data.network.RevisionResponse -import com.duckduckgo.malicioussiteprotection.impl.models.Feed.PHISHING import com.duckduckgo.malicioussiteprotection.impl.models.Filter +import com.duckduckgo.malicioussiteprotection.impl.models.FilterSet import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision.PhishingFilterSetWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.HashPrefixesWithRevision.PhishingHashPrefixesWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.Match @@ -109,24 +110,30 @@ class RealMaliciousSiteRepositoryTest { @Test fun getFilters_returnsFiltersWhenHashExists() = runTest { val hash = "testHash" - val filters = listOf(FilterEntity(hash, "regex", Type.FILTER_SET.name)) + val filters = listOf(FilterEntity(hash, "regex", PHISHING.name)) whenever(maliciousSiteDao.getFilter(hash)).thenReturn(filters) val result = repository.getFilters(hash) + val expected = FilterSet(filters.map { Filter(it.hash, it.regex) }, PHISHING) - assertEquals(filters.map { Filter(it.hash, it.regex) }, result) + assertTrue(result?.all { it.feed == expected.feed }!!) + assertEquals(result.firstOrNull()?.filters, expected.filters) } @Test fun matches_returnsMatchesWhenHashPrefixExists() = runTest { val hashPrefix = "testPrefix" - val matchesResponse = MatchesResponse(listOf(MatchResponse("hostname", "url", "regex", "hash"))) + val matchesResponse = MatchesResponse( + listOf( + MatchResponse("hostname", "url", "regex", "hash", PHISHING.name), + ), + ) whenever(maliciousSiteService.getMatches(hashPrefix)).thenReturn(matchesResponse) val result = repository.matches(hashPrefix) - assertEquals(matchesResponse.matches.map { Match(it.hostname, it.url, it.regex, it.hash) }, result) + assertEquals(matchesResponse.matches.map { Match(it.hostname, it.url, it.regex, it.hash, PHISHING) }, result) } } diff --git a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDaoTest.kt b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDaoTest.kt index cb16995890ca..3e7eea76f942 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDaoTest.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/db/MaliciousSiteDaoTest.kt @@ -19,7 +19,7 @@ package com.duckduckgo.malicioussiteprotection.impl.data.db import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.malicioussiteprotection.impl.models.Feed +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.malicioussiteprotection.impl.models.Filter import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision.PhishingFilterSetWithRevision import com.duckduckgo.malicioussiteprotection.impl.models.HashPrefixesWithRevision.PhishingHashPrefixesWithRevision diff --git a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtectionTest.kt b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtectionTest.kt index bfb205ce03ba..673efcb728b0 100644 --- a/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtectionTest.kt +++ b/malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/domain/RealMaliciousSiteProtectionTest.kt @@ -20,9 +20,14 @@ import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMaliciousResult.ConfirmedResult +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Malicious +import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.MaliciousStatus.Safe import com.duckduckgo.malicioussiteprotection.impl.MaliciousSiteProtectionRCFeature import com.duckduckgo.malicioussiteprotection.impl.data.MaliciousSiteRepository import com.duckduckgo.malicioussiteprotection.impl.models.Filter +import com.duckduckgo.malicioussiteprotection.impl.models.FilterSet import com.duckduckgo.malicioussiteprotection.impl.models.Match import java.security.MessageDigest import kotlinx.coroutines.test.runTest @@ -69,7 +74,7 @@ class RealMaliciousSiteProtectionTest { val result = realMaliciousSiteProtection.isMalicious(url) {} - assertEquals(MaliciousSiteProtection.IsMaliciousResult.SAFE, result) + assertEquals(ConfirmedResult(Safe), result) } @Test @@ -81,11 +86,18 @@ class RealMaliciousSiteProtectionTest { val filter = Filter(hash, ".*malicious.*") whenever(maliciousSiteRepository.containsHashPrefix(hashPrefix)).thenReturn(true) - whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(filter)) + whenever(maliciousSiteRepository.getFilters(hash)).thenReturn( + listOf( + FilterSet( + listOf(filter), + PHISHING, + ), + ), + ) val result = realMaliciousSiteProtection.isMalicious(url) {} - assertEquals(MaliciousSiteProtection.IsMaliciousResult.MALICIOUS, result) + assertEquals(ConfirmedResult(Malicious(PHISHING)), result) } @Test @@ -97,12 +109,12 @@ class RealMaliciousSiteProtectionTest { val filter = Filter(hash, ".*malicious.*") whenever(maliciousSiteRepository.containsHashPrefix(hashPrefix)).thenReturn(true) - whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(filter)) + whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(FilterSet(listOf(filter), PHISHING))) whenever(mockMaliciousSiteProtectionRCFeature.isFeatureEnabled()).thenReturn(false) val result = realMaliciousSiteProtection.isMalicious(url) {} - assertEquals(MaliciousSiteProtection.IsMaliciousResult.SAFE, result) + assertEquals(ConfirmedResult(Safe), result) } @Test @@ -114,11 +126,11 @@ class RealMaliciousSiteProtectionTest { val filter = Filter(hash, ".*unsafe.*") whenever(maliciousSiteRepository.containsHashPrefix(hashPrefix)).thenReturn(true) - whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(filter)) + whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(FilterSet(listOf(filter), PHISHING))) val result = realMaliciousSiteProtection.isMalicious(url) {} - assertEquals(MaliciousSiteProtection.IsMaliciousResult.WAIT_FOR_CONFIRMATION, result) + assertEquals(MaliciousSiteProtection.IsMaliciousResult.WaitForConfirmation, result) } @Test @@ -131,9 +143,9 @@ class RealMaliciousSiteProtectionTest { var onSiteBlockedAsyncCalled = false whenever(maliciousSiteRepository.containsHashPrefix(hashPrefix)).thenReturn(true) - whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(filter)) + whenever(maliciousSiteRepository.getFilters(hash)).thenReturn(listOf(FilterSet(listOf(filter), PHISHING))) whenever(maliciousSiteRepository.matches(hashPrefix.substring(0, 4))) - .thenReturn(listOf(Match(hostname, url.toString(), ".*malicious.*", hash))) + .thenReturn(listOf(Match(hostname, url.toString(), ".*malicious.*", hash, PHISHING))) realMaliciousSiteProtection.isMalicious(url) { onSiteBlockedAsyncCalled = true