Skip to content

Commit

Permalink
Refactor the Click to Load declarativeNetRequest rule logic (#1677)
Browse files Browse the repository at this point in the history
The "Click to Load" feature blocks some embedded third-party content,
while giving users the option to click to load it again. To get that
working with Chrome MV3, we have the necessary allowing
declarativeNetRequest rules ready and add those for tabs where the
blocking shouldn't happen. There are quite a few things to consider,
like if the content is first-party, if the user has allowlisted the
website, if the feature is enabled for the current domain etc. Let's
refactor the declarativeNetRequest rule generation logic to handle
more of those edge-cases.
  • Loading branch information
kzar committed Feb 2, 2023
1 parent 9a5b938 commit 53cd347
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 79 deletions.
59 changes: 0 additions & 59 deletions shared/js/background/classes/custom-rules-manager.js

This file was deleted.

4 changes: 2 additions & 2 deletions shared/js/background/classes/tab-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export class TabState {
this.trackers = {}
/** @type {null | import('../events/referrer-trimming').Referrer} */
this.referrer = null
/** @type {Object.<string, number[]>} */
this.customActionRules = {}
/** @type {Record<string, number[]>} */
this.dnrRuleIdsByDisabledClickToLoadRuleAction = {}
/** @type {boolean} */
this.ctlYouTube = false // True when at least one YouTube Click to Load placeholder was displayed in the tab.

Expand Down
8 changes: 4 additions & 4 deletions shared/js/background/classes/tab.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ class Tab {
return this._tabState.adClick
}

set customActionRules (value) {
this._tabState.setValue('customActionRules', value)
set dnrRuleIdsByDisabledClickToLoadRuleAction (value) {
this._tabState.setValue('dnrRuleIdsByDisabledClickToLoadRuleAction', value)
}

get customActionRules () {
return this._tabState.customActionRules
get dnrRuleIdsByDisabledClickToLoadRuleAction () {
return this._tabState.dnrRuleIdsByDisabledClickToLoadRuleAction
}

set trackers (value) {
Expand Down
2 changes: 1 addition & 1 deletion shared/js/background/declarative-net-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ async function updateConfigRules (

await settings.ready()
if (Object.keys(allowingRulesByClickToLoadAction).length) {
settings.updateSetting('allowingRulesByClickToLoadAction', allowingRulesByClickToLoadAction)
settings.updateSetting('allowingDnrRulesByClickToLoadRuleAction', allowingRulesByClickToLoadAction)
}
settings.updateSetting(settingName, settingValue)
}
Expand Down
222 changes: 222 additions & 0 deletions shared/js/background/dnr-click-to-load.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { getNextSessionRuleId } from './dnr-session-rule-id'
import settings from './settings.es6'
import tdsStorage from './storage/tds.es6'
import * as utils from './utils.es6'

/**
* Generates the declarativeNetRequest allowing rules required to disable the
* specified Click to Load rule action for the given tab. If the tab already has
* the required declarativeNetRequest allowing rules, none are returned. If
* declarativeNetRequest rules are returned, the tab's rule lookup is also
* mutated to note the new rule IDs as a side effect.
* @param {string} ruleAction
* @param {import('./classes/tab.es6')} tab
* @return {Promise<import('@duckduckgo/ddg2dnr/lib/utils.js').DNRRule[]>}
*/
async function generateDnrAllowingRules (tab, ruleAction) {
// The necessary declarativeNetRequest allowing rules already exist for this
// tab, nothing to do.
const existingRuleIds =
tab.dnrRuleIdsByDisabledClickToLoadRuleAction[ruleAction]
if (existingRuleIds && existingRuleIds.length > 0) {
return []
}

// Load the Click to Load declarativeNetRequest allowing rule lookup from
// the settings.
await settings.ready()
const allowingDnrRulesByClickToLoadRuleAction =
settings.getSetting('allowingDnrRulesByClickToLoadRuleAction')
if (!allowingDnrRulesByClickToLoadRuleAction) {
console.warn('Failed to load Click to Load allowing rules.')
return []
}

// Find the correct declarativeNetRequest allowing rules for this Click to
// Load rule action.
let allowingRules = allowingDnrRulesByClickToLoadRuleAction[ruleAction]
if (!allowingRules) {
console.warn(`No Click to Load allowing rules for action ${ruleAction}.`)
return []
}

// Make a copy of those declarativeNetRequest rules and assign IDs and
// tab ID matching rule conditions.
const ruleIds = []
allowingRules = JSON.parse(JSON.stringify(allowingRules))
for (const rule of allowingRules) {
// Assign the rule ID.
const ruleId = getNextSessionRuleId()
if (typeof ruleId !== 'number') {
// Not much that can be done if fetching the rule ID failed. Also,
// no need to log a warning here as getNextSessionRuleId will have
// done that already.
continue
}
rule.id = ruleId
ruleIds.push(ruleId)

// Assign the tab ID condition.
rule.condition.tabIds = [tab.id]
}

// Save the rule IDs on the Tab Object, so that the tab's rules can be
// removed/updated as necessary later.
if (ruleIds.length > 0) {
tab.dnrRuleIdsByDisabledClickToLoadRuleAction[ruleAction] = ruleIds
}

return allowingRules
}

/**
* Ensure the correct declarativeNetRequest allowing session rules are added so
* that the default Click to Load rule actions are enabled/disabled for the tab.
*
* 1. The blocking declarativeNetRequest rules for Click to Load are added with
* the rules generated for the tracker block list (tds.json). Therefore, to
* start with all Click to Load rule actions are enabled.
* 2. Session allowing declarativeNetRequest rules are added to disable Click to
* Load rule actions as necessary for tabs. That happens on both navigation
* (with this function) and when the user clicks to load content
* (@see {ensureClickToLoadRuleActionDisabled}).
* 3. Finally, all of session allowing declarativeNetRequest rules are removed
* for a tab when it is closed (@see clearClickToLoadDnrRulesForTab).
*
* Factors that determine which Click to Load rule actions should be enabled for
* a tab include the tab's origin, the extension configuration and the user's
* list of allowlisted domains.
* @param {import('./classes/tab.es6')} tab
* @return {Promise}
*/
export async function restoreDefaultClickToLoadRuleActions (tab) {
const addRules = []
const removeRuleIds = []

await settings.ready()
const allowingDnrRulesByClickToLoadRuleAction =
settings.getSetting('allowingDnrRulesByClickToLoadRuleAction')
if (!allowingDnrRulesByClickToLoadRuleAction) {
console.warn('Click to Load DNR rules are not known yet, skipping.')
return
}

// Assume all Click to Load rule actions should be disabled initially.
const disabledRuleActions =
new Set(Object.keys(allowingDnrRulesByClickToLoadRuleAction))

// If the Click to Load feature is supported and enabled for this tab, see
// which rule actions shouldn't be disabled.
if (utils.getClickToPlaySupport(tab)) {
let { parentEntity } = tab.site

// TODO: Remove this workaround once the content-scope-scripts and
// privacy-configuration repositories have been updated.
if (parentEntity === 'Facebook, Inc.') {
parentEntity = 'Facebook'
}

// Rule actions for enabled third-party entities shouldn't be disabled.
await tdsStorage.ready('config')
const clickToLoadSettings =
tdsStorage?.config?.features?.clickToPlay?.settings || {}
for (const entity of Object.keys(clickToLoadSettings)) {
if (clickToLoadSettings?.[entity]?.state === 'enabled' &&
parentEntity !== entity) {
const { ruleActions } = clickToLoadSettings[entity]
if (ruleActions) {
for (const ruleAction of ruleActions) {
disabledRuleActions.delete(ruleAction)
}
}
}
}
}

// Tab was cleared by the time the extension configuration was read.
if (!tab) {
return
}

// Check which Click to Load rule actions are already disabled for the tab.
for (const disabledRuleAction of
Object.keys(tab.dnrRuleIdsByDisabledClickToLoadRuleAction)) {
if (disabledRuleActions.has(disabledRuleAction)) {
// Existing declarativeNetRequest rules can be reused, since this
// Click to Load rule action should still be still be disabled.
disabledRuleActions.delete(disabledRuleAction)
} else {
// Existing declarativeNetRequest rules should be cleared.
for (const ruleId of
tab.dnrRuleIdsByDisabledClickToLoadRuleAction[disabledRuleAction]) {
removeRuleIds.push(ruleId)
}
delete tab.dnrRuleIdsByDisabledClickToLoadRuleAction[disabledRuleAction]
}
}

// Generate any missing declarativeNetRequest allowing rules needed to
// disable Click to Load rule actions for the tab.
// Note: This also updates the dnrRuleIdsByDisabledClickToLoadRuleAction
// lookup for the tab.
for (const disabledRuleAction of disabledRuleActions) {
addRules.push(...await generateDnrAllowingRules(tab, disabledRuleAction))
}

// Notes:
// - The allowing declarativeNetRequest rule IDs for the tab are noted in
// the Tab Object before the rules are added. If there is a problem
// adding the rules, it will result in an inconsistent state.
// - There is a race condition between the declarativeNetRequest rules
// being added for a tab, and the (potentially blocked) requests from
// being made. This is made worse since some asynchronous operations are
// required (e.g. checking the extension configuration) to know which
// rules should be added/removed. It is possible that sometimes the
// blocking/allowing action will be incorrect if the request happens more
// quickly than the ruleset can be updated.
// - A future optimisation could be to add the allowing
// declarativeNetRequest rules for disabled Click to Load rule actions
// once for all tabs. But note that since there is already an
// optimisation to avoid removing+re-adding rules unnecessarily on
// navigation, it might not be worth the added code complexity.

// Update the declarativeNetRequest session rules for the tab.
if (addRules.length > 0 || removeRuleIds.length > 0) {
return await chrome.declarativeNetRequest.updateSessionRules(
{ addRules, removeRuleIds }
)
}
}

/**
* Ensure the necessary declarativeNetRequest allowing rules are added to
* disable the given Click to Load rule action for the tab.
* @param {string} ruleAction
* @param {import('./classes/tab.es6')} tab
* @return {Promise}
*/
export async function ensureClickToLoadRuleActionDisabled (ruleAction, tab) {
const addRules = await generateDnrAllowingRules(tab, ruleAction)

if (addRules.length > 0) {
return await chrome.declarativeNetRequest.updateSessionRules({ addRules })
}
}

/**
* Removes all Click to Load session declarativeNetRequest rules associated with
* the given tab.
* @param {import('./classes/tab.es6')} tab
* @return {Promise}
*/
export async function clearClickToLoadDnrRulesForTab (tab) {
const removeRuleIds = Array.prototype.concat(
...Object.values(tab.dnrRuleIdsByDisabledClickToLoadRuleAction)
)

if (removeRuleIds.length > 0) {
return await chrome.declarativeNetRequest.updateSessionRules(
{ removeRuleIds }
)
}
}
17 changes: 10 additions & 7 deletions shared/js/background/events.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import browser from 'webextension-polyfill'
import * as messageHandlers from './message-handlers'
import { updateActionIcon } from './events/privacy-icon-indicator'
import { removeInverseRules } from './classes/custom-rules-manager'
import { restoreDefaultClickToLoadRuleActions } from './dnr-click-to-load'
import {
flushSessionRules,
refreshUserAllowlistRules
Expand Down Expand Up @@ -290,16 +290,19 @@ browser.webNavigation.onBeforeNavigate.addListener(details => {
if (details.frameId !== 0) return

const currentTab = tabManager.get({ tabId: details.tabId })
const newTab = tabManager.create({ tabId: details.tabId, url: details.url })

if (manifestVersion === 3) {
// Upon navigation, remove any custom action session rules that may have been applied to this tab
// for example, by click-to-load to temporarily allow FB content to be displayed
// Should we instead rely on chrome.webNavigation.onCommitted events, since a main_frame req may not result
// in a navigation?O . TOH that may result in a race condition if reules aren't removed quickly enough
removeInverseRules(currentTab)
// Ensure that the correct declarativeNetRequest allowing rules are
// added for this tab.
// Note: The webNavigation.onBeforeCommitted event would be better,
// since onBeforeNavigate can be fired for a navigation that is
// not later committed. But since there is a race-condition
// between the page loading and the rules being added, let's use
// onBeforeNavigate for now as it fires sooner.
restoreDefaultClickToLoadRuleActions(newTab)
}

const newTab = tabManager.create({ tabId: details.tabId, url: details.url })
// persist the last URL the tab was trying to upgrade to HTTPS
if (currentTab && currentTab.httpsRedirects) {
newTab.httpsRedirects.persistMainFrameRedirect(currentTab.httpsRedirects.getMainFrameRedirect())
Expand Down
4 changes: 2 additions & 2 deletions shared/js/background/message-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { breakageReportForTab } from './broken-site-report'
import parseUserAgentString from '../shared-utils/parse-user-agent-string.es6'
import { getExtensionURL, notifyPopup } from './wrapper.es6'
import { reloadCurrentTab } from './utils.es6'
import { ensureClickToLoadRuleActionDisabled } from './dnr-click-to-load'
const { getDomain } = require('tldts')
const utils = require('./utils.es6')
const settings = require('./settings.es6')
Expand All @@ -16,7 +17,6 @@ const Companies = require('./companies.es6')
const browserName = utils.getBrowserName()
const devtools = require('./devtools.es6')
const browserWrapper = require('./wrapper.es6')
const { enableInverseRules } = require('./classes/custom-rules-manager')
const getArgumentsObject = require('./helpers/arguments-object')

export async function registeredContentScript (options, sender, req) {
Expand Down Expand Up @@ -208,7 +208,7 @@ export async function unblockClickToLoadContent (data, sender) {
const entity = data.entity

if (browserWrapper.getManifestVersion() === 3) {
await enableInverseRules(data.action, sender.tab.id)
await ensureClickToLoadRuleActionDisabled(data.action, tab)
}
tab.site.clickToLoad.push(entity)

Expand Down

0 comments on commit 53cd347

Please sign in to comment.