/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { setTimeout: "resource://gre/modules/Timer.sys.mjs", clearTimeout: "resource://gre/modules/Timer.sys.mjs", setInterval: "resource://gre/modules/Timer.sys.mjs", clearInterval: "resource://gre/modules/Timer.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "serviceMode", "cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_DISABLED ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "serviceModePBM", "cookiebanners.service.mode.privateBrowsing", Ci.nsICookieBannerService.MODE_DISABLED ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "prefDetectOnly", "cookiebanners.service.detectOnly", false ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "bannerClickingEnabled", "cookiebanners.bannerClicking.enabled", false ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "cleanupTimeoutAfterLoad", "cookiebanners.bannerClicking.timeoutAfterLoad" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "cleanupTimeoutAfterDOMContentLoaded", "cookiebanners.bannerClicking.timeoutAfterDOMContentLoaded" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "pollingInterval", "cookiebanners.bannerClicking.pollingInterval", 500 ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "testing", "cookiebanners.bannerClicking.testing", false ); ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { return console.createInstance({ prefix: "CookieBannerChild", maxLogLevelPref: "cookiebanners.bannerClicking.logLevel", }); }); export class CookieBannerChild extends JSWindowActorChild { // Caches the enabled state to ensure we only compute it once for the lifetime // of the actor. Particularly the private browsing check can be expensive. #isEnabledCached = null; #clickRules; #abortController = new AbortController(); #timeoutSignalController = new AbortController(); #timeoutTimerID; #hasActiveObserver = false; // Indicates whether the page "load" event occurred. #didLoad = false; // Indicates whether we should stop running the cookie banner handling // mechanism because it has been previously executed for the site. So, we can // cool down the cookie banner handing to improve performance. #isCooledDownInSession = false; handleEvent(event) { if (!this.#isEnabled) { // Automated tests may still expect the test message to be sent. this.#maybeSendTestMessage(); return; } switch (event.type) { case "DOMContentLoaded": this.#onDOMContentLoaded(); break; case "load": this.#onLoad(); break; default: lazy.logConsole.warn(`Unexpected event ${event.type}.`, event); } } get #isPrivateBrowsing() { return lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow); } /** * Whether the feature is enabled based on pref state. * @type {boolean} true if feature is enabled, false otherwise. */ get #isEnabled() { if (this.#isEnabledCached != null) { return this.#isEnabledCached; } let checkIsEnabled = () => { if (!lazy.bannerClickingEnabled) { return false; } if (this.#isPrivateBrowsing) { return lazy.serviceModePBM != Ci.nsICookieBannerService.MODE_DISABLED; } return lazy.serviceMode != Ci.nsICookieBannerService.MODE_DISABLED; }; this.#isEnabledCached = checkIsEnabled(); return this.#isEnabledCached; } /** * Whether the feature is enabled in detect-only-mode where cookie banner * detection events are dispatched, but banners aren't handled. * @type {boolean} true if feature mode is enabled, false otherwise. */ get #isDetectOnly() { // We can't be in detect-only-mode if fully disabled. if (!this.#isEnabled) { return false; } return lazy.prefDetectOnly; } /** * @returns {boolean} Whether we handled a banner for the current load by * injecting cookies. */ get #hasInjectedCookieForCookieBannerHandling() { return this.docShell?.currentDocumentChannel?.loadInfo ?.hasInjectedCookieForCookieBannerHandling; } /** * Checks whether we handled a banner for this site by injecting cookies and * dispatches events. * @returns {boolean} Whether we handled the banner and dispatched events. */ #dispatchEventsForBannerHandledByInjection() { if ( !this.#hasInjectedCookieForCookieBannerHandling || this.#isCooledDownInSession ) { return false; } // Strictly speaking we don't actively detect a banner when we handle it by // cookie injection. We still dispatch "cookiebannerdetected" in this case // for consistency. this.sendAsyncMessage("CookieBanner::DetectedBanner"); this.sendAsyncMessage("CookieBanner::HandledBanner"); return true; } /** * Handler for DOMContentLoaded events which is the entry point for cookie * banner handling. */ async #onDOMContentLoaded() { lazy.logConsole.debug("onDOMContentLoaded", { didLoad: this.#didLoad }); this.#didLoad = false; let principal = this.document?.nodePrincipal; // We only apply banner auto-clicking if the document has a content // principal. if (!principal?.isContentPrincipal) { return; } // We don't need to do auto-clicking if it's not a http/https page. if (!principal.schemeIs("http") && !principal.schemeIs("https")) { return; } lazy.logConsole.debug("Send message to get rule", { baseDomain: principal.baseDomain, isTopLevel: this.browsingContext == this.browsingContext?.top, }); let rules; try { let data = await this.sendQuery("CookieBanner::GetClickRules", {}); rules = data.rules; // Set we are cooling down for this session if the cookie banner handling // has been executed previously. this.#isCooledDownInSession = data.hasExecuted; } catch (e) { lazy.logConsole.warn("Failed to get click rule from parent.", e); return; } lazy.logConsole.debug("Got rules:", rules); // We can stop here if we don't have a rule. if (!rules.length) { // If the cookie injector has handled the banner and there are no click // rules we still need to dispatch a "cookiebannerhandled" event. this.#dispatchEventsForBannerHandledByInjection(); this.#maybeSendTestMessage(); return; } this.#clickRules = rules; let bannerHandled, bannerDetected, matchedRules; try { ({ bannerHandled, bannerDetected, matchedRules } = await this.handleCookieBanner()); } catch (e) { if (DOMException.isInstance(e) && e.name === "AbortError") { lazy.logConsole.debug("handleCookieBanner() has aborted"); return; } throw e; } // Send a message to mark that the cookie banner handling has been executed. this.sendAsyncMessage("CookieBanner::MarkSiteExecuted"); let dispatchedEventsForCookieInjection = this.#dispatchEventsForBannerHandledByInjection(); // 1. Detected event. if (bannerDetected) { lazy.logConsole.info("Detected cookie banner.", { url: this.document.location.href, }); // Avoid dispatching a duplicate "cookiebannerdetected" event. if (!dispatchedEventsForCookieInjection) { this.sendAsyncMessage("CookieBanner::DetectedBanner"); } } // 2. Handled event. if (bannerHandled) { lazy.logConsole.info("Handled cookie banner.", { url: this.document.location.href, matchedRules, }); // Avoid dispatching a duplicate "cookiebannerhandled" event. if (!dispatchedEventsForCookieInjection) { this.sendAsyncMessage("CookieBanner::HandledBanner"); } } this.#maybeSendTestMessage(); } /** * Handler for "load" events. Used as a signal to stop observing the DOM for * cookie banners after a timeout. */ #onLoad() { this.#didLoad = true; // Exit early if we are not handling banners for this site. if (!this.#clickRules?.length) { return; } lazy.logConsole.debug("Observed 'load' event", { href: this.document.location.href, hasActiveObserver: this.#hasActiveObserver, observerCleanupTimer: this.#timeoutTimerID, }); // On load reset the timer for cleanup. this.#startOrResetCleanupTimer(); } /** * We limit how long we observe cookie banner mutations for performance * reasons. If not present initially on DOMContentLoaded, cookie banners are * expected to show up during or shortly after page load. * This method starts a cleanup timeout which duration depends on the current * load stage (DOMContentLoaded, or load). When called, if a timeout is * already running, it is cancelled and a new timeout is scheduled. */ #startOrResetCleanupTimer() { // Cancel any already running timeout so we can schedule a new one. if (this.#timeoutTimerID) { lazy.logConsole.debug( "#startOrResetCleanupTimer: Cancelling existing cleanup timeout", { didLoad: this.#didLoad, id: this.#timeoutTimerID, } ); lazy.clearTimeout(this.#timeoutTimerID); this.#timeoutTimerID = null; } let durationMS = this.#didLoad ? lazy.cleanupTimeoutAfterLoad : lazy.cleanupTimeoutAfterDOMContentLoaded; lazy.logConsole.debug( "#startOrResetCleanupTimer: Starting cleanup timeout", { durationMS, didLoad: this.#didLoad, } ); this.#timeoutTimerID = lazy.setTimeout(() => { lazy.logConsole.debug( "#startOrResetCleanupTimer: Cleanup timeout triggered", { durationMS, didLoad: this.#didLoad, } ); this.#timeoutTimerID = null; this.#timeoutSignalController.abort(); }, durationMS); } didDestroy() { lazy.logConsole.debug("didDestroy() called"); // Clean up the observer and polling function. this.#abortController.abort(); lazy.clearTimeout(this.#timeoutTimerID); this.#timeoutTimerID = null; } /** * The function to perform the core logic of handing the cookie banner. It * will detect the banner and click the banner button whenever possible * according to the given click rules. * If the service mode pref is set to detect only mode we will only attempt to * find the cookie banner element and return early. * * @returns A promise which resolves when it finishes auto clicking. */ async handleCookieBanner() { lazy.logConsole.debug("handleCookieBanner", this.document.location.href); // Start timer to clean up detection code (polling and mutation observers). this.#startOrResetCleanupTimer(); // First, we detect if the banner is shown on the page let rules = await this.#detectBanner(); if (!rules.length) { // The banner was never shown. return { bannerHandled: false, bannerDetected: false }; } // No rule with valid button to click. This can happen if we're in // MODE_REJECT and there are only opt-in buttons available. // This also applies when detect-only mode is enabled. We only want to // dispatch events matching the current service mode. if (rules.every(rule => rule.target == null)) { return { bannerHandled: false, bannerDetected: false }; } // If the cookie banner prefs only enable detection but not handling we're done here. if (this.#isDetectOnly) { return { bannerHandled: false, bannerDetected: true }; } let successClick = false; successClick = await this.#clickTarget(rules); return { bannerHandled: successClick, bannerDetected: true, matchedRules: rules, }; } /** * The helper function to observe the changes on the document with a timeout. * It will call the check function when it observes mutations on the document * body. Once the check function returns a truthy value, it will resolve with * that value. Otherwise, it will resolve with null on timeout. * * @param {function} [checkFn] - The check function. * @returns {Promise} - A promise which resolves with the return value of the * check function or null if the function times out. */ #promiseObserve(checkFn) { if (this.#hasActiveObserver) { throw new Error( "The promiseObserve is called before previous one resolves." ); } this.#hasActiveObserver = true; return new Promise((resolve, reject) => { if (this.#abortController.signal.aborted) { reject(this.#abortController.signal.reason); return; } if (this.#timeoutSignalController.signal.aborted) { resolve(null); return; } let win = this.contentWindow; // Marks whether a mutation on the site has been observed since we last // ran checkFn. let sawMutation = false; // IDs for interval for checkFn polling. let pollIntervalId = null; // Keep track of DOM changes via MutationObserver. We only run query // selectors again if the DOM updated since our last check. let observer = new win.MutationObserver(() => { sawMutation = true; }); observer.observe(win.document.body, { attributes: true, subtree: true, childList: true, }); // Start polling checkFn. let intervalFn = () => { lazy.logConsole.debug( "#promiseObserve interval function", this.document.location.href ); if (this.#abortController.signal.aborted) { throw new Error( "The promiseObserve interval function is still running after banner detection has aborted." ); } if (this.#timeoutSignalController.signal.aborted) { throw new Error( "The promiseObserve interval function is still running after banner detection has timed out." ); } // Nothing changed since last run, skip running checkFn. if (!sawMutation) { return; } // Reset mutation flag. sawMutation = false; // A truthy result means we have a hit so we can stop observing. let result = checkFn?.(); if (result) { cleanUp(); resolve(result); } }; pollIntervalId = lazy.setInterval(intervalFn, lazy.pollingInterval); let cleanUp = () => { lazy.logConsole.debug("#promiseObserve cleanup", { observer, pollIntervalId, href: this.document.location?.href, }); // Unregister the observer. if (observer) { observer.disconnect(); observer = null; } // Stop the polling checks. if (pollIntervalId) { lazy.clearInterval(pollIntervalId); pollIntervalId = null; } this.#hasActiveObserver = false; this.#abortController.signal.removeEventListener( "abort", abortFunction ); this.#timeoutSignalController.signal.removeEventListener( "abort", timeoutFunction ); }; let abortFunction = () => { cleanUp(); reject(this.#abortController.signal.reason); }; this.#abortController.signal.addEventListener("abort", abortFunction); let timeoutFunction = () => { cleanUp(); resolve(null); }; this.#timeoutSignalController.signal.addEventListener( "abort", timeoutFunction ); }); } // Detecting if the banner is shown on the page. async #detectBanner() { if (!this.#clickRules?.length) { return []; } lazy.logConsole.debug("Starting to detect the banner"); // Returns an array of rules for which a cookie banner exists for the // current site. let presenceDetector = () => { lazy.logConsole.debug("presenceDetector start"); let matchingRules = this.#clickRules.filter(rule => { let { presence, skipPresenceVisibilityCheck } = rule; let banner = this.document.querySelector(presence); lazy.logConsole.debug("Testing banner el presence", { result: banner, rule, presence, }); if (!banner) { return false; } if (skipPresenceVisibilityCheck) { return true; } return this.#isVisible(banner); }); // For no rules matched return null explicitly so #promiseObserve knows we // want to keep observing. if (!matchingRules.length) { return null; } return matchingRules; }; lazy.logConsole.debug("Initial call to presenceDetector"); let rules = presenceDetector(); // If we couldn't detect the banner at the beginning, we register an // observer with the timeout to observe if the banner was shown within the // timeout. if (!rules?.length) { lazy.logConsole.debug( "Initial presenceDetector failed, registering MutationObserver", rules ); rules = await this.#promiseObserve(presenceDetector); } if (!rules?.length) { lazy.logConsole.debug("Couldn't detect the banner", rules); return []; } lazy.logConsole.debug("Detected the banner for rules", rules); return rules; } // Clicking the target button. async #clickTarget(rules) { lazy.logConsole.debug("Starting to detect the target button"); let targetEl; for (let rule of rules) { targetEl = this.document.querySelector(rule.target); if (targetEl) { break; } } // The target button is not available. We register an observer to wait until // it's ready. if (!targetEl) { targetEl = await this.#promiseObserve(() => { for (let rule of rules) { let el = this.document.querySelector(rule.target); lazy.logConsole.debug("Testing button el presence", { result: el, rule, target: rule.target, }); if (el) { lazy.logConsole.debug( "Found button from rule", rule, rule.target, el ); return el; } } return null; }); if (!targetEl) { lazy.logConsole.debug("Cannot find the target button."); return false; } } lazy.logConsole.debug("Found the target button, click it.", targetEl); targetEl.click(); return true; } // The helper function to check if the given element if visible. #isVisible(element) { return element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, }); } #maybeSendTestMessage() { if (lazy.testing) { let win = this.contentWindow; // Report the clicking is finished after the style has been flushed. win.requestAnimationFrame(() => { win.setTimeout(() => { this.sendAsyncMessage("CookieBanner::Test-FinishClicking"); }, 0); }); } } }