From a483f7955fc8c9c820684c3f55c9f982a7abb2d3 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Thu, 6 Mar 2025 08:57:27 -0500 Subject: [PATCH] Add ability to inject scriptlets according to origin of ancestor contexts New syntax for scriptlet-based filtering: it is now possible to inject a scriptlet in a context according to whether an ancestor origin matches a specific hostname. Example: example.com>>##+js(set, iAmEmbeddedInExampleDotCom, true) The new `>>` specifier means that the scriptlet will not be injected in `example.com`, but will be injected in all direct and indirect embedded contexts in pages loaded from `example.com` The new ancestor domain specifier also works for entity directives: example.*##+js(set, iAmEmbeddedInExampleDotEffectiveTLD, true) This is an experimental feature, to be further evaluated and discussed by filter list maintainers. This is not a complete implementation, by choice. Do not open issues regarding this new syntax, the current shortcomings are known. The new syntax is being discussed internally at: https://github.com/uBlockOrigin/uBlock-discussions/discussions/244 --- src/js/benchmarks.js | 1 - src/js/cosmetic-filtering.js | 6 ++- src/js/messaging.js | 3 +- src/js/pagestore.js | 15 ++++++ src/js/scriptlet-filtering-core.js | 21 ++++++-- src/js/scriptlet-filtering.js | 3 +- src/js/static-filtering-parser.js | 86 +++++++++++++++++++----------- src/js/tab.js | 1 + src/js/traffic.js | 1 + src/js/uri-utils.js | 10 ++++ 10 files changed, 103 insertions(+), 44 deletions(-) diff --git a/src/js/benchmarks.js b/src/js/benchmarks.js index f9875694e..7e24d39bd 100644 --- a/src/js/benchmarks.js +++ b/src/js/benchmarks.js @@ -321,7 +321,6 @@ export async function benchmarkCosmeticFiltering() { frameId: undefined, hostname: '', domain: '', - entity: '', }; const options = { noSpecificCosmeticFiltering: false, diff --git a/src/js/cosmetic-filtering.js b/src/js/cosmetic-filtering.js index 9a078095c..663fa0df9 100644 --- a/src/js/cosmetic-filtering.js +++ b/src/js/cosmetic-filtering.js @@ -23,6 +23,7 @@ import { MRUCache } from './mrucache.js'; import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js'; +import { entityFromHostname } from './uri-utils.js'; import logger from './logger.js'; import µb from './background.js'; @@ -818,9 +819,10 @@ CosmeticFilteringEngine.prototype.retrieveSpecificSelectors = function( 3 ); // Retrieve filters with a entity-based hostname value - if ( request.entity !== '' ) { + const entity = entityFromHostname(hostname, request.domain); + if ( entity !== '' ) { this.specificFilters.retrieve( - `${hostname.slice(0, -request.domain.length)}${request.entity}`, + entity, options.noSpecificCosmeticFiltering ? discardSets : retrieveSets, 1 ); diff --git a/src/js/messaging.js b/src/js/messaging.js index 2a5e7da95..668138c3b 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -25,7 +25,6 @@ import * as sfp from './static-filtering-parser.js'; import { domainFromHostname, domainFromURI, - entityFromDomain, hostnameFromURI, isNetworkURI, } from './uri-utils.js'; @@ -684,7 +683,7 @@ const retrieveContentScriptParameters = async function(sender, request) { request.frameId = frameId; request.hostname = hostnameFromURI(request.url); request.domain = domainFromHostname(request.hostname); - request.entity = entityFromDomain(request.domain); + request.ancestors = pageStore.getFrameAncestorDetails(frameId); const scf = response.specificCosmeticFilters = cosmeticFilteringEngine.retrieveSpecificSelectors(request, response); diff --git a/src/js/pagestore.js b/src/js/pagestore.js index 5d67f86f1..cfb997475 100644 --- a/src/js/pagestore.js +++ b/src/js/pagestore.js @@ -535,6 +535,21 @@ const PageStore = class { return sender.frameURL; } + getFrameAncestorDetails(frameId) { + if ( frameId === 0 ) { return []; } + const out = []; + for (;;) { + const frameStore = this.getFrameStore(frameId); + if ( frameStore === null ) { break; } + const { domain, hostname } = frameStore; + if ( hostname !== undefined ) { + out.push({ domain, hostname }); + } + frameId = frameStore.parentId; + } + return out.slice(1); + } + // There is no event to tell us a specific subframe has been removed from // the main document. The code below will remove subframes which are no // longer present in the root document. Removing obsolete subframes is diff --git a/src/js/scriptlet-filtering-core.js b/src/js/scriptlet-filtering-core.js index a585f119e..89b06ca05 100644 --- a/src/js/scriptlet-filtering-core.js +++ b/src/js/scriptlet-filtering-core.js @@ -20,6 +20,7 @@ */ import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js'; +import { entityFromHostname } from './uri-utils.js'; import { redirectEngine as reng } from './redirect-engine.js'; /******************************************************************************/ @@ -208,13 +209,23 @@ export class ScriptletFilteringEngine { $scriptlets.clear(); $exceptions.clear(); - const { hostname } = request; + const { ancestors = [], domain, hostname } = request; this.scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]); - const entity = request.entity !== '' - ? `${hostname.slice(0, -request.domain.length)}${request.entity}` - : '*'; - this.scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1); + const entity = entityFromHostname(hostname, domain); + if ( entity !== '' ) { + this.scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1); + } else { + this.scriptletDB.retrieve('*', [ $scriptlets, $exceptions ], 1); + } + for ( const ancestor of ancestors ) { + const { domain, hostname } = ancestor; + this.scriptletDB.retrieve(`${hostname}>>`, [ $scriptlets, $exceptions ], 1); + const entity = entityFromHostname(hostname, domain); + if ( entity !== '' ) { + this.scriptletDB.retrieve(`${entity}>>`, [ $scriptlets, $exceptions ], 1); + } + } if ( $scriptlets.size === 0 ) { return; } // Wholly disable scriptlet injection? diff --git a/src/js/scriptlet-filtering.js b/src/js/scriptlet-filtering.js index 722a6fe34..0d127f6a5 100644 --- a/src/js/scriptlet-filtering.js +++ b/src/js/scriptlet-filtering.js @@ -23,7 +23,6 @@ import { domainFromHostname, - entityFromDomain, hostnameFromURI, } from './uri-utils.js'; @@ -335,7 +334,7 @@ export class ScriptletFilteringEngineEx extends ScriptletFilteringEngine { url: details.url, hostname, domain, - entity: entityFromDomain(domain), + ancestors: details.ancestors, }); if ( scriptletDetails === undefined ) { contentScriptRegisterer.unregister(hostname); diff --git a/src/js/static-filtering-parser.js b/src/js/static-filtering-parser.js index a5d8b3b57..4b649d7d9 100644 --- a/src/js/static-filtering-parser.js +++ b/src/js/static-filtering-parser.js @@ -335,6 +335,27 @@ export const nodeNameFromNodeType = new Map([ /******************************************************************************/ +// Local constants + +const DOMAIN_CAN_USE_WILDCARD = 0b000001; +const DOMAIN_CAN_USE_ENTITY = 0b000010; +const DOMAIN_CAN_USE_SINGLE_WILDCARD = 0b000100; +const DOMAIN_CAN_BE_NEGATED = 0b001000; +const DOMAIN_CAN_BE_REGEX = 0b010000; +const DOMAIN_CAN_BE_ANCESTOR = 0b100000; + +const DOMAIN_FROM_FROMTO_LIST = DOMAIN_CAN_USE_ENTITY | + DOMAIN_CAN_BE_NEGATED | + DOMAIN_CAN_BE_REGEX; +const DOMAIN_FROM_DENYALLOW_LIST = 0; +const DOMAIN_FROM_EXT_LIST = DOMAIN_CAN_USE_ENTITY | + DOMAIN_CAN_USE_SINGLE_WILDCARD | + DOMAIN_CAN_BE_NEGATED | + DOMAIN_CAN_BE_REGEX | + DOMAIN_CAN_BE_ANCESTOR; + +/******************************************************************************/ + // Precomputed AST layouts for most common filters. const astTemplates = { @@ -1839,7 +1860,7 @@ export class AstFilterParser { const hn = match[0].replace(this.reHostnameLabel, s => { if ( this.reHasUnicodeChar.test(s) === false ) { return s; } if ( s.charCodeAt(0) === 0x2D /* - */ ) { s = '*' + s; } - return this.normalizeHostnameValue(s, 0b0001) || s; + return this.normalizeHostnameValue(s, DOMAIN_CAN_USE_WILDCARD) || s; }); normal = hn + normal.slice(match.index + match[0].length); } @@ -2018,11 +2039,11 @@ export class AstFilterParser { } switch ( nodeOptionType ) { case NODE_TYPE_NET_OPTION_NAME_DENYALLOW: - this.linkDown(next, this.parseDomainList(next, '|'), 0b00000); + this.linkDown(next, this.parseDomainList(next, '|'), DOMAIN_FROM_DENYALLOW_LIST); break; case NODE_TYPE_NET_OPTION_NAME_FROM: case NODE_TYPE_NET_OPTION_NAME_TO: - this.linkDown(next, this.parseDomainList(next, '|', 0b11010)); + this.linkDown(next, this.parseDomainList(next, '|', DOMAIN_FROM_FROMTO_LIST)); break; default: break; @@ -2054,7 +2075,7 @@ export class AstFilterParser { return this.getNodeTransform(valueNode); } - parseDomainList(parent, separator, mode = 0b00000) { + parseDomainList(parent, separator, mode = 0) { const parentBeg = this.nodes[parent+NODE_BEG_INDEX]; const parentEnd = this.nodes[parent+NODE_END_INDEX]; const containerNode = this.allocTypedNode( @@ -2128,7 +2149,7 @@ export class AstFilterParser { if ( not ) { this.addNodeFlags(parent, NODE_FLAG_IS_NEGATED); head = this.allocTypedNode(NODE_TYPE_OPTION_VALUE_NOT, beg, beg + 1); - if ( (parseDetails.mode & 0b1000) === 0 ) { + if ( (parseDetails.mode & DOMAIN_CAN_BE_NEGATED) === 0 ) { this.addNodeFlags(parent, NODE_FLAG_ERROR); } beg += 1; @@ -2173,23 +2194,29 @@ export class AstFilterParser { parseDetails.len = end - parentBeg; } - // mode bits: - // 0b00001: can use wildcard at any position - // 0b00010: can use entity-based hostnames - // 0b00100: can use single wildcard - // 0b01000: can be negated - // 0b10000: can be a regex normalizeDomainValue(node, type, modeBits) { - const s = this.getNodeString(node); - if ( type === 0 ) { - return this.normalizeHostnameValue(s, modeBits); + const raw = this.getNodeString(node); + const isAncestor = raw.endsWith('>>'); + if ( isAncestor ) { + if ( (modeBits & DOMAIN_CAN_BE_ANCESTOR) === 0 ) { return ''; } } - if ( (modeBits & 0b10000) === 0 ) { return ''; } - const regex = type === 1 ? s : `/${s.slice(10, -2)}/`; - const source = this.normalizeRegexPattern(regex); - if ( source === '' ) { return ''; } - if ( type === 1 && source === regex ) { return; } - return `/${source}/`; + const before = isAncestor ? raw.slice(0, -2) : raw; + let after; + if ( type === 0 ) { + after = this.normalizeHostnameValue(before, modeBits) ?? before; + if ( after === '' ) { return ''; } + } else { + if ( (modeBits & DOMAIN_CAN_BE_REGEX) === 0 ) { return ''; } + const regex = type === 1 ? before : `/${before.slice(10, -2)}/`; + const source = this.normalizeRegexPattern(regex); + if ( source === '' ) { return ''; } + after = type === 2 || source !== regex ? `/${source}/` : before; + } + if ( isAncestor ) { + after = `${after}>>`; + } + if ( after === raw ) { return; } + return after; } parseExt(parent, anchorBeg, anchorLen) { @@ -2207,7 +2234,8 @@ export class AstFilterParser { ); this.addFlags(AST_FLAG_HAS_OPTIONS); this.addNodeToRegister(NODE_TYPE_EXT_OPTIONS, next); - this.linkDown(next, this.parseDomainList(next, ',', 0b11110)); + const down = this.parseDomainList(next, ',', DOMAIN_FROM_EXT_LIST); + this.linkDown(next, down); prev = this.linkRight(prev, next); } next = this.allocTypedNode( @@ -2800,17 +2828,11 @@ export class AstFilterParser { // Ultimately, let the browser API do the hostname normalization, after // making some other trivial checks. // - // mode bits: - // 0b00001: can use wildcard at any position - // 0b00010: can use entity-based hostnames - // 0b00100: can use single wildcard - // 0b01000: can be negated - // // returns: // undefined: no normalization needed, use original hostname // empty string: hostname is invalid // non-empty string: normalized hostname - normalizeHostnameValue(s, modeBits = 0b00000) { + normalizeHostnameValue(s, modeBits = 0) { if ( this.reHostnameAscii.test(s) ) { return; } if ( this.reBadHostnameChars.test(s) ) { return ''; } let hn = s; @@ -2818,13 +2840,13 @@ export class AstFilterParser { if ( hasWildcard ) { if ( modeBits === 0 ) { return ''; } if ( hn.length === 1 ) { - if ( (modeBits & 0b0100) === 0 ) { return ''; } + if ( (modeBits & DOMAIN_CAN_USE_SINGLE_WILDCARD) === 0 ) { return ''; } return; } - if ( (modeBits & 0b0010) !== 0 ) { + if ( (modeBits & DOMAIN_CAN_USE_ENTITY) !== 0 ) { if ( this.rePlainEntity.test(hn) ) { return; } if ( this.reIsEntity.test(hn) === false ) { return ''; } - } else if ( (modeBits & 0b0001) === 0 ) { + } else if ( (modeBits & DOMAIN_CAN_USE_WILDCARD) === 0 ) { return ''; } hn = hn.replace(/\*/g, '__asterisk__'); @@ -2841,7 +2863,7 @@ export class AstFilterParser { hn = this.punycoder.hostname.replace(/__asterisk__/g, '*'); } if ( - (modeBits & 0b0001) === 0 && ( + (modeBits & DOMAIN_CAN_USE_WILDCARD) === 0 && ( hn.charCodeAt(0) === 0x2E /* . */ || exCharCodeAt(hn, -1) === 0x2E /* . */ ) diff --git a/src/js/tab.js b/src/js/tab.js index eeba9a528..1bf65912d 100644 --- a/src/js/tab.js +++ b/src/js/tab.js @@ -928,6 +928,7 @@ vAPI.Tabs = class extends vAPI.Tabs { if ( pageStore === null ) { return; } pageStore.setFrameURL(details); if ( pageStore.getNetFilteringSwitch() ) { + details.ancestors = pageStore.getFrameAncestorDetails(frameId); scriptletFilteringEngine.injectNow(details); } } diff --git a/src/js/traffic.js b/src/js/traffic.js index df3b09714..6e4b064b2 100644 --- a/src/js/traffic.js +++ b/src/js/traffic.js @@ -1256,6 +1256,7 @@ const webRequest = { const pageStore = µb.pageStoreFromTabId(details.tabId); if ( pageStore === null ) { return; } if ( pageStore.getNetFilteringSwitch() === false ) { return; } + details.ancestors = pageStore.getFrameAncestorDetails(details.frameId); scriptletFilteringEngine.injectNow(details); }, { diff --git a/src/js/uri-utils.js b/src/js/uri-utils.js index aa9dca934..aded30947 100644 --- a/src/js/uri-utils.js +++ b/src/js/uri-utils.js @@ -70,6 +70,15 @@ function entityFromDomain(domain) { return pos !== -1 ? domain.slice(0, pos) + '.*' : ''; } +function entityFromHostname(hostname, domain) { + if ( domain === undefined ) { + domain = domainFromHostname(hostname); + } + const entity = entityFromDomain(domain); + if ( entity === '' ) { return ''; } + return `${hostname.slice(0, -domain.length)}${entity}` +} + function hostnameFromURI(uri) { let match = reHostnameFromCommonURL.exec(uri); if ( match !== null ) { return match[0].slice(8, -1); } @@ -164,6 +173,7 @@ export { domainFromHostname, domainFromURI, entityFromDomain, + entityFromHostname, hostnameFromNetworkURL, hostnameFromURI, isNetworkURI,