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 089b66fa3cef..a39af86db59e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -671,6 +671,7 @@ class BrowserTabViewModelTest { toggleReports = mockToggleReports, brokenSitePrompt = mockBrokenSitePrompt, tabStatsBucketing = mockTabStatsBucketing, + maliciousSiteBlockerWebViewIntegration = mock(), ) testee.loadData("abc", null, false, false) @@ -5088,16 +5089,16 @@ class BrowserTabViewModelTest { } @Test - fun whenRecoveringFromSSLWarningPageAndBrowserShouldShowThenViewStatesUpdated() { - testee.recoverFromSSLWarningPage(true) + fun whenRecoveringFromWarningPageAndBrowserShouldShowThenViewStatesUpdated() { + testee.recoverFromWarningPage(true) assertEquals(NONE, browserViewState().sslError) assertEquals(true, browserViewState().browserShowing) } @Test - fun whenRecoveringFromSSLWarningPageAndBrowserShouldNotShowThenViewStatesUpdated() = runTest { - testee.recoverFromSSLWarningPage(false) + fun whenRecoveringFromWarningPageAndBrowserShouldNotShowThenViewStatesUpdated() = runTest { + testee.recoverFromWarningPage(false) assertEquals(NONE, browserViewState().sslError) assertEquals(false, browserViewState().browserShowing) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 837c568b5d71..ceae6fbcddd0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -43,6 +43,7 @@ import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBinding import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.shortcut.ShortcutBuilder +import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams import com.duckduckgo.app.feedback.ui.common.FeedbackActivity @@ -128,6 +129,9 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var appBuildConfig: AppBuildConfig + @Inject + lateinit var maliciousSiteBlockerWebViewIntegration: RealMaliciousSiteBlockerWebViewIntegration + private val lastActiveTabs = TabList() private var currentTab: BrowserTabFragment? = null 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 294b13f71dd3..23f6ede6ac05 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -108,6 +108,8 @@ import com.duckduckgo.app.browser.applinks.AppLinksSnackBarConfigurator import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter import com.duckduckgo.app.browser.autocomplete.SuggestionItemDecoration import com.duckduckgo.app.browser.commands.Command +import com.duckduckgo.app.browser.commands.Command.OpenBrokenSiteLearnMore +import com.duckduckgo.app.browser.commands.Command.ReportBrokenSiteError import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager @@ -223,6 +225,7 @@ import com.duckduckgo.autofill.api.emailprotection.EmailInjector import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.RELOAD_THREE_TIMES_WITHIN_20_SECONDS +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.store.BrowserAppTheme import com.duckduckgo.common.ui.view.DaxDialog @@ -579,6 +582,9 @@ class BrowserTabFragment : private val errorView get() = binding.includeErrorView + private val maliciousWarningView + get() = binding.maliciousSiteWarningLayout + private val sslErrorView get() = binding.sslErrorWarningLayout @@ -1301,6 +1307,7 @@ class BrowserTabFragment : webView?.hide() errorView.errorLayout.gone() sslErrorView.gone() + maliciousWarningView.gone() } private fun showBrowser() { @@ -1312,6 +1319,7 @@ class BrowserTabFragment : webView?.onResume() errorView.errorLayout.gone() sslErrorView.gone() + maliciousWarningView.gone() omnibar.setViewMode(Omnibar.ViewMode.Browser(viewModel.url)) } @@ -1323,6 +1331,7 @@ class BrowserTabFragment : newBrowserTab.newTabLayout.gone() newBrowserTab.newTabContainerLayout.gone() sslErrorView.gone() + maliciousWarningView.gone() omnibar.setViewMode(Omnibar.ViewMode.Error) webView?.onPause() webView?.hide() @@ -1335,6 +1344,71 @@ class BrowserTabFragment : errorView.errorLayout.show() } + private fun showMaliciousWarning(url: Uri) { + webViewContainer.gone() + newBrowserTab.newTabLayout.gone() + newBrowserTab.newTabContainerLayout.gone() + sslErrorView.gone() + errorView.errorLayout.gone() + binding.browserLayout.gone() + omnibar.setViewMode(ViewMode.MaliciousSiteWarning) + webView?.onPause() + webView?.hide() + webView?.stopLoading() + maliciousWarningView.bind { action -> + viewModel.onMaliciousSiteUserAction(action, url) + } + maliciousWarningView.show() + binding.focusDummy.requestFocus() + } + + private fun hideMaliciousWarning() { + val navList = webView?.safeCopyBackForwardList() + val currentIndex = navList?.currentIndex ?: 0 + + if (currentIndex >= 0) { + Timber.d("MaliciousSite: hiding warning page and triggering a reload of the previous") + viewModel.recoverFromWarningPage(true) + refresh() + } else { + Timber.d("MaliciousSite: no previous page to load, showing home") + viewModel.recoverFromWarningPage(false) + renderer.showNewTab() + maliciousWarningView.gone() + } + } + + private fun onEscapeMaliciousSite() { + maliciousWarningView.gone() + viewModel.openNewTab() + viewModel.closeCurrentTab() + } + + private fun onBypassMaliciousWarning(url: Uri) { + showBrowser() + webView?.loadUrl(url.toString()) + } + + private fun openBrokenSiteLearnMore(url: String) { + globalActivityStarter.start( + this.requireContext(), + WebViewActivityWithParams( + url = url, + getString(R.string.maliciousSiteLearnMoreTitle), + ), + ) + } + + private fun openBrokenSiteReportError(url: String) { + globalActivityStarter.start( + this.requireContext(), + WebViewActivityWithParams( + url = url, + getString(R.string.maliciousSiteReportErrorTitle), + ), + ) + } + private fun showSSLWarning( handler: SslErrorHandler, errorResponse: SslErrorResponse, @@ -1347,6 +1421,7 @@ class BrowserTabFragment : omnibar.setViewMode(Omnibar.ViewMode.SSLWarning) errorView.errorLayout.gone() binding.browserLayout.gone() + maliciousWarningView.gone() sslErrorView.bind(handler, errorResponse) { action -> viewModel.onSSLCertificateWarningAction(action, errorResponse.url) } @@ -1359,11 +1434,11 @@ class BrowserTabFragment : if (currentIndex >= 0) { Timber.d("SSLError: hiding warning page and triggering a reload of the previous") - viewModel.recoverFromSSLWarningPage(true) + viewModel.recoverFromWarningPage(true) refresh() } else { Timber.d("SSLError: no previous page to load, showing home") - viewModel.recoverFromSSLWarningPage(false) + viewModel.recoverFromWarningPage(false) } } @@ -1677,6 +1752,12 @@ class BrowserTabFragment : ) is Command.WebViewError -> showError(it.errorType, it.url) + is Command.ShowWarningMaliciousSite -> showMaliciousWarning(it.url) + is Command.HideWarningMaliciousSite -> hideMaliciousWarning() + is Command.EscapeMaliciousSite -> onEscapeMaliciousSite() + is Command.BypassMaliciousSiteWarning -> onBypassMaliciousWarning(it.url) + is OpenBrokenSiteLearnMore -> openBrokenSiteLearnMore(it.url) + is ReportBrokenSiteError -> openBrokenSiteReportError(it.url) is Command.SendResponseToJs -> contentScopeScripts.onResponse(it.data) is Command.SendResponseToDuckPlayer -> duckPlayerScripts.onResponse(it.data) is Command.WebShareRequest -> webShareRequest.launch(it.data) @@ -3940,7 +4021,7 @@ class BrowserTabFragment : viewModel.onCtaShown() } - private fun showNewTab() { + fun showNewTab() { newTabPageProvider.provideNewTabPageVersion().onEach { newTabPage -> if (newBrowserTab.newTabContainerLayout.childCount == 0) { newBrowserTab.newTabContainerLayout.addView( 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 abb6273a2765..c9a32d1d1c92 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -82,6 +82,7 @@ import com.duckduckgo.app.browser.commands.Command.AskToDisableLoginDetection import com.duckduckgo.app.browser.commands.Command.AskToFireproofWebsite import com.duckduckgo.app.browser.commands.Command.AutocompleteItemRemoved import com.duckduckgo.app.browser.commands.Command.BrokenSiteFeedback +import com.duckduckgo.app.browser.commands.Command.BypassMaliciousSiteWarning import com.duckduckgo.app.browser.commands.Command.CancelIncomingAutofillRequest import com.duckduckgo.app.browser.commands.Command.ChildTabClosed import com.duckduckgo.app.browser.commands.Command.ConvertBlobToDataUri @@ -95,6 +96,7 @@ import com.duckduckgo.app.browser.commands.Command.DismissFindInPage import com.duckduckgo.app.browser.commands.Command.DownloadImage import com.duckduckgo.app.browser.commands.Command.EditWithSelectedQuery import com.duckduckgo.app.browser.commands.Command.EmailSignEvent +import com.duckduckgo.app.browser.commands.Command.EscapeMaliciousSite import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink import com.duckduckgo.app.browser.commands.Command.FindInPageCommand import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage @@ -103,6 +105,7 @@ import com.duckduckgo.app.browser.commands.Command.HideBrokenSitePromptCta import com.duckduckgo.app.browser.commands.Command.HideKeyboard import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog import com.duckduckgo.app.browser.commands.Command.HideSSLError +import com.duckduckgo.app.browser.commands.Command.HideWarningMaliciousSite import com.duckduckgo.app.browser.commands.Command.HideWebContent import com.duckduckgo.app.browser.commands.Command.InjectEmailAddress import com.duckduckgo.app.browser.commands.Command.LaunchAddWidget @@ -113,6 +116,7 @@ import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro import com.duckduckgo.app.browser.commands.Command.LaunchTabSwitcher import com.duckduckgo.app.browser.commands.Command.LoadExtractedUrl import com.duckduckgo.app.browser.commands.Command.OpenAppLink +import com.duckduckgo.app.browser.commands.Command.OpenBrokenSiteLearnMore import com.duckduckgo.app.browser.commands.Command.OpenInNewBackgroundTab import com.duckduckgo.app.browser.commands.Command.OpenInNewTab import com.duckduckgo.app.browser.commands.Command.OpenMessageInNewTab @@ -120,6 +124,7 @@ import com.duckduckgo.app.browser.commands.Command.PrintLink import com.duckduckgo.app.browser.commands.Command.RefreshAndShowPrivacyProtectionDisabledConfirmation import com.duckduckgo.app.browser.commands.Command.RefreshAndShowPrivacyProtectionEnabledConfirmation import com.duckduckgo.app.browser.commands.Command.RefreshUserAgent +import com.duckduckgo.app.browser.commands.Command.ReportBrokenSiteError import com.duckduckgo.app.browser.commands.Command.RequestFileDownload import com.duckduckgo.app.browser.commands.Command.RequiresAuthentication import com.duckduckgo.app.browser.commands.Command.ResetHistory @@ -152,6 +157,7 @@ import com.duckduckgo.app.browser.commands.Command.ShowSitePermissionsDialog import com.duckduckgo.app.browser.commands.Command.ShowSoundRecorder import com.duckduckgo.app.browser.commands.Command.ShowUserCredentialSavedOrUpdatedConfirmation import com.duckduckgo.app.browser.commands.Command.ShowVideoCamera +import com.duckduckgo.app.browser.commands.Command.ShowWarningMaliciousSite import com.duckduckgo.app.browser.commands.Command.ShowWebContent import com.duckduckgo.app.browser.commands.Command.ShowWebPageTitle import com.duckduckgo.app.browser.commands.Command.ToggleReportFeedback @@ -202,6 +208,12 @@ import com.duckduckgo.app.browser.viewstate.LoadingViewState import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.viewstate.PrivacyShieldViewState import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState +import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout +import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Action.LearnMore +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 @@ -374,6 +386,9 @@ import org.json.JSONArray import org.json.JSONObject import timber.log.Timber +private const val MALICIOUS_SITE_LEARN_MORE_URL = "https://duckduckgo.com/duckduckgo-help-pages/privacy/phishing-and-malware-protection/" +private const val MALICIOUS_SITE_REPORT_ERROR_URL = "https://duckduckgo.com/malicious-site-protection/report-error?url=" + @ContributesViewModel(FragmentScope::class) class BrowserTabViewModel @Inject constructor( private val statisticsUpdater: StatisticsUpdater, @@ -441,6 +456,7 @@ class BrowserTabViewModel @Inject constructor( private val toggleReports: ToggleReports, private val brokenSitePrompt: BrokenSitePrompt, private val tabStatsBucketing: TabStatsBucketing, + private val maliciousSiteBlockerWebViewIntegration: MaliciousSiteBlockerWebViewIntegration, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -1188,6 +1204,11 @@ class BrowserTabViewModel @Inject constructor( } } + fun openNewTab() { + command.value = GenerateWebViewPreviewImage + command.value = LaunchNewTab + } + fun closeAndReturnToSourceIfBlankTab() { if (url == null) { closeAndSelectSourceTab() @@ -1283,6 +1304,11 @@ class BrowserTabViewModel @Inject constructor( return true } + if (currentBrowserViewState().maliciousSiteDetected) { + command.postValue(HideWarningMaliciousSite) + return true + } + if (!currentBrowserViewState().browserShowing) { return false } @@ -1840,6 +1866,28 @@ class BrowserTabViewModel @Inject constructor( site?.consentCosmeticHide = isCosmetic } + fun onMaliciousSiteUserAction( + action: MaliciousSiteBlockedWarningLayout.Action, + url: Uri, + ) { + when (action) { + LeaveSite -> { + command.postValue(EscapeMaliciousSite) + } + + VisitSite -> { + command.postValue(BypassMaliciousSiteWarning(url)) + 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")) + } + } + private fun onSiteChanged() { httpsUpgraded = false site?.isDesktopMode = currentBrowserViewState().isDesktopBrowsingMode @@ -3036,6 +3084,9 @@ class BrowserTabViewModel @Inject constructor( if (currentBrowserViewState().sslError != NONE) { browserViewState.value = currentBrowserViewState().copy(browserShowing = true, sslError = NONE) } + if (currentBrowserViewState().maliciousSiteDetected) { + browserViewState.value = currentBrowserViewState().copy(browserShowing = true, maliciousSiteDetected = false) + } } fun onWebViewRefreshed() { @@ -3111,6 +3162,19 @@ class BrowserTabViewModel @Inject constructor( command.postValue(WebViewError(errorType, url)) } + override fun onReceivedMaliciousSiteWarning(url: Uri) { + // 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)) + } + override fun recordErrorCode( error: String, url: String, @@ -3401,9 +3465,9 @@ class BrowserTabViewModel @Inject constructor( } } - fun recoverFromSSLWarningPage(showBrowser: Boolean) { + fun recoverFromWarningPage(showBrowser: Boolean) { if (showBrowser) { - browserViewState.value = currentBrowserViewState().copy(browserShowing = true, sslError = NONE) + browserViewState.value = currentBrowserViewState().copy(browserShowing = true, sslError = NONE, maliciousSiteDetected = false) } else { omnibarViewState.value = currentOmnibarViewState().copy( omnibarText = "", @@ -3415,6 +3479,7 @@ class BrowserTabViewModel @Inject constructor( showPrivacyShield = HighlightableButton.Visible(enabled = false), browserShowing = showBrowser, sslError = NONE, + maliciousSiteDetected = false, ) } } @@ -3700,6 +3765,10 @@ class BrowserTabViewModel @Inject constructor( command.value = SetOnboardingDialogBackground(getBackgroundResource(lightModeEnabled)) } + fun addExemptedMaliciousUrlToMemory(url: Uri) { + maliciousSiteBlockerWebViewIntegration.onSiteExempted(url) + } + private fun getBackgroundResource(lightModeEnabled: Boolean): Int { return when { lightModeEnabled && highlightsOnboardingExperimentManager.isHighlightsEnabled() -> 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 5bc6cbae99cf..a3d83afcd712 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -161,7 +161,7 @@ class BrowserWebViewClient @Inject constructor( try { Timber.v("shouldOverride webViewUrl: ${webView.url} URL: $url") webViewClientListener?.onShouldOverride() - if (requestInterceptor.shouldOverrideUrlLoading(webView, url, isForMainFrame)) { + if (requestInterceptor.shouldOverrideUrlLoading(webViewClientListener, url, isForMainFrame)) { return true } 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 f8fb1313a391..b9d5eeb258fe 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -95,6 +95,7 @@ interface WebViewClientListener { fun linkOpenedInNewTab(): Boolean fun isActiveTab(): Boolean fun onReceivedError(errorType: WebViewErrorResponse, url: String) + fun onReceivedMaliciousSiteWarning(url: Uri) 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 8c70907848d4..3025281f83d5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -39,7 +39,6 @@ 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 @@ -63,7 +62,7 @@ interface RequestInterceptor { @WorkerThread fun shouldOverrideUrlLoading( - webView: WebView, + webViewClientListener: WebViewClientListener?, url: Uri, isForMainFrame: Boolean, ): Boolean @@ -109,10 +108,10 @@ class WebViewRequestInterceptor( maliciousSiteBlockerWebViewIntegration.shouldIntercept(request, documentUri) { isMalicious -> if (isMalicious) { - handleSiteBlocked(webView) + handleSiteBlocked(webViewClientListener, url) } }?.let { - handleSiteBlocked(webView) + handleSiteBlocked(webViewClientListener, url) return it } @@ -181,24 +180,24 @@ class WebViewRequestInterceptor( return getWebResourceResponse(request, documentUrl, null) } - override fun shouldOverrideUrlLoading(webView: WebView, url: Uri, isForMainFrame: Boolean): Boolean { + override fun shouldOverrideUrlLoading(webViewClientListener: WebViewClientListener?, url: Uri, isForMainFrame: Boolean): Boolean { if (maliciousSiteBlockerWebViewIntegration.shouldOverrideUrlLoading( url, isForMainFrame, ) { isMalicious -> if (isMalicious) { - handleSiteBlocked(webView) + handleSiteBlocked(webViewClientListener, url) } } ) { - handleSiteBlocked(webView) + handleSiteBlocked(webViewClientListener, url) return true } return false } - private fun handleSiteBlocked(webView: WebView) { - Snackbar.make(webView, "Site blocked", Snackbar.LENGTH_SHORT).show() + private fun handleSiteBlocked(webViewClientListener: WebViewClientListener?, url: Uri?) { + url?.let { webViewClientListener?.onReceivedMaliciousSiteWarning(it) } } 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 911f4d3e2b92..3e8843f75298 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 @@ -217,6 +217,21 @@ sealed class Command { val url: String, ) : Command() + data class ShowWarningMaliciousSite( + val url: Uri, + ) : Command() + + data object HideWarningMaliciousSite : Command() + + data object EscapeMaliciousSite : Command() + + data class BypassMaliciousSiteWarning( + val url: Uri, + ) : Command() + + data class OpenBrokenSiteLearnMore(val url: String) : Command() + data class ReportBrokenSiteError(val url: String) : Command() + // TODO (cbarreiro) Rename to SendResponseToCSS data class SendResponseToJs(val data: JsCallbackData) : Command() data class SendResponseToDuckPlayer(val data: JsCallbackData) : Command() diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt index 2bca37f11515..4c99745891e4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt @@ -31,6 +31,7 @@ import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.IncludeFindInPageBinding import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.CustomTab import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.Error +import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.MaliciousSiteWarning import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.NewTab import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.SSLWarning import com.duckduckgo.app.browser.omnibar.OmnibarLayout.Decoration @@ -126,6 +127,7 @@ class Omnibar( sealed class ViewMode { data object Error : ViewMode() data object SSLWarning : ViewMode() + data object MaliciousSiteWarning : ViewMode() data object NewTab : ViewMode() data class Browser(val url: String?) : ViewMode() data class CustomTab( @@ -198,6 +200,10 @@ class Omnibar( newOmnibar.decorate(Mode(viewMode)) } + MaliciousSiteWarning -> { + newOmnibar.decorate(Mode(viewMode)) + } + else -> { newOmnibar.decorate(Mode(viewMode)) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt index 364bd611eea8..20064dbe1188 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt @@ -26,6 +26,7 @@ import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.Browser import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.CustomTab import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.Error +import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.MaliciousSiteWarning import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.NewTab import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.SSLWarning import com.duckduckgo.app.browser.omnibar.OmnibarLayout.Decoration.LaunchTrackersAnimation @@ -231,9 +232,8 @@ class OmnibarLayoutViewModel @Inject constructor( url: String, ): LeadingIconState { return when (_viewState.value.viewMode) { - Error -> GLOBE + Error, SSLWarning, MaliciousSiteWarning -> GLOBE NewTab -> SEARCH - SSLWarning -> GLOBE else -> { if (hasFocus) { SEARCH @@ -299,9 +299,8 @@ class OmnibarLayoutViewModel @Inject constructor( LeadingIconState.SEARCH } else { when (viewMode) { - Error -> GLOBE + Error, SSLWarning, MaliciousSiteWarning -> GLOBE NewTab -> SEARCH - SSLWarning -> GLOBE else -> SEARCH } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt index 94645833574b..42ef4515230b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt @@ -53,6 +53,7 @@ data class BrowserViewState( val showAutofill: Boolean = false, val browserError: WebViewErrorResponse = WebViewErrorResponse.OMITTED, val sslError: SSLErrorType = SSLErrorType.NONE, + val maliciousSiteDetected: Boolean = false, val privacyProtectionsPopupViewState: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone, val showDuckChatOption: Boolean = false, ) 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 new file mode 100644 index 000000000000..1d5377fa53a0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockedWarningLayout.kt @@ -0,0 +1,129 @@ +/* + * 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.app.browser.webview + +import android.content.Context +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.core.text.HtmlCompat +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.databinding.ViewMaliciousSiteBlockedWarningBinding +import com.duckduckgo.app.browser.webview.MaliciousSiteBlockedWarningLayout.Action.LearnMore +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.common.ui.view.gone +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 + +class MaliciousSiteBlockedWarningLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + sealed class Action { + data object VisitSite : Action() + data object LeaveSite : Action() + data object LearnMore : Action() + data object ReportError : Action() + } + + private val binding: ViewMaliciousSiteBlockedWarningBinding by viewBinding() + + fun bind( + actionHandler: (Action) -> Unit, + ) { + resetViewState() + + formatCopy(actionHandler) + setListeners(actionHandler) + } + + private fun resetViewState() { + with(binding) { + advancedCTA.show() + advancedGroup.gone() + } + } + + private fun formatCopy( + actionHandler: (Action) -> Unit, + ) { + with(binding) { + errorHeadline.setSpannable(R.string.maliciousSiteMalwareHeadline) { actionHandler(LearnMore) } + expandedHeadline.setSpannable(R.string.maliciousSiteExpandedHeadline) { actionHandler(ReportError) } + expandedCTA.text = HtmlCompat.fromHtml(context.getString(R.string.maliciousSiteExpandedCTA), HtmlCompat.FROM_HTML_MODE_LEGACY) + } + } + + private fun DaxTextView.setSpannable( + @StringRes errorResource: Int, + actionHandler: () -> Unit, + ) { + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + actionHandler() + } + } + val htmlContent = context.getString(errorResource).html(context) + val spannableString = SpannableStringBuilder(htmlContent) + val urlSpans = htmlContent.getSpans(0, htmlContent.length, URLSpan::class.java) + urlSpans?.forEach { + spannableString.apply { + setSpan( + clickableSpan, + spannableString.getSpanStart(it), + spannableString.getSpanEnd(it), + spannableString.getSpanFlags(it), + ) + removeSpan(it) + trim() + } + } + text = spannableString + movementMethod = LinkMovementMethod.getInstance() + } + + private fun setListeners( + actionHandler: (Action) -> Unit, + ) { + with(binding) { + leaveSiteCTA.setOnClickListener { + actionHandler(LeaveSite) + } + advancedCTA.setOnClickListener { + advancedCTA.gone() + advancedGroup.show() + maliciousSiteLayout.post { + maliciousSiteLayout.fullScroll(View.FOCUS_DOWN) + } + } + expandedCTA.setOnClickListener { + actionHandler(VisitSite) + } + } + } +} 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 0eb3a918df5e..1401c4b6fb71 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 @@ -31,6 +31,7 @@ import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.IsMali 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 @@ -54,6 +55,13 @@ interface MaliciousSiteBlockerWebViewIntegration { ): Boolean fun onPageLoadStarted() + + fun onSiteExempted(url: Uri) +} + +@SingleInstanceIn(AppScope::class) +class ExemptedUrlsHolder @Inject constructor() { + val exemptedMaliciousUrls = mutableSetOf() } @ContributesMultibinding(AppScope::class, PrivacyConfigCallbackPlugin::class) @@ -63,11 +71,13 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, private val dispatchers: DispatcherProvider, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val exemptedUrlsHolder: ExemptedUrlsHolder, @IsMainProcess private val isMainProcess: Boolean, ) : MaliciousSiteBlockerWebViewIntegration, PrivacyConfigCallbackPlugin { @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val processedUrls = mutableListOf() + private var isFeatureEnabled = false private var currentCheckId = AtomicInteger(0) @@ -111,6 +121,11 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( return null } + if (exemptedUrlsHolder.exemptedMaliciousUrls.contains(decodedUrl)) { + Timber.tag("MaliciousSiteDetector").d("Previously exempted, skipping $decodedUrl") + return null + } + val belongsToCurrentPage = documentUri?.host == request.requestHeaders["Referer"]?.toUri()?.host if (request.isForMainFrame || (isForIframe(request) && belongsToCurrentPage)) { if (checkMaliciousUrl(decodedUrl, confirmationCallback)) { @@ -139,6 +154,11 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( return@runBlocking false } + if (exemptedUrlsHolder.exemptedMaliciousUrls.contains(decodedUrl)) { + Timber.tag("MaliciousSiteDetector").d("Previously exempted, skipping $decodedUrl") + return@runBlocking false + } + // iframes always go through the shouldIntercept method, so we only need to check the main frame here if (isForMainFrame) { if (checkMaliciousUrl(decodedUrl, confirmationCallback)) { @@ -176,4 +196,12 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor( override fun onPageLoadStarted() { processedUrls.clear() } + + override fun onSiteExempted(url: Uri) { + val convertedUrl = URLDecoder.decode(url.toString(), "UTF-8").lowercase() + exemptedUrlsHolder.exemptedMaliciousUrls.add(convertedUrl) + Timber.tag("MaliciousSiteDetector").d( + "Added $url to exemptedUrls, contents: ${exemptedUrlsHolder.exemptedMaliciousUrls}", + ) + } } diff --git a/app/src/main/res/drawable/malware_site_128.xml b/app/src/main/res/drawable/malware_site_128.xml new file mode 100644 index 000000000000..cb82a5e50e53 --- /dev/null +++ b/app/src/main/res/drawable/malware_site_128.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index 26d95c96a4d3..7a21732d3f39 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -66,6 +66,12 @@ android:layout_height="match_parent" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index e637e74a412e..66435293769b 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -34,6 +34,17 @@ A server with the specified hostname could not be found. %1$s which could put your confidential information at risk.]]> + + Warning: This site may be a security risk + Learn more]]> + Learn more]]> + Leave This Site + Advanced + report an error. You can still visit the website at your own risk.]]> + Accept Risk and Visit Site]]> + Report a site incorrectly flagged as malicious + Learn more + What site are you signing in to? (required) Site name (required) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a45ab654da28..821672dcb4a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -695,7 +695,7 @@ Leave This Site Advanced DuckDuckGo warns you when a website has an invalid certificate. - Accept Risk and Visit Site]]> + Accept Risk and Visit Site]]> The security certificate for %1$s is expired. It’s possible that the website is misconfigured, that an attacker has compromised your connection, or that your system clock is incorrect. The security certificate for %1$s does not match *.%2$s. It’s possible that the website is misconfigured or that an attacker has compromised your connection, or that your system clock is incorrect. The security certificate for %1$s is not trusted by your device\'s operating system. It’s possible that the website is misconfigured or that an attacker has compromised your connection, or that your system clock is incorrect. diff --git a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt index 906e4e78e1d8..b862dff97c38 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt @@ -294,6 +294,17 @@ class OmnibarLayoutViewModelTest { } } + @Test + fun whenViewModeChangedToMaliciousSiteWarningThenViewStateCorrect() = runTest { + testee.onViewModeChanged(ViewMode.MaliciousSiteWarning) + + testee.viewState.test { + val viewState = awaitItem() + assertTrue(viewState.leadingIconState == LeadingIconState.GLOBE) + assertTrue(viewState.scrollingEnabled) + } + } + @Test fun whenViewModeChangedToNewTabThenViewStateCorrect() = runTest { testee.onViewModeChanged(ViewMode.NewTab) @@ -338,6 +349,17 @@ class OmnibarLayoutViewModelTest { } } + @Test + fun whenViewModeChangedToMaliciousSiteWarningAndFocusThenViewStateCorrect() = runTest { + testee.onOmnibarFocusChanged(true, RANDOM_URL) + testee.onViewModeChanged(ViewMode.MaliciousSiteWarning) + + testee.viewState.test { + val viewState = expectMostRecentItem() + assertTrue(viewState.leadingIconState == LeadingIconState.SEARCH) + } + } + @Test fun whenViewModeChangedToNewTabAndFocusThenViewStateCorrect() = runTest { testee.onOmnibarFocusChanged(true, RANDOM_URL) 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 8341067ed880..3b97d16eff91 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 @@ -44,6 +44,7 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest { dispatchers = coroutineRule.testDispatcherProvider, appCoroutineScope = coroutineRule.testScope, isMainProcess = true, + exemptedUrlsHolder = ExemptedUrlsHolder(), ) @Before 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 f73d7215aaf1..b45c9519d527 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 @@ -68,7 +68,13 @@ class RealMaliciousSiteProtection @Inject constructor( } } appCoroutineScope.launch(dispatchers.io()) { - confirmationCallback(matches(hashPrefix, url, hostname, hash)) + try { + val matches = matches(hashPrefix, url, hostname, hash) + confirmationCallback(matches) + } catch (e: Exception) { + Timber.e(e, "\uD83D\uDD34 Cris: shouldBlock $url") + confirmationCallback(false) + } } return IsMaliciousResult.WAIT_FOR_CONFIRMATION }