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 1ebcd0075aa5..c7cecc1d5b52 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -2309,6 +2309,12 @@ class BrowserTabViewModelTest { verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED) } + @Test + fun whenDosAttackDetectedThenErrorIsShown() { + testee.dosAttackDetected() + assertCommandIssued() + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } 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 e0c82fd29b65..f319cbf8cd26 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -52,6 +52,7 @@ class BrowserWebViewClientTest { private val loginDetector: DOMLoginDetector = mock() private val offlinePixelCountDataStore: OfflinePixelCountDataStore = mock() private val uncaughtExceptionRepository: UncaughtExceptionRepository = mock() + private val dosDetector: DosDetector = DosDetector() @UiThreadTest @Before @@ -64,7 +65,8 @@ class BrowserWebViewClientTest { offlinePixelCountDataStore, uncaughtExceptionRepository, cookieManager, - loginDetector + loginDetector, + dosDetector ) testee.webViewClientListener = listener } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/DosDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/DosDetectorTest.kt new file mode 100644 index 000000000000..5042570c5d09 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/DosDetectorTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2020 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 + +import android.net.Uri +import com.duckduckgo.app.browser.DosDetector.Companion.MAX_REQUESTS_COUNT +import com.duckduckgo.app.browser.DosDetector.Companion.DOS_TIME_WINDOW_MS +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DosDetectorTest { + + val testee: DosDetector = DosDetector() + + @Test + fun whenLessThanMaxRequestsCountCallsWithSameUrlThenReturnFalse() { + for (i in 0 until MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + } + + @Test + fun whenMoreThanMaxRequestsCountCallsWithSameUrlThenLastCallReturnsTrue() { + for (i in 0..MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + assertTrue(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + + @Test + fun whenMoreThanMaxRequestsCountCallsWithSameUrlAndDelayGreaterThanLimitThenReturnFalse() { + runBlocking { + for (i in 0..MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + delay((DOS_TIME_WINDOW_MS + 100).toLong()) + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + } + + @Test + fun whenMoreThanMaxRequestsCountCallsWithSameUrlAndDelayGreaterThanLimitThenCountIsResetSoNextAndSubsequentRequestsReturnFalse() { + runBlocking { + for (i in 0..MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + delay((DOS_TIME_WINDOW_MS + 100).toLong()) + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + } + + @Test + fun whenMultipleRequestsFromDifferentUrlsThenReturnFalse() { + for (i in 0 until MAX_REQUESTS_COUNT * 2) { + if (i % 2 == 0) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } else { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example2.com"))) + } + } + } + + @Test + fun whenMaxRequestsReceivedConsecutivelyFromDifferentUrlsThenReturnFalse() { + for (i in 0 until MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com"))) + } + for (i in 0 until MAX_REQUESTS_COUNT) { + assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example2.com"))) + } + } +} 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 628203add179..45e76aee4cac 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -595,6 +595,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi private fun showErrorSnackbar(command: Command.ShowErrorWithAction) { // Snackbar is global and it should appear only the foreground fragment if (!errorSnackbar.view.isAttachedToWindow && isVisible) { + errorSnackbar.setText(command.textResId) errorSnackbar.setAction(R.string.crashedWebViewErrorAction) { command.action() }.show() } } 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 da3c571a25f4..c31cd861a3c4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -243,7 +243,7 @@ class BrowserTabViewModel( object ShowWebContent : Command() class RefreshUserAgent(val host: String?, val isDesktop: Boolean) : Command() - class ShowErrorWithAction(val action: () -> Unit) : Command() + class ShowErrorWithAction(val textResId: Int, val action: () -> Unit) : Command() sealed class DaxCommand : Command() { object FinishTrackerAnimation : DaxCommand() class HideDaxDialog(val cta: Cta) : DaxCommand() @@ -807,6 +807,11 @@ class BrowserTabViewModel( } } + override fun dosAttackDetected() { + invalidateBrowsingActions() + showErrorWithAction(R.string.dosErrorMessage) + } + override fun titleReceived(newTitle: String) { site?.title = newTitle onSiteChanged() @@ -1385,8 +1390,8 @@ class BrowserTabViewModel( ) } - private fun showErrorWithAction() { - command.value = ShowErrorWithAction { this.onUserSubmittedQuery(url.orEmpty()) } + private fun showErrorWithAction(errorMessage: Int = R.string.crashedWebViewErrorMessage) { + command.value = ShowErrorWithAction(errorMessage) { this.onUserSubmittedQuery(url.orEmpty()) } } private fun recoverTabWithQuery(query: String) { 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 bcc61f97f5a0..058645fc458f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -41,7 +41,8 @@ class BrowserWebViewClient( private val offlinePixelCountDataStore: OfflinePixelCountDataStore, private val uncaughtExceptionRepository: UncaughtExceptionRepository, private val cookieManager: CookieManager, - private val loginDetector: DOMLoginDetector + private val loginDetector: DOMLoginDetector, + private val dosDetector: DosDetector ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -52,7 +53,7 @@ class BrowserWebViewClient( */ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url - return shouldOverride(view, url) + return shouldOverride(view, url, request.isForMainFrame) } /** @@ -61,15 +62,21 @@ class BrowserWebViewClient( @Suppress("OverridingDeprecatedMember") override fun shouldOverrideUrlLoading(view: WebView, urlString: String): Boolean { val url = Uri.parse(urlString) - return shouldOverride(view, url) + return shouldOverride(view, url, true) } /** * API-agnostic implementation of deciding whether to override url or not */ - private fun shouldOverride(webView: WebView, url: Uri): Boolean { + private fun shouldOverride(webView: WebView, url: Uri, isForMainFrame: Boolean): Boolean { + + Timber.v("shouldOverride $url") try { - Timber.v("shouldOverride $url") + if (isForMainFrame && dosDetector.isUrlGeneratingDos(url)) { + webView.loadUrl("about:blank") + webViewClientListener?.dosAttackDetected() + return false + } return when (val urlType = specialUrlDetector.determineType(url)) { is SpecialUrlDetector.UrlType.Email -> { diff --git a/app/src/main/java/com/duckduckgo/app/browser/DosDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/DosDetector.kt new file mode 100644 index 000000000000..87e6686c5b1f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/DosDetector.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020 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 + +import android.net.Uri +import javax.inject.Inject + +class DosDetector @Inject constructor() { + + var lastUrl: Uri? = null + var lastUrlLoadTime: Long? = null + var dosCount = 0 + + fun isUrlGeneratingDos(url: Uri?): Boolean { + + val currentUrlLoadTime = System.currentTimeMillis() + + if (url != lastUrl) { + reset(url, currentUrlLoadTime) + return false + } + + if (!withinDosTimeWindow(currentUrlLoadTime)) { + reset(url, currentUrlLoadTime) + return false + } + + dosCount++ + lastUrlLoadTime = currentUrlLoadTime + return dosCount > MAX_REQUESTS_COUNT + } + + private fun reset(url: Uri?, currentLoadTime: Long) { + dosCount = 0 + lastUrl = url + lastUrlLoadTime = currentLoadTime + } + + private fun withinDosTimeWindow(currentLoadTime: Long): Boolean { + val previousLoadTime = lastUrlLoadTime ?: return false + return (currentLoadTime - previousLoadTime) < DOS_TIME_WINDOW_MS + } + + companion object { + const val DOS_TIME_WINDOW_MS = 1_000 + const val MAX_REQUESTS_COUNT = 20 + } +} 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 dba8ae8ea816..419579390a62 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -51,4 +51,5 @@ interface WebViewClientListener { fun surrogateDetected(surrogate: SurrogateResponse) fun loginDetected() + fun dosAttackDetected() } diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index bbac06264c81..97eb0134c4da 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -87,7 +87,8 @@ class BrowserModule { offlinePixelCountDataStore: OfflinePixelCountDataStore, uncaughtExceptionRepository: UncaughtExceptionRepository, cookieManager: CookieManager, - loginDetector: DOMLoginDetector + loginDetector: DOMLoginDetector, + dosDetector: DosDetector ): BrowserWebViewClient { return BrowserWebViewClient( requestRewriter, @@ -96,7 +97,8 @@ class BrowserModule { offlinePixelCountDataStore, uncaughtExceptionRepository, cookieManager, - loginDetector + loginDetector, + dosDetector ) } diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 253bb78486f7..c985fa252e92 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -51,4 +51,7 @@ Success! %s has been added to your home screen. Checking your feed in DuckDuckGo is a great alternative to using the Facebook app!<br/><br/>But if the Facebook app is on your phone, it can make requests for data even when you\'re not using it.<br/><br/>Prevent this by deleting it now! + + Connection aborted. Website could be harmful to your device. +