Skip to content

Commit 3cff846

Browse files
committed
Add algorithm pixels
1 parent 9ecbb7a commit 3cff846

File tree

7 files changed

+97
-15
lines changed

7 files changed

+97
-15
lines changed

app/src/main/java/com/duckduckgo/app/browser/webview/MaliciousSiteBlockerWebViewIntegration.kt

+21-2
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import com.duckduckgo.app.browser.webview.ExemptedUrlsHolder.ExemptedUrl
2424
import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData
2525
import com.duckduckgo.app.di.AppCoroutineScope
2626
import com.duckduckgo.app.di.IsMainProcess
27+
import com.duckduckgo.app.pixels.AppPixelName
2728
import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
2829
import com.duckduckgo.app.settings.db.SettingsDataStore
30+
import com.duckduckgo.app.statistics.pixels.Pixel
2931
import com.duckduckgo.common.utils.DispatcherProvider
3032
import com.duckduckgo.di.scopes.AppScope
3133
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection
@@ -97,6 +99,7 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor(
9799
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
98100
private val exemptedUrlsHolder: ExemptedUrlsHolder,
99101
@IsMainProcess private val isMainProcess: Boolean,
102+
private val pixel: Pixel,
100103
) : MaliciousSiteBlockerWebViewIntegration, PrivacyConfigCallbackPlugin {
101104

102105
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@@ -161,19 +164,31 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor(
161164
}
162165

163166
val belongsToCurrentPage = documentUri?.host == request.requestHeaders["Referer"]?.toUri()?.host
164-
if (request.isForMainFrame || (isForIframe(request) && belongsToCurrentPage)) {
165-
when (val result = checkMaliciousUrl(decodedUrl, confirmationCallback)) {
167+
val isForIframe = isForIframe(request) && belongsToCurrentPage
168+
if (request.isForMainFrame || isForIframe) {
169+
val result = checkMaliciousUrl(decodedUrl) {
170+
if (isForIframe && it is Malicious) {
171+
firePixelForMaliciousIframe(it.feed)
172+
}
173+
confirmationCallback(it)
174+
}
175+
when (result) {
166176
is ConfirmedResult -> {
167177
when (val status = result.status) {
168178
is Malicious -> {
179+
if (isForIframe) {
180+
firePixelForMaliciousIframe(status.feed)
181+
}
169182
return IsMaliciousViewData.MaliciousSite(url, status.feed, false)
170183
}
184+
171185
is Safe -> {
172186
processedUrls.add(decodedUrl)
173187
return IsMaliciousViewData.Safe
174188
}
175189
}
176190
}
191+
177192
is WaitForConfirmation -> {
178193
processedUrls.add(decodedUrl)
179194
return IsMaliciousViewData.WaitForConfirmation
@@ -231,6 +246,10 @@ class RealMaliciousSiteBlockerWebViewIntegration @Inject constructor(
231246
}
232247
}
233248

249+
private fun firePixelForMaliciousIframe(feed: Feed) {
250+
pixel.fire(AppPixelName.MALICIOUS_SITE_DETECTED_IN_IFRAME, mapOf("category" to feed.name.lowercase()))
251+
}
252+
234253
private suspend fun checkMaliciousUrl(
235254
url: String,
236255
confirmationCallback: (maliciousStatus: MaliciousStatus) -> Unit,

app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt

+2
Original file line numberDiff line numberDiff line change
@@ -397,4 +397,6 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName {
397397
SET_AS_DEFAULT_PROMPT_CLICK("m_set-as-default_prompt_click"),
398398
SET_AS_DEFAULT_PROMPT_DISMISSED("m_set-as-default_prompt_dismissed"),
399399
SET_AS_DEFAULT_IN_MENU_CLICK("m_set-as-default_in-menu_click"),
400+
401+
MALICIOUS_SITE_DETECTED_IN_IFRAME("m_malicious-site-protection_iframe-loaded"),
400402
}

app/src/test/java/com/duckduckgo/app/browser/webview/RealMaliciousSiteBlockerWebViewIntegrationTest.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
66
import com.duckduckgo.app.browser.webview.ExemptedUrlsHolder.ExemptedUrl
77
import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.MaliciousSite
88
import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration.IsMaliciousViewData.Safe
9+
import com.duckduckgo.app.pixels.AppPixelName
910
import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
1011
import com.duckduckgo.app.settings.db.SettingsDataStore
12+
import com.duckduckgo.app.statistics.pixels.Pixel
1113
import com.duckduckgo.common.test.CoroutineTestRule
1214
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
1315
import com.duckduckgo.feature.toggles.api.Toggle.State
@@ -41,6 +43,7 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest {
4143
private val maliciousSiteProtection: MaliciousSiteProtection = mock(MaliciousSiteProtection::class.java)
4244
private val mockSettingsDataStore: SettingsDataStore = mock(SettingsDataStore::class.java)
4345
private val mockExemptedUrlsHolder = mock(ExemptedUrlsHolder::class.java)
46+
private val mockPixel: Pixel = mock()
4447
private val fakeAndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java)
4548
private val maliciousUri = "http://malicious.com".toUri()
4649
private val exampleUri = "http://example.com".toUri()
@@ -52,6 +55,7 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest {
5255
appCoroutineScope = coroutineRule.testScope,
5356
isMainProcess = true,
5457
exemptedUrlsHolder = mockExemptedUrlsHolder,
58+
pixel = mockPixel,
5559
)
5660

5761
@Before
@@ -120,11 +124,12 @@ class RealMaliciousSiteBlockerWebViewIntegrationTest {
120124
fun `shouldInterceptRequest returns result when feature is enabled, setting is enabled, is malicious, and is iframe`() = runTest {
121125
val request = mock(WebResourceRequest::class.java)
122126
whenever(request.url).thenReturn(maliciousUri)
123-
whenever(request.isForMainFrame).thenReturn(true)
124-
whenever(request.requestHeaders).thenReturn(mapOf("Sec-Fetch-Dest" to "iframe"))
127+
whenever(request.isForMainFrame).thenReturn(false)
128+
whenever(request.requestHeaders).thenReturn(mapOf("Sec-Fetch-Dest" to "iframe", "Referer" to maliciousUri.toString()))
125129
whenever(maliciousSiteProtection.isMalicious(any(), any())).thenReturn(ConfirmedResult(Malicious(MALWARE)))
126130

127131
val result = testee.shouldIntercept(request, maliciousUri) {}
132+
verify(mockPixel).fire(AppPixelName.MALICIOUS_SITE_DETECTED_IN_IFRAME, mapOf("category" to MALWARE.name.lowercase()))
128133
assertEquals(MaliciousSite(maliciousUri, MALWARE, false), result)
129134
}
130135

malicious-site-protection/malicious-site-protection-impl/build.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ dependencies {
5454

5555
implementation Google.android.material
5656

57+
implementation project(path: ':statistics-api')
58+
5759
testImplementation AndroidX.test.ext.junit
5860
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
5961
testImplementation Testing.junit4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) 2025 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.malicioussiteprotection.impl
18+
19+
import com.duckduckgo.app.statistics.pixels.Pixel
20+
21+
enum class MaliciousSitePixelName(override val pixelName: String) : Pixel.PixelName {
22+
MALICIOUS_SITE_CLIENT_TIMEOUT("m_malicious-site-protection_client-timeout"),
23+
}

malicious-site-protection/malicious-site-protection-impl/src/main/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/MaliciousSiteRepository.kt

+20-10
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
package com.duckduckgo.malicioussiteprotection.impl.data
1818

19+
import com.duckduckgo.app.statistics.pixels.Pixel
1920
import com.duckduckgo.common.utils.DispatcherProvider
2021
import com.duckduckgo.di.scopes.AppScope
2122
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed
2223
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE
2324
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING
25+
import com.duckduckgo.malicioussiteprotection.impl.MaliciousSitePixelName.MALICIOUS_SITE_CLIENT_TIMEOUT
2426
import com.duckduckgo.malicioussiteprotection.impl.data.db.MaliciousSiteDao
2527
import com.duckduckgo.malicioussiteprotection.impl.data.db.RevisionEntity
2628
import com.duckduckgo.malicioussiteprotection.impl.data.network.FilterResponse
@@ -42,7 +44,9 @@ import com.duckduckgo.malicioussiteprotection.impl.models.Type.HASH_PREFIXES
4244
import com.squareup.anvil.annotations.ContributesBinding
4345
import dagger.SingleInstanceIn
4446
import javax.inject.Inject
47+
import kotlinx.coroutines.TimeoutCancellationException
4548
import kotlinx.coroutines.withContext
49+
import kotlinx.coroutines.withTimeout
4650

4751
interface MaliciousSiteRepository {
4852
suspend fun containsHashPrefix(hashPrefix: String): Boolean
@@ -58,6 +62,7 @@ class RealMaliciousSiteRepository @Inject constructor(
5862
private val maliciousSiteDao: MaliciousSiteDao,
5963
private val maliciousSiteService: MaliciousSiteService,
6064
private val dispatcherProvider: DispatcherProvider,
65+
private val pixels: Pixel,
6166
) : MaliciousSiteRepository {
6267

6368
override suspend fun containsHashPrefix(hashPrefix: String): Boolean {
@@ -79,18 +84,23 @@ class RealMaliciousSiteRepository @Inject constructor(
7984

8085
override suspend fun matches(hashPrefix: String): List<Match> {
8186
return try {
82-
maliciousSiteService.getMatches(hashPrefix).matches.mapNotNull {
83-
val feed = when (it.feed.uppercase()) {
84-
PHISHING.name -> PHISHING
85-
MALWARE.name -> MALWARE
86-
else -> null
87-
}
88-
if (feed != null) {
89-
Match(it.hostname, it.url, it.regex, it.hash, feed)
90-
} else {
91-
null
87+
withTimeout(1000) {
88+
maliciousSiteService.getMatches(hashPrefix).matches.mapNotNull {
89+
val feed = when (it.feed.uppercase()) {
90+
PHISHING.name -> PHISHING
91+
MALWARE.name -> MALWARE
92+
else -> null
93+
}
94+
if (feed != null) {
95+
Match(it.hostname, it.url, it.regex, it.hash, feed)
96+
} else {
97+
null
98+
}
9299
}
93100
}
101+
} catch (e: TimeoutCancellationException) {
102+
pixels.fire(MALICIOUS_SITE_CLIENT_TIMEOUT)
103+
listOf()
94104
} catch (e: Exception) {
95105
listOf()
96106
}

malicious-site-protection/malicious-site-protection-impl/src/test/kotlin/com/duckduckgo/malicioussiteprotection/impl/data/RealMaliciousSiteRepositoryTest.kt

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.duckduckgo.malicioussiteprotection.impl.data
22

3+
import com.duckduckgo.app.statistics.pixels.Pixel
34
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING
5+
import com.duckduckgo.malicioussiteprotection.impl.MaliciousSitePixelName.MALICIOUS_SITE_CLIENT_TIMEOUT
46
import com.duckduckgo.malicioussiteprotection.impl.data.db.FilterEntity
57
import com.duckduckgo.malicioussiteprotection.impl.data.db.HashPrefixEntity
68
import com.duckduckgo.malicioussiteprotection.impl.data.db.MaliciousSiteDao
@@ -17,6 +19,7 @@ import com.duckduckgo.malicioussiteprotection.impl.models.FilterSetWithRevision.
1719
import com.duckduckgo.malicioussiteprotection.impl.models.HashPrefixesWithRevision.PhishingHashPrefixesWithRevision
1820
import com.duckduckgo.malicioussiteprotection.impl.models.Match
1921
import com.duckduckgo.malicioussiteprotection.impl.models.Type
22+
import kotlinx.coroutines.TimeoutCancellationException
2023
import kotlinx.coroutines.test.runTest
2124
import org.junit.Assert.assertEquals
2225
import org.junit.Assert.assertTrue
@@ -34,7 +37,13 @@ class RealMaliciousSiteRepositoryTest {
3437

3538
private val maliciousSiteDao: MaliciousSiteDao = mock()
3639
private val maliciousSiteService: MaliciousSiteService = mock()
37-
private val repository = RealMaliciousSiteRepository(maliciousSiteDao, maliciousSiteService, coroutineRule.testDispatcherProvider)
40+
private val mockPixel: Pixel = mock()
41+
private val repository = RealMaliciousSiteRepository(
42+
maliciousSiteDao,
43+
maliciousSiteService,
44+
coroutineRule.testDispatcherProvider,
45+
mockPixel,
46+
)
3847

3948
@Test
4049
fun loadFilters_updatesFiltersWhenNetworkRevisionIsHigher() = runTest {
@@ -136,4 +145,16 @@ class RealMaliciousSiteRepositoryTest {
136145

137146
assertEquals(matchesResponse.matches.map { Match(it.hostname, it.url, it.regex, it.hash, PHISHING) }, result)
138147
}
148+
149+
@Test
150+
fun matches_returnsEmptyListOnTimeout() = runTest {
151+
val hashPrefix = "testPrefix"
152+
153+
whenever(maliciousSiteService.getMatches(hashPrefix)).thenThrow(TimeoutCancellationException::class.java)
154+
155+
val result = repository.matches(hashPrefix)
156+
157+
assertTrue(result.isEmpty())
158+
verify(mockPixel).fire(MALICIOUS_SITE_CLIENT_TIMEOUT)
159+
}
139160
}

0 commit comments

Comments
 (0)