Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2309,6 +2309,12 @@ class BrowserTabViewModelTest {
verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED)
}

@Test
fun whenDosAttackDetectedThenErrorIsShown() {
testee.dosAttackDetected()
assertCommandIssued<Command.ShowErrorWithAction>()
}

private inline fun <reified T : Command> assertCommandIssued(instanceAssertions: T.() -> Unit = {}) {
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
val issuedCommand = commandCaptor.allValues.find { it is T }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,7 +65,8 @@ class BrowserWebViewClientTest {
offlinePixelCountDataStore,
uncaughtExceptionRepository,
cookieManager,
loginDetector
loginDetector,
dosDetector
)
testee.webViewClientListener = listener
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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")))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -807,6 +807,11 @@ class BrowserTabViewModel(
}
}

override fun dosAttackDetected() {
invalidateBrowsingActions()
showErrorWithAction(R.string.dosErrorMessage)
}

override fun titleReceived(newTitle: String) {
site?.title = newTitle
onSiteChanged()
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

/**
Expand All @@ -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 -> {
Expand Down
62 changes: 62 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/DosDetector.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ interface WebViewClientListener {
fun surrogateDetected(surrogate: SurrogateResponse)

fun loginDetected()
fun dosAttackDetected()
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ class BrowserModule {
offlinePixelCountDataStore: OfflinePixelCountDataStore,
uncaughtExceptionRepository: UncaughtExceptionRepository,
cookieManager: CookieManager,
loginDetector: DOMLoginDetector
loginDetector: DOMLoginDetector,
dosDetector: DosDetector
): BrowserWebViewClient {
return BrowserWebViewClient(
requestRewriter,
Expand All @@ -96,7 +97,8 @@ class BrowserModule {
offlinePixelCountDataStore,
uncaughtExceptionRepository,
cookieManager,
loginDetector
loginDetector,
dosDetector
)
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/string-untranslated.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@
<string name="useOurAppShortcutAddedText">Success! %s has been added to your home screen.</string>
<string name="useOurAppDeletionDialogText">Checking your feed in DuckDuckGo is a great alternative to using the Facebook app!&lt;br/&gt;&lt;br/&gt;But if the Facebook app is on your phone, it can make requests for data even when you\'re not using it.&lt;br/&gt;&lt;br/&gt;Prevent this by deleting it now!</string>

<!-- Dos Attack error-->
<string name="dosErrorMessage">Connection aborted. Website could be harmful to your device.</string>

</resources>