Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

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 HttpUrl instead. That way we could get pretty much the same performance benefits as with Uri, while still keeping this as a Kotlin module

Copy link
Contributor Author

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.

}
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this toString again? If the URL is malformed, requestUrl.toHttpUrlOrNull() should've returned null, which already short-circuits

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requestUrl.toHttpUrlOrNull() is used to normalise the request URL. The requirement is that the request URL should be normalised first, so protocol and domain parts are always considered to be lowercase. That function is used to handle that. However, the regex matchins requires a string, that’s why I am calling that again.


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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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. example.com is a valid entry, while some.subdomain.example.com should be skipped.

val validRules = entry.value.rules?.mapNotNull { BlocklistRuleEntity.fromJson(it) } ?: emptyList()
if (validRules.isNotEmpty()) {
newBlockedRequests[domain] = validRules
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 com.duckduckgo.app.browser.Domain? That will allow us to use a more performant implementation of sameOrSubdomain later on. Otherwise, we would convert this back to Uri, so we can get the domain from it

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

val httpUrl = requestUrl.toHttpUrlOrNull() ?: return false
val requestDomain = httpUrl.topPrivateDomain() ?: return false

sameOrSubdomain is used to check if the document url is the same or a subdomain of the domans that the rule needs to apply for.

}
}
}
}.onFailure {
logcat { "RequestBlocklist: Failed to parse settings: ${it.message}" }
}
}

blockedRequests.clear()
blockedRequests.putAll(newBlockedRequests)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-atomic map update creates brief blocklist gap

Medium Severity

The blockedRequests.clear() followed by blockedRequests.putAll(newBlockedRequests) is not atomic. Between these two calls, any concurrent containedInBlocklist invocation will see an empty map and return false, allowing requests that are in the blocklist to pass through. Using a volatile reference swap (e.g., AtomicReference<Map<...>>) instead of mutating a ConcurrentHashMap in place would avoid this window.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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>>?,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be a String (or a Regex)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

"rules": [
                    {
                        "rule": “testing/*.jpg",
                        "domains": ["example.com"],
                        "reason": ["EXAMPLE"]
                    }
                ]

This List<Map<String, @JvmSuppressWildcards Any>>? is used here because this is a Moshi/JSON deserialization model. It represents the raw JSON structure before it's parsed into typed objects.

Each rule in the JSON is an object with varying keys (rule, domains, reason), so Moshi deserializes each one as a Map<String, Any>. The typed conversion happens later in BlocklistRuleEntity.fromJson(map), which validates the keys and casts values to their expected types.

)

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? {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unchecked generic cast on deserialized list elements

Low Severity

The cast as? List<String> on the domains value only checks is List at runtime due to JVM type erasure — it does not verify that elements are actually String. If the config JSON contains non-string values in the domains array, the cast silently succeeds but a ClassCastException will be thrown later when domainMatches iterates the list, bypassing the runCatching in loadToMemory since the error occurs at match time in containedInBlocklist.


Please tell me if this was useful or not with a 👍 or 👎.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 fromJson method.

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)
}
}
}
Loading
Loading