Skip to content

Commit b973426

Browse files
Feature/shared/dos (#904)
1 parent 64485a5 commit b973426

File tree

10 files changed

+190
-11
lines changed

10 files changed

+190
-11
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,6 +2309,12 @@ class BrowserTabViewModelTest {
23092309
verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED)
23102310
}
23112311

2312+
@Test
2313+
fun whenDosAttackDetectedThenErrorIsShown() {
2314+
testee.dosAttackDetected()
2315+
assertCommandIssued<Command.ShowErrorWithAction>()
2316+
}
2317+
23122318
private inline fun <reified T : Command> assertCommandIssued(instanceAssertions: T.() -> Unit = {}) {
23132319
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
23142320
val issuedCommand = commandCaptor.allValues.find { it is T }

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class BrowserWebViewClientTest {
5252
private val loginDetector: DOMLoginDetector = mock()
5353
private val offlinePixelCountDataStore: OfflinePixelCountDataStore = mock()
5454
private val uncaughtExceptionRepository: UncaughtExceptionRepository = mock()
55+
private val dosDetector: DosDetector = DosDetector()
5556

5657
@UiThreadTest
5758
@Before
@@ -64,7 +65,8 @@ class BrowserWebViewClientTest {
6465
offlinePixelCountDataStore,
6566
uncaughtExceptionRepository,
6667
cookieManager,
67-
loginDetector
68+
loginDetector,
69+
dosDetector
6870
)
6971
testee.webViewClientListener = listener
7072
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright (c) 2020 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.browser
18+
19+
import android.net.Uri
20+
import com.duckduckgo.app.browser.DosDetector.Companion.MAX_REQUESTS_COUNT
21+
import com.duckduckgo.app.browser.DosDetector.Companion.DOS_TIME_WINDOW_MS
22+
import kotlinx.coroutines.delay
23+
import kotlinx.coroutines.runBlocking
24+
import org.junit.Assert.assertFalse
25+
import org.junit.Assert.assertTrue
26+
import org.junit.Test
27+
28+
class DosDetectorTest {
29+
30+
val testee: DosDetector = DosDetector()
31+
32+
@Test
33+
fun whenLessThanMaxRequestsCountCallsWithSameUrlThenReturnFalse() {
34+
for (i in 0 until MAX_REQUESTS_COUNT) {
35+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
36+
}
37+
}
38+
39+
@Test
40+
fun whenMoreThanMaxRequestsCountCallsWithSameUrlThenLastCallReturnsTrue() {
41+
for (i in 0..MAX_REQUESTS_COUNT) {
42+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
43+
}
44+
assertTrue(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
45+
}
46+
47+
@Test
48+
fun whenMoreThanMaxRequestsCountCallsWithSameUrlAndDelayGreaterThanLimitThenReturnFalse() {
49+
runBlocking {
50+
for (i in 0..MAX_REQUESTS_COUNT) {
51+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
52+
}
53+
delay((DOS_TIME_WINDOW_MS + 100).toLong())
54+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
55+
}
56+
}
57+
58+
@Test
59+
fun whenMoreThanMaxRequestsCountCallsWithSameUrlAndDelayGreaterThanLimitThenCountIsResetSoNextAndSubsequentRequestsReturnFalse() {
60+
runBlocking {
61+
for (i in 0..MAX_REQUESTS_COUNT) {
62+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
63+
}
64+
delay((DOS_TIME_WINDOW_MS + 100).toLong())
65+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
66+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
67+
}
68+
}
69+
70+
@Test
71+
fun whenMultipleRequestsFromDifferentUrlsThenReturnFalse() {
72+
for (i in 0 until MAX_REQUESTS_COUNT * 2) {
73+
if (i % 2 == 0) {
74+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
75+
} else {
76+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example2.com")))
77+
}
78+
}
79+
}
80+
81+
@Test
82+
fun whenMaxRequestsReceivedConsecutivelyFromDifferentUrlsThenReturnFalse() {
83+
for (i in 0 until MAX_REQUESTS_COUNT) {
84+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example.com")))
85+
}
86+
for (i in 0 until MAX_REQUESTS_COUNT) {
87+
assertFalse(testee.isUrlGeneratingDos(Uri.parse("http://example2.com")))
88+
}
89+
}
90+
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
595595
private fun showErrorSnackbar(command: Command.ShowErrorWithAction) {
596596
// Snackbar is global and it should appear only the foreground fragment
597597
if (!errorSnackbar.view.isAttachedToWindow && isVisible) {
598+
errorSnackbar.setText(command.textResId)
598599
errorSnackbar.setAction(R.string.crashedWebViewErrorAction) { command.action() }.show()
599600
}
600601
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ class BrowserTabViewModel(
243243
object ShowWebContent : Command()
244244
class RefreshUserAgent(val host: String?, val isDesktop: Boolean) : Command()
245245

246-
class ShowErrorWithAction(val action: () -> Unit) : Command()
246+
class ShowErrorWithAction(val textResId: Int, val action: () -> Unit) : Command()
247247
sealed class DaxCommand : Command() {
248248
object FinishTrackerAnimation : DaxCommand()
249249
class HideDaxDialog(val cta: Cta) : DaxCommand()
@@ -807,6 +807,11 @@ class BrowserTabViewModel(
807807
}
808808
}
809809

810+
override fun dosAttackDetected() {
811+
invalidateBrowsingActions()
812+
showErrorWithAction(R.string.dosErrorMessage)
813+
}
814+
810815
override fun titleReceived(newTitle: String) {
811816
site?.title = newTitle
812817
onSiteChanged()
@@ -1385,8 +1390,8 @@ class BrowserTabViewModel(
13851390
)
13861391
}
13871392

1388-
private fun showErrorWithAction() {
1389-
command.value = ShowErrorWithAction { this.onUserSubmittedQuery(url.orEmpty()) }
1393+
private fun showErrorWithAction(errorMessage: Int = R.string.crashedWebViewErrorMessage) {
1394+
command.value = ShowErrorWithAction(errorMessage) { this.onUserSubmittedQuery(url.orEmpty()) }
13901395
}
13911396

13921397
private fun recoverTabWithQuery(query: String) {

app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ class BrowserWebViewClient(
4141
private val offlinePixelCountDataStore: OfflinePixelCountDataStore,
4242
private val uncaughtExceptionRepository: UncaughtExceptionRepository,
4343
private val cookieManager: CookieManager,
44-
private val loginDetector: DOMLoginDetector
44+
private val loginDetector: DOMLoginDetector,
45+
private val dosDetector: DosDetector
4546
) : WebViewClient() {
4647

4748
var webViewClientListener: WebViewClientListener? = null
@@ -52,7 +53,7 @@ class BrowserWebViewClient(
5253
*/
5354
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
5455
val url = request.url
55-
return shouldOverride(view, url)
56+
return shouldOverride(view, url, request.isForMainFrame)
5657
}
5758

5859
/**
@@ -61,15 +62,21 @@ class BrowserWebViewClient(
6162
@Suppress("OverridingDeprecatedMember")
6263
override fun shouldOverrideUrlLoading(view: WebView, urlString: String): Boolean {
6364
val url = Uri.parse(urlString)
64-
return shouldOverride(view, url)
65+
return shouldOverride(view, url, true)
6566
}
6667

6768
/**
6869
* API-agnostic implementation of deciding whether to override url or not
6970
*/
70-
private fun shouldOverride(webView: WebView, url: Uri): Boolean {
71+
private fun shouldOverride(webView: WebView, url: Uri, isForMainFrame: Boolean): Boolean {
72+
73+
Timber.v("shouldOverride $url")
7174
try {
72-
Timber.v("shouldOverride $url")
75+
if (isForMainFrame && dosDetector.isUrlGeneratingDos(url)) {
76+
webView.loadUrl("about:blank")
77+
webViewClientListener?.dosAttackDetected()
78+
return false
79+
}
7380

7481
return when (val urlType = specialUrlDetector.determineType(url)) {
7582
is SpecialUrlDetector.UrlType.Email -> {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright (c) 2020 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.browser
18+
19+
import android.net.Uri
20+
import javax.inject.Inject
21+
22+
class DosDetector @Inject constructor() {
23+
24+
var lastUrl: Uri? = null
25+
var lastUrlLoadTime: Long? = null
26+
var dosCount = 0
27+
28+
fun isUrlGeneratingDos(url: Uri?): Boolean {
29+
30+
val currentUrlLoadTime = System.currentTimeMillis()
31+
32+
if (url != lastUrl) {
33+
reset(url, currentUrlLoadTime)
34+
return false
35+
}
36+
37+
if (!withinDosTimeWindow(currentUrlLoadTime)) {
38+
reset(url, currentUrlLoadTime)
39+
return false
40+
}
41+
42+
dosCount++
43+
lastUrlLoadTime = currentUrlLoadTime
44+
return dosCount > MAX_REQUESTS_COUNT
45+
}
46+
47+
private fun reset(url: Uri?, currentLoadTime: Long) {
48+
dosCount = 0
49+
lastUrl = url
50+
lastUrlLoadTime = currentLoadTime
51+
}
52+
53+
private fun withinDosTimeWindow(currentLoadTime: Long): Boolean {
54+
val previousLoadTime = lastUrlLoadTime ?: return false
55+
return (currentLoadTime - previousLoadTime) < DOS_TIME_WINDOW_MS
56+
}
57+
58+
companion object {
59+
const val DOS_TIME_WINDOW_MS = 1_000
60+
const val MAX_REQUESTS_COUNT = 20
61+
}
62+
}

app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ interface WebViewClientListener {
5151
fun surrogateDetected(surrogate: SurrogateResponse)
5252

5353
fun loginDetected()
54+
fun dosAttackDetected()
5455
}

app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ class BrowserModule {
8787
offlinePixelCountDataStore: OfflinePixelCountDataStore,
8888
uncaughtExceptionRepository: UncaughtExceptionRepository,
8989
cookieManager: CookieManager,
90-
loginDetector: DOMLoginDetector
90+
loginDetector: DOMLoginDetector,
91+
dosDetector: DosDetector
9192
): BrowserWebViewClient {
9293
return BrowserWebViewClient(
9394
requestRewriter,
@@ -96,7 +97,8 @@ class BrowserModule {
9697
offlinePixelCountDataStore,
9798
uncaughtExceptionRepository,
9899
cookieManager,
99-
loginDetector
100+
loginDetector,
101+
dosDetector
100102
)
101103
}
102104

app/src/main/res/values/string-untranslated.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,7 @@
5151
<string name="useOurAppShortcutAddedText">Success! %s has been added to your home screen.</string>
5252
<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>
5353

54+
<!-- Dos Attack error-->
55+
<string name="dosErrorMessage">Connection aborted. Website could be harmful to your device.</string>
56+
5457
</resources>

0 commit comments

Comments
 (0)