-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Create the new RequestBlocklist interface and implementation #7880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
650c48f
615bb99
b2d7575
027992a
d930cf5
9a9368e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| /* | ||
| * Copyright (c) 2026 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.privacy.config.api | ||
|
|
||
| interface RequestBlocklist { | ||
| /** | ||
| * Checks whether a request is contained in the blocklist rules | ||
| * from the Privacy Configuration. | ||
| * | ||
| * @param documentUrl the URL of the page making the request | ||
| * @param requestUrl the URL being requested | ||
| * @return true if the request matches a blocklist rule | ||
| */ | ||
| fun containedInBlocklist(documentUrl: String, requestUrl: String): Boolean | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /* | ||
| * Copyright (c) 2026 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.privacy.config.impl.features.requestblocklist | ||
|
|
||
| import com.duckduckgo.app.browser.UriString | ||
| import com.duckduckgo.app.di.AppCoroutineScope | ||
| import com.duckduckgo.app.di.IsMainProcess | ||
| import com.duckduckgo.common.utils.DispatcherProvider | ||
| import com.duckduckgo.di.scopes.AppScope | ||
| import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin | ||
| import com.duckduckgo.privacy.config.api.RequestBlocklist | ||
| import com.squareup.anvil.annotations.ContributesBinding | ||
| import com.squareup.anvil.annotations.ContributesMultibinding | ||
| import com.squareup.moshi.JsonAdapter | ||
| import com.squareup.moshi.Moshi | ||
| import dagger.SingleInstanceIn | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.launch | ||
| import logcat.logcat | ||
| import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||
| import java.util.concurrent.ConcurrentHashMap | ||
| import javax.inject.Inject | ||
|
|
||
| @SingleInstanceIn(AppScope::class) | ||
| @ContributesBinding(AppScope::class, RequestBlocklist::class) | ||
| @ContributesMultibinding(AppScope::class, PrivacyConfigCallbackPlugin::class) | ||
| class RealRequestBlocklist @Inject constructor( | ||
| private val requestBlocklistFeature: RequestBlocklistFeature, | ||
| private val dispatchers: DispatcherProvider, | ||
| @IsMainProcess private val isMainProcess: Boolean, | ||
| @AppCoroutineScope private val appCoroutineScope: CoroutineScope, | ||
| moshi: Moshi, | ||
| ) : RequestBlocklist, PrivacyConfigCallbackPlugin { | ||
|
|
||
| private val blockedRequests = ConcurrentHashMap<String, List<BlocklistRuleEntity>>() | ||
|
|
||
| private val blockListSettingsJsonAdapter: JsonAdapter<RequestBlocklistSettings> = | ||
| moshi.adapter(RequestBlocklistSettings::class.java) | ||
|
|
||
| init { | ||
| if (isMainProcess) { | ||
| loadToMemory() | ||
| } | ||
| } | ||
|
|
||
| override fun onPrivacyConfigDownloaded() { | ||
| loadToMemory() | ||
| } | ||
|
|
||
| override fun containedInBlocklist( | ||
| documentUrl: String, | ||
| requestUrl: String, | ||
| ): Boolean { | ||
| if (!requestBlocklistFeature.self().isEnabled()) return false | ||
|
|
||
| val httpUrl = requestUrl.toHttpUrlOrNull() ?: return false | ||
| val requestDomain = httpUrl.topPrivateDomain() ?: return false | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just thinking out loud, but if in the future we want to better orchestrate all types of request interception, we might want to avoid each implementation performing the same URL manipulations, as a way to improve performance
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with that. But we need to take into account that we will need to add other dependencies to the API module. As in this case we’re using strings for the urls, but we’re converting them here because we cannot pass them as HttpUrl/ Uri becase the API module doesn’t have Android/ OkHttp dependencies. |
||
|
|
||
| val rules = blockedRequests[requestDomain] ?: return false | ||
|
|
||
| val normalizedUrl = httpUrl.toString() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| return rules.any { rule -> | ||
| rule.rule.containsMatchIn(normalizedUrl) && domainMatches(documentUrl, rule.domains) | ||
| } | ||
| } | ||
|
|
||
| private fun loadToMemory() { | ||
| appCoroutineScope.launch(dispatchers.io()) { | ||
| val newBlockedRequests = ConcurrentHashMap<String, List<BlocklistRuleEntity>>() | ||
|
|
||
| requestBlocklistFeature.self().getSettings()?.let { settingsJson -> | ||
| runCatching { | ||
| val settings = blockListSettingsJsonAdapter.fromJson(settingsJson) | ||
|
|
||
| settings?.blockedRequests?.entries?.forEach { entry -> | ||
| val domain = entry.key | ||
| val topPrivateDomain = "https://$domain".toHttpUrlOrNull()?.topPrivateDomain() | ||
| if (topPrivateDomain != null && topPrivateDomain == domain) { | ||
|
Comment on lines
+92
to
+93
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Entries must be for eTLD+1 domains, other entries will be ignored. I need to make sure that the entry is in that format, because we don’t support subdomains in the entries. |
||
| val validRules = entry.value.rules?.mapNotNull { BlocklistRuleEntity.fromJson(it) } ?: emptyList() | ||
| if (validRules.isNotEmpty()) { | ||
| newBlockedRequests[domain] = validRules | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At this point, we have already validated the domain, so could we use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I won’t really help us, becasue the domain in this case if the one from the request. We take it like this:
|
||
| } | ||
| } | ||
| } | ||
| }.onFailure { | ||
| logcat { "RequestBlocklist: Failed to parse settings: ${it.message}" } | ||
| } | ||
| } | ||
|
|
||
| blockedRequests.clear() | ||
| blockedRequests.putAll(newBlockedRequests) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-atomic map update creates brief blocklist gapMedium Severity The Additional Locations (1)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The blocklist is processed at app launch or afer the privacy config is downloaded, this shouldn’t be a real issue. |
||
| } | ||
| } | ||
|
|
||
| private fun domainMatches( | ||
| documentUrl: String?, | ||
| domains: List<String>, | ||
| ): Boolean { | ||
| if (documentUrl == null) return false | ||
| if (domains.contains("<all>")) return true | ||
| return domains.any { domain -> UriString.sameOrSubdomain(documentUrl, domain) } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| /* | ||
| * Copyright (c) 2026 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.privacy.config.impl.features.requestblocklist | ||
|
|
||
| import com.duckduckgo.anvil.annotations.ContributesRemoteFeature | ||
| import com.duckduckgo.di.scopes.AppScope | ||
| import com.duckduckgo.feature.toggles.api.Toggle | ||
| import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue | ||
|
|
||
| @ContributesRemoteFeature( | ||
| scope = AppScope::class, | ||
| featureName = "requestBlocklist", | ||
| ) | ||
| interface RequestBlocklistFeature { | ||
| @Toggle.DefaultValue(DefaultFeatureValue.FALSE) | ||
| fun self(): Toggle | ||
| } | ||
|
|
||
| data class RequestBlocklistSettings( | ||
| val blockedRequests: Map<String, BlockedRequestEntry>?, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /* | ||
| * Copyright (c) 2026 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.privacy.config.impl.features.requestblocklist | ||
|
|
||
| data class BlockedRequestEntry( | ||
| val rules: List<Map<String, @JvmSuppressWildcards Any>>?, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be a String (or a Regex)?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rules have the following format, so we cannot use String or Regex, because the parsing would fail. This Each rule in the JSON is an object with varying keys (rule, domains, reason), so Moshi deserializes each one as a |
||
| ) | ||
|
|
||
| class BlocklistRuleEntity( | ||
| val rule: Regex, | ||
| val domains: List<String>, | ||
| val reason: String?, | ||
| ) { | ||
| companion object { | ||
| private const val PROPERTY_RULE = "rule" | ||
| private const val PROPERTY_DOMAINS = "domains" | ||
| private const val PROPERTY_REASON = "reason" | ||
|
|
||
| private val KNOWN_PROPERTIES = setOf(PROPERTY_RULE, PROPERTY_DOMAINS, PROPERTY_REASON) | ||
|
|
||
| @Suppress("UNCHECKED_CAST") | ||
| fun fromJson(map: Map<String, Any>): BlocklistRuleEntity? { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do we need this manual parsing for? Also, I think failing on unknown properties is too restrictive and will break older versions if we add a new field. If we really need this, I think we should at least fire a pixel to alert on the situation, but I think we can just ignore unknown fields
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The requirement was to skip rules with unknown properties or missing required ones. I discussed with Dave as well and this is how he suggested to handle this. I can add a pixel there so that we know about this situation. But the reason for this was to avoid situations where the rule is accepted but behaves differently on older versions (I guess in scenarios where we would add a new property which should be considered as well when checking if the request is in the blocklist). |
||
| if (map.keys.any { it !in KNOWN_PROPERTIES }) return null | ||
|
|
||
| val ruleString = map[PROPERTY_RULE] as? String ?: return null | ||
| val domains = map[PROPERTY_DOMAINS] as? List<String> ?: return null | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unchecked generic cast on deserialized list elementsLow Severity The cast Please tell me if this was useful or not with a 👍 or 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not a real concern, the same would happen if we would use a data model for parsing instead of using the |
||
| val reason = map[PROPERTY_REASON] as? String | ||
|
|
||
| val rule = buildString { | ||
| for (char in ruleString) { | ||
| if (char == '*') { | ||
| append("[^/]*") | ||
| } else { | ||
| append(Regex.escape(char.toString())) | ||
| } | ||
| } | ||
| }.toRegex() | ||
|
|
||
| return BlocklistRuleEntity(rule = rule, domains = domains, reason = reason) | ||
| } | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this was accepted on the proposal, but looking at the implementation details, and some notes on performance I added below, I'm wondering whether the function should use
HttpUrlinstead. That way we could get pretty much the same performance benefits as withUri, while still keeping this as a Kotlin moduleThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HttpUrl is from the okhttp module, which the API module is not using. We cannot use it unless we add the dependency there. It’s the same issue that was with Uri, which required Android dependencies in a pure Kotlin module.