Skip to content

Commit 9deb3dc

Browse files
Add tracker blocker for service workers (#1249)
1 parent ac377f5 commit 9deb3dc

File tree

9 files changed

+253
-2
lines changed

9 files changed

+253
-2
lines changed

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,50 @@ class WebViewRequestInterceptorTest {
491491
verify(webView, never()).loadUrl(any())
492492
}
493493

494+
@Test
495+
fun whenInterceptFromServiceWorkerAndRequestShouldBlockAndNoSurrogateThenCancellingResponseReturned() = runBlocking<Unit> {
496+
whenever(mockResourceSurrogates.get(any())).thenReturn(SurrogateResponse(responseAvailable = false))
497+
498+
configureShouldNotUpgrade()
499+
configureShouldBlock()
500+
val response = testee.shouldInterceptFromServiceWorker(
501+
request = mockRequest,
502+
documentUrl = "foo.com"
503+
)
504+
505+
assertCancelledResponse(response)
506+
}
507+
508+
@Test
509+
fun whenInterceptFromServiceWorkerAndRequestShouldBlockButThereIsASurrogateThenResponseReturnedContainsTheSurrogateData() = runBlocking<Unit> {
510+
val availableSurrogate = SurrogateResponse(
511+
scriptId = "testId",
512+
responseAvailable = true,
513+
mimeType = "application/javascript",
514+
jsFunction = "javascript replacement function goes here"
515+
)
516+
whenever(mockResourceSurrogates.get(any())).thenReturn(availableSurrogate)
517+
518+
configureShouldNotUpgrade()
519+
configureShouldBlock()
520+
val response = testee.shouldInterceptFromServiceWorker(
521+
request = mockRequest,
522+
documentUrl = "foo.com"
523+
)
524+
525+
assertEquals(availableSurrogate.jsFunction.byteInputStream().read(), response!!.data.read())
526+
}
527+
528+
@Test
529+
fun whenInterceptFromServiceWorkerAndRequestIsNullThenReturnNull() = runBlocking<Unit> {
530+
assertNull(testee.shouldInterceptFromServiceWorker(request = null, documentUrl = "foo.com"))
531+
}
532+
533+
@Test
534+
fun whenInterceptFromServiceWorkerAndDocumentUrlIsNullThenReturnNull() = runBlocking<Unit> {
535+
assertNull(testee.shouldInterceptFromServiceWorker(request = mockRequest, documentUrl = null))
536+
}
537+
494538
private fun assertRequestCanContinueToLoad(response: WebResourceResponse?) {
495539
assertNull(response)
496540
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (c) 2021 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.serviceworker
18+
19+
import android.webkit.WebResourceRequest
20+
import androidx.test.filters.SdkSuppress
21+
import com.duckduckgo.app.CoroutineTestRule
22+
import com.duckduckgo.app.browser.RequestInterceptor
23+
import com.duckduckgo.app.global.exception.UncaughtExceptionRepository
24+
import com.duckduckgo.app.runBlocking
25+
import com.nhaarman.mockitokotlin2.mock
26+
import com.nhaarman.mockitokotlin2.verify
27+
import com.nhaarman.mockitokotlin2.whenever
28+
import kotlinx.coroutines.ExperimentalCoroutinesApi
29+
import org.junit.Before
30+
import org.junit.Rule
31+
import org.junit.Test
32+
33+
@ExperimentalCoroutinesApi
34+
@SdkSuppress(minSdkVersion = 24)
35+
class BrowserServiceWorkerClientTest {
36+
37+
@get:Rule
38+
var coroutinesTestRule = CoroutineTestRule()
39+
40+
private val requestInterceptor: RequestInterceptor = mock()
41+
private val uncaughtExceptionRepository: UncaughtExceptionRepository = mock()
42+
43+
private lateinit var testee: BrowserServiceWorkerClient
44+
45+
@Before
46+
fun setup() {
47+
testee = BrowserServiceWorkerClient(requestInterceptor, uncaughtExceptionRepository)
48+
}
49+
50+
@Test
51+
fun whenShouldInterceptRequestAndOriginHeaderExistThenSendItToInterceptor() = coroutinesTestRule.runBlocking {
52+
val webResourceRequest: WebResourceRequest = mock()
53+
whenever(webResourceRequest.requestHeaders).thenReturn(mapOf("Origin" to "example.com"))
54+
55+
testee.shouldInterceptRequest(webResourceRequest)
56+
57+
verify(requestInterceptor).shouldInterceptFromServiceWorker(webResourceRequest, "example.com")
58+
}
59+
60+
@Test
61+
fun whenShouldInterceptRequestAndOriginHeaderDoesNotExistButRefererExistThenSendItToInterceptor() = coroutinesTestRule.runBlocking {
62+
val webResourceRequest: WebResourceRequest = mock()
63+
whenever(webResourceRequest.requestHeaders).thenReturn(mapOf("Referer" to "example.com"))
64+
65+
testee.shouldInterceptRequest(webResourceRequest)
66+
67+
verify(requestInterceptor).shouldInterceptFromServiceWorker(webResourceRequest, "example.com")
68+
}
69+
70+
@Test
71+
fun whenShouldInterceptRequestAndNoOriginOrRefererHeadersExistThenSendNullToInterceptor() = coroutinesTestRule.runBlocking {
72+
val webResourceRequest: WebResourceRequest = mock()
73+
whenever(webResourceRequest.requestHeaders).thenReturn(mapOf())
74+
75+
testee.shouldInterceptRequest(webResourceRequest)
76+
77+
verify(requestInterceptor).shouldInterceptFromServiceWorker(webResourceRequest, null)
78+
}
79+
80+
}

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ interface RequestInterceptor {
4444
documentUrl: String?,
4545
webViewClientListener: WebViewClientListener?
4646
): WebResourceResponse?
47+
48+
@WorkerThread
49+
suspend fun shouldInterceptFromServiceWorker(
50+
request: WebResourceRequest?,
51+
documentUrl: String?
52+
): WebResourceResponse?
4753
}
4854

4955
class WebViewRequestInterceptor(
@@ -112,18 +118,37 @@ class WebViewRequestInterceptor(
112118
webViewClientListener?.pageHasHttpResources(documentUrl)
113119
}
114120

121+
return getWebResourceResponse(request, documentUrl, webViewClientListener)
122+
}
123+
124+
override suspend fun shouldInterceptFromServiceWorker(
125+
request: WebResourceRequest?,
126+
documentUrl: String?
127+
): WebResourceResponse? {
128+
129+
if (documentUrl == null) return null
130+
if (request == null) return null
131+
132+
if (TrustedSites.isTrusted(documentUrl)) {
133+
return null
134+
}
135+
136+
return getWebResourceResponse(request, documentUrl, null)
137+
}
138+
139+
private fun getWebResourceResponse(request: WebResourceRequest, documentUrl: String?, webViewClientListener: WebViewClientListener?): WebResourceResponse? {
115140
val trackingEvent = trackingEvent(request, documentUrl, webViewClientListener)
116141
if (trackingEvent?.blocked == true) {
117142
trackingEvent.surrogateId?.let { surrogateId ->
118143
val surrogate = resourceSurrogates.get(surrogateId)
119144
if (surrogate.responseAvailable) {
120-
Timber.d("Surrogate found for $url")
145+
Timber.d("Surrogate found for ${request.url}")
121146
webViewClientListener?.surrogateDetected(surrogate)
122147
return WebResourceResponse(surrogate.mimeType, "UTF-8", surrogate.jsFunction.byteInputStream())
123148
}
124149
}
125150

126-
Timber.d("Blocking request $url")
151+
Timber.d("Blocking request ${request.url}")
127152
privacyProtectionCountDao.incrementBlockedTrackerCount()
128153
return WebResourceResponse(null, null, null)
129154
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.duckduckgo.app.browser.favicon.FaviconPersister
3636
import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister
3737
import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore
3838
import com.duckduckgo.app.browser.logindetection.*
39+
import com.duckduckgo.app.browser.serviceworker.ServiceWorkerLifecycleObserver
3940
import com.duckduckgo.app.browser.session.WebViewSessionInMemoryStorage
4041
import com.duckduckgo.app.browser.session.WebViewSessionStorage
4142
import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewGenerator
@@ -312,4 +313,9 @@ class BrowserModule {
312313
fun thirdPartyCookieManager(cookieManager: CookieManager, authCookiesAllowedDomainsRepository: AuthCookiesAllowedDomainsRepository): ThirdPartyCookieManager {
313314
return AppThirdPartyCookieManager(cookieManager, authCookiesAllowedDomainsRepository)
314315
}
316+
317+
@Provides
318+
@Singleton
319+
@IntoSet
320+
fun serviceWorkerLifecycleObserver(serviceWorkerLifecycleObserver: ServiceWorkerLifecycleObserver): LifecycleObserver = serviceWorkerLifecycleObserver
315321
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2021 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.serviceworker
18+
19+
import android.os.Build
20+
import android.webkit.ServiceWorkerClient
21+
import android.webkit.WebResourceRequest
22+
import android.webkit.WebResourceResponse
23+
import androidx.annotation.RequiresApi
24+
import com.duckduckgo.app.browser.RequestInterceptor
25+
import com.duckduckgo.app.global.exception.UncaughtExceptionRepository
26+
import com.duckduckgo.app.global.exception.UncaughtExceptionSource
27+
import kotlinx.coroutines.runBlocking
28+
import timber.log.Timber
29+
30+
@RequiresApi(Build.VERSION_CODES.N)
31+
class BrowserServiceWorkerClient(private val requestInterceptor: RequestInterceptor, private val uncaughtExceptionRepository: UncaughtExceptionRepository) : ServiceWorkerClient() {
32+
33+
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
34+
return runBlocking {
35+
try {
36+
val documentUrl: String? = request.requestHeaders[HEADER_ORIGIN] ?: request.requestHeaders[HEADER_REFERER]
37+
Timber.v("Intercepting Service Worker resource ${request.url} type:${request.method} on page $documentUrl")
38+
requestInterceptor.shouldInterceptFromServiceWorker(request, documentUrl)
39+
} catch (e: Throwable) {
40+
uncaughtExceptionRepository.recordUncaughtException(e, UncaughtExceptionSource.SHOULD_INTERCEPT_REQUEST_FROM_SERVICE_WORKER)
41+
throw e
42+
}
43+
}
44+
}
45+
46+
companion object {
47+
const val HEADER_ORIGIN = "Origin"
48+
const val HEADER_REFERER = "Referer"
49+
}
50+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) 2021 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.serviceworker
18+
19+
import android.os.Build
20+
import android.webkit.ServiceWorkerController
21+
import androidx.lifecycle.Lifecycle
22+
import androidx.lifecycle.LifecycleObserver
23+
import androidx.lifecycle.OnLifecycleEvent
24+
import com.duckduckgo.app.browser.RequestInterceptor
25+
import com.duckduckgo.app.global.exception.UncaughtExceptionRepository
26+
import javax.inject.Inject
27+
import javax.inject.Singleton
28+
29+
@Singleton
30+
class ServiceWorkerLifecycleObserver @Inject constructor(
31+
private val requestInterceptor: RequestInterceptor,
32+
private val uncaughtExceptionRepository: UncaughtExceptionRepository
33+
) : LifecycleObserver {
34+
35+
@OnLifecycleEvent(Lifecycle.Event.ON_START)
36+
fun setServiceWorkerClient() {
37+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
38+
ServiceWorkerController.getInstance().setServiceWorkerClient(
39+
BrowserServiceWorkerClient(requestInterceptor, uncaughtExceptionRepository)
40+
)
41+
}
42+
}
43+
}

common/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionDao.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ abstract class UncaughtExceptionDao {
4040
enum class UncaughtExceptionSource {
4141
GLOBAL,
4242
SHOULD_INTERCEPT_REQUEST,
43+
SHOULD_INTERCEPT_REQUEST_FROM_SERVICE_WORKER,
4344
ON_PAGE_STARTED,
4445
ON_PAGE_FINISHED,
4546
SHOULD_OVERRIDE_REQUEST,

statistics/src/main/java/com/duckduckgo/app/statistics/api/OfflinePixelSender.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class OfflinePixelSender @Inject constructor(
118118
return when (exception.exceptionSource) {
119119
GLOBAL -> APPLICATION_CRASH_GLOBAL
120120
SHOULD_INTERCEPT_REQUEST -> APPLICATION_CRASH_WEBVIEW_SHOULD_INTERCEPT
121+
SHOULD_INTERCEPT_REQUEST_FROM_SERVICE_WORKER -> APPLICATION_CRASH_WEBVIEW_SHOULD_INTERCEPT_SERVICE_WORKER
121122
ON_PAGE_STARTED -> APPLICATION_CRASH_WEBVIEW_PAGE_STARTED
122123
ON_PAGE_FINISHED -> APPLICATION_CRASH_WEBVIEW_PAGE_FINISHED
123124
SHOULD_OVERRIDE_REQUEST -> APPLICATION_CRASH_WEBVIEW_OVERRIDE_REQUEST

statistics/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface Pixel {
4040

4141
APPLICATION_CRASH("m_d_ac"),
4242
APPLICATION_CRASH_GLOBAL("m_d_ac_g"),
43+
APPLICATION_CRASH_WEBVIEW_SHOULD_INTERCEPT_SERVICE_WORKER("m_d_ac_wisw"),
4344
APPLICATION_CRASH_WEBVIEW_SHOULD_INTERCEPT("m_d_ac_wi"),
4445
APPLICATION_CRASH_WEBVIEW_PAGE_STARTED("m_d_ac_wps"),
4546
APPLICATION_CRASH_WEBVIEW_PAGE_FINISHED("m_d_ac_wpf"),

0 commit comments

Comments
 (0)