Skip to content

Commit

Permalink
Add Chrome MV3 support for user allowlisting (#1464)
Browse files Browse the repository at this point in the history
Users can allowlist (disable protections) for websites from the popup
UI and also from the options page. To make that work with Chrome MV3,
we need to add a declarativeNetRequest rule that ensures requests
initiated by domains that the user has allowlisted aren't blocked or
redirected.
  • Loading branch information
kzar committed Oct 27, 2022
1 parent bfb8438 commit 6710367
Show file tree
Hide file tree
Showing 4 changed files with 296 additions and 81 deletions.
167 changes: 142 additions & 25 deletions shared/js/background/declarative-net-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
import {
generateTdsRuleset
} from '@duckduckgo/ddg2dnr/lib/tds'
import {
generateDNRRule
} from '@duckduckgo/ddg2dnr/lib/utils'
import {
USER_ALLOWLISTED_PRIORITY
} from '@duckduckgo/ddg2dnr/lib/rulePriorities'

export const SETTING_PREFIX = 'declarative_net_request-'

Expand All @@ -21,30 +27,57 @@ const ruleIdRangeByConfigName = {
config: [10001, 20000]
}

// A dummy etag rule is saved with the declarativeNetRequest rules generated for
// each configuration. That way, a consistent extension state (between tds
// configurations, extension settings and declarativeNetRequest rules) can be
// ensured.
// User allowlisting only requires one declarativeNetRequest rule, so hardcode
// the rule ID here.
export const USER_ALLOWLIST_RULE_ID = 20001

/**
* A dummy etag rule is saved with the declarativeNetRequest rules generated for
* each configuration. That way, a consistent extension state (between tds
* configurations, extension settings and declarativeNetRequest rules) can be
* ensured.
* @param {number} id
* @param {string} etag
* @returns {import('@duckduckgo/ddg2dnr/lib/utils.js').DNRRule}
*/
function generateEtagRule (id, etag) {
return {
return generateDNRRule({
id,
priority: 1,
condition: {
urlFilter: etag,
requestDomains: ['etag.invalid']
},
action: { type: 'allow' }
actionType: 'allow',
urlFilter: etag,
requestDomains: ['etag.invalid']
})
}

/**
* Find an existing dynamic declarativeNetRequest rule with the given rule ID
* and return it.
* @param {number} desiredRuleId
* @returns {Promise<import('@duckduckgo/ddg2dnr/lib/utils.js').DNRRule|null>}
*/
async function findExistingDynamicRule (desiredRuleId) {
const existingRules = await chrome.declarativeNetRequest.getDynamicRules()

for (const rule of existingRules) {
if (rule.id === desiredRuleId) {
return rule
}
}

return null
}

/**
* tdsStorage.onUpdate listener which is called when the configurations are
* updated and when the background ServiceWorker is restarted.
* Note: Only exported for use by unit tests, do not call manually.
* @param {'config'|'tds'} configName
* @param {string} etag
* @param {object} configValue
* @returns {Promise}
*/
async function onUpdate (configName, etag, configValue) {
export async function onConfigUpdate (configName, etag, configValue) {
await settings.ready()

const [ruleIdStart, ruleIdEnd] = ruleIdRangeByConfigName[configName]
Expand All @@ -67,15 +100,9 @@ async function onUpdate (configName, etag, configValue) {
previousSettingEtag === etag &&
previousExtensionVersion &&
previousExtensionVersion === extensionVersion) {
const existingRules =
await chrome.declarativeNetRequest.getDynamicRules()
let previousRuleEtag = null
for (const rule of existingRules) {
if (rule.id === etagRuleId) {
previousRuleEtag = rule.condition.urlFilter
break
}
}
const existingEtagRule = await findExistingDynamicRule(etagRuleId)
const previousRuleEtag =
existingEtagRule && existingEtagRule.condition.urlFilter

// No change, rules are already current.
if (previousRuleEtag && previousRuleEtag === etag) {
Expand Down Expand Up @@ -172,22 +199,28 @@ async function onUpdate (configName, etag, configValue) {
*/

/**
* @typedef {object} getMatchDetailsUnknownResult
* @typedef {object} getMatchDetailsResult
* @property {string} type
* The match type 'unknown'.
* The match type, e.g. 'unknown' or 'userAllowlist'.
*/

/**
* Find the match details (if any) associated with the given
* declarativeNetRequest rule ID.
* @param {number} ruleId
* @return {Promise<getMatchDetailsUnknownResult |
* @return {Promise<getMatchDetailsResult |
* getMatchDetailsExtensionConfigurationResult |
* getMatchDetailsTrackerBlockingResult>}
*/
export async function getMatchDetails (ruleId) {
await settings.ready()

if (ruleId === USER_ALLOWLIST_RULE_ID) {
return {
type: 'userAllowlist'
}
}

for (const [configName, [ruleIdStart, ruleIdEnd]]
of Object.entries(ruleIdRangeByConfigName)) {
if (ruleId >= ruleIdStart && ruleId <= ruleIdEnd) {
Expand All @@ -210,7 +243,91 @@ export async function getMatchDetails (ruleId) {
return { type: 'unknown' }
}

/**
* Normalize and validate the given untrusted domain (e.g. from user input).
* Returns the normalized domain, or null should the domain be considered
* invalid.
* @param {string} domain
* @return {null|string}
*/
function normalizeUntrustedDomain (domain) {
try {
return new URL('https://' + domain).hostname
} catch (e) {
return null
}
}

/**
* Update the user allowlisting declarativeNetRequest rule to ensure the correct
* domains are allowlisted.
* @param {string[]} allowlistedDomains
* @return {Promise}
*/
async function updateUserAllowlistRule (allowlistedDomains) {
const addRules = []
const removeRuleIds = [USER_ALLOWLIST_RULE_ID]

if (allowlistedDomains.length > 0) {
addRules.push(generateDNRRule({
id: USER_ALLOWLIST_RULE_ID,
priority: USER_ALLOWLISTED_PRIORITY,
actionType: 'allowAllRequests',
resourceTypes: ['main_frame'],
requestDomains: allowlistedDomains
}))
}

await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds, addRules
})
}

/**
* Update the user allowlisting declarativeNetRequest rule to enable/disable
* user allowlisting for the given domain.
* @param {string} domain
* @param {boolean} enable
* True if the domain is being added to the allowlist, false if it is being
* removed.
* @return {Promise}
*/
export async function toggleUserAllowlistDomain (domain, enable) {
const normalizedDomain = normalizeUntrustedDomain(domain)
if (typeof normalizedDomain !== 'string') {
return
}

// Figure out the correct set of allowlisted domains.
const existingRule = await findExistingDynamicRule(USER_ALLOWLIST_RULE_ID)
const allowlistedDomains = new Set(
existingRule ? existingRule.condition.requestDomains : []
)
allowlistedDomains[enable ? 'add' : 'delete'](normalizedDomain)

await updateUserAllowlistRule(Array.from(allowlistedDomains))
}

/**
* Reset the user allowlisting declarativeNetRequest rule to match the given
* array of user allowlisted domains.
* @param {string[]} allowlistedDomains
* @return {Promise}
*/
export async function refreshUserAllowlistRules (allowlistedDomains) {
// Normalise and validate the domains. We're passing the user provided
// domains through to the declarativeNetRequest API, so it's important to
// prevent invalid input sneaking through.
const normalizedAllowlistedDomains = /** @type {string[]} */ (
allowlistedDomains
.map(normalizeUntrustedDomain)
.filter(domain => typeof domain === 'string')
)

await updateUserAllowlistRule(normalizedAllowlistedDomains)
}

if (browserWrapper.getManifestVersion() === 3) {
tdsStorage.onUpdate('config', onUpdate)
tdsStorage.onUpdate('tds', onUpdate)
tdsStorage.onUpdate('config', onConfigUpdate)
tdsStorage.onUpdate('tds', onConfigUpdate)
}
16 changes: 16 additions & 0 deletions shared/js/background/events.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const tdsStorage = require('./storage/tds.es6')
const browserWrapper = require('./wrapper.es6')
const limitReferrerData = require('./events/referrer-trimming')
const { dropTracking3pCookiesFromResponse, dropTracking3pCookiesFromRequest } = require('./events/3p-tracking-cookie-blocking')
const { refreshUserAllowlistRules } = require('./declarative-net-request')

const manifestVersion = browserWrapper.getManifestVersion()

Expand Down Expand Up @@ -55,6 +56,21 @@ async function onInstalled (details) {
})
}
}

// Refresh the user allowlisting declarativeNetRequest rule.
if (manifestVersion === 3) {
await settings.ready()
const allowlist = settings.getSetting('allowlisted') || {}

const allowlistedDomains = []
for (const [domain, enabled] of Object.entries(allowlist)) {
if (enabled) {
allowlistedDomains.push(domain)
}
}

await refreshUserAllowlistRules(allowlistedDomains)
}
}

browser.runtime.onInstalled.addListener(onInstalled)
Expand Down
20 changes: 19 additions & 1 deletion shared/js/background/tab-manager.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ const settings = require('./settings.es6')
const Tab = require('./classes/tab.es6')
const { TabState } = require('./classes/tab-state')
const browserWrapper = require('./wrapper.es6')
const { toggleUserAllowlistDomain } = require('./declarative-net-request.js')

const manifestVersion = browserWrapper.getManifestVersion()

/**
* @typedef {import('./classes/site.es6.js').allowlistName} allowlistName
Expand Down Expand Up @@ -84,8 +87,9 @@ class TabManager {
* @param {allowlistName} data.list - name of the allowlist to update
* @param {string} data.domain - domain to allowlist
* @param {boolean} data.value - allowlist value, true or false
* @return {Promise}
*/
setList (data) {
async setList (data) {
this.setGlobalAllowlist(data.list, data.domain, data.value)

for (const tabId in this.tabContainer) {
Expand All @@ -95,6 +99,20 @@ class TabManager {
}
}

if (manifestVersion === 3) {
// Ensure that user allowlisting/denylisting is honoured for
// manifest v3 builds of the extension, by adding/removing the
// necessary declarativeNetRequest rules.
await toggleUserAllowlistDomain(data.domain, data.value)

// TODO - Once support for the temporary allowlist
// (the contentBlocking) section of extension-config.json is
// added, the "denylisted" event needs to be handled here to
// ensure that users are able to override the temporary
// allowlist manually be re-enabling protections for a
// webiste.
}

browserWrapper.notifyPopup({ allowlistChanged: true })
}

Expand Down

0 comments on commit 6710367

Please sign in to comment.