summaryrefslogtreecommitdiffstats
path: root/toolkit/components/cookiebanners/CookieBannerChild.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/cookiebanners/CookieBannerChild.sys.mjs721
1 files changed, 721 insertions, 0 deletions
diff --git a/toolkit/components/cookiebanners/CookieBannerChild.sys.mjs b/toolkit/components/cookiebanners/CookieBannerChild.sys.mjs
new file mode 100644
index 0000000000..ad46ed35f5
--- /dev/null
+++ b/toolkit/components/cookiebanners/CookieBannerChild.sys.mjs
@@ -0,0 +1,721 @@
+/* -*- 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, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "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,
+ "observeTimeout",
+ "cookiebanners.bannerClicking.timeout",
+ 3000
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "testing",
+ "cookiebanners.bannerClicking.testing",
+ false
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "CookieBannerChild",
+ maxLogLevelPref: "cookiebanners.bannerClicking.logLevel",
+ });
+});
+
+export class CookieBannerChild extends JSWindowActorChild {
+ #clickRules;
+ #originalBannerDisplay = null;
+ #observerCleanUp;
+ #observerCleanUpTimer;
+ // Indicates whether the page "load" event occurred.
+ #didLoad = false;
+
+ // Used to keep track of click telemetry for the current window.
+ #telemetryStatus = {
+ currentStage: null,
+ success: false,
+ successStage: null,
+ failReason: null,
+ bannerVisibilityFail: false,
+ };
+ // For measuring the cookie banner handling duration.
+ #gleanBannerHandlingTimer = null;
+
+ 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 (!lazy.bannerClickingEnabled) {
+ return false;
+ }
+ if (this.#isPrivateBrowsing) {
+ return lazy.serviceModePBM != Ci.nsICookieBannerService.MODE_DISABLED;
+ }
+ return lazy.serviceMode != Ci.nsICookieBannerService.MODE_DISABLED;
+ }
+
+ /**
+ * 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) {
+ 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;
+ this.#telemetryStatus.currentStage = "dom_content_loaded";
+
+ 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 {
+ rules = await this.sendQuery("CookieBanner::GetClickRules", {});
+ } 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.
+ let dispatchedEvents = this.#dispatchEventsForBannerHandledByInjection();
+ // Record telemetry about handling the banner via cookie injection.
+ // Note: The success state recorded here may be invalid if the given
+ // cookie fails to handle the banner. Since we don't have a presence
+ // detector for this rule we can't determine whether the banner is still
+ // showing or not.
+ if (dispatchedEvents) {
+ this.#telemetryStatus.failReason = null;
+ this.#telemetryStatus.success = true;
+ this.#telemetryStatus.successStage = "cookie_injected";
+ }
+
+ this.#maybeSendTestMessage();
+ return;
+ }
+
+ this.#clickRules = rules;
+
+ if (!this.#isDetectOnly) {
+ // Start a timer to measure how long it takes for the banner to appear and
+ // be handled.
+ this.#gleanBannerHandlingTimer =
+ Glean.cookieBannersClick.handleDuration.start();
+ }
+
+ let { bannerHandled, bannerDetected, matchedRule } =
+ await this.handleCookieBanner();
+
+ let dispatchedEventsForCookieInjection =
+ this.#dispatchEventsForBannerHandledByInjection();
+ // A cookie injection followed by not detecting the banner via querySelector
+ // is a success state. Record that in telemetry.
+ // Note: The success state reported may be invalid in edge cases where both
+ // the cookie injection and the banner detection via query selector fails.
+ if (dispatchedEventsForCookieInjection && !bannerDetected) {
+ this.#telemetryStatus.success = true;
+ this.#telemetryStatus.successStage = "cookie_injected";
+ }
+
+ // 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,
+ rule: matchedRule,
+ });
+
+ // Stop the timer to record how long it took to handle the banner.
+ lazy.logConsole.debug(
+ "Telemetry timer: stop and accumulate",
+ this.#gleanBannerHandlingTimer
+ );
+ Glean.cookieBannersClick.handleDuration.stopAndAccumulate(
+ this.#gleanBannerHandlingTimer
+ );
+
+ // Avoid dispatching a duplicate "cookiebannerhandled" event.
+ if (!dispatchedEventsForCookieInjection) {
+ this.sendAsyncMessage("CookieBanner::HandledBanner");
+ }
+ } else if (!this.#isDetectOnly) {
+ // Cancel the timer we didn't handle the banner.
+ Glean.cookieBannersClick.handleDuration.cancel(
+ this.#gleanBannerHandlingTimer
+ );
+ }
+
+ 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.#observerCleanUp,
+ observerCleanupTimer: this.#observerCleanUpTimer,
+ });
+
+ // Update stage for click telemetry.
+ if (!this.#telemetryStatus.success) {
+ this.#telemetryStatus.currentStage = "mutation_post_load";
+ }
+
+ this.#startObserverCleanupTimer();
+ }
+
+ /**
+ * If there is an active mutation observer, start a timeout to unregister it.
+ */
+ #startObserverCleanupTimer() {
+ // 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.
+ if (!this.#observerCleanUp || this.#observerCleanUpTimer) {
+ return;
+ }
+ lazy.logConsole.debug("Starting MutationObserver cleanup timeout");
+ this.#observerCleanUpTimer = lazy.setTimeout(() => {
+ lazy.logConsole.debug(
+ `MutationObserver timeout after ${lazy.observeTimeout}ms.`
+ );
+ this.#observerCleanUp();
+ }, lazy.observeTimeout);
+ }
+
+ didDestroy() {
+ this.#reportTelemetry();
+
+ // Clean up the observer and timer if needed.
+ this.#observerCleanUp?.();
+ }
+
+ #reportTelemetry() {
+ // Nothing to report, banner handling didn't run.
+ if (
+ this.#telemetryStatus.successStage == null &&
+ this.#telemetryStatus.failReason == null
+ ) {
+ lazy.logConsole.debug(
+ "Skip telemetry",
+ this.#telemetryStatus,
+ this.#clickRules
+ );
+ return;
+ }
+
+ let { success, successStage, currentStage, failReason } =
+ this.#telemetryStatus;
+
+ // Check if we got interrupted during an observe.
+ if (this.#observerCleanUp && !success) {
+ failReason = "actor_destroyed";
+ }
+
+ let status, reason;
+ if (success) {
+ status = "success";
+ reason = successStage;
+ } else {
+ status = "fail";
+ reason = failReason;
+ }
+
+ // Increment general success or failure counter.
+ Glean.cookieBannersClick.result[status].add(1);
+ // Increment reason counters.
+ if (reason) {
+ Glean.cookieBannersClick.result[`${status}_${reason}`].add(1);
+ } else {
+ lazy.logConsole.debug(
+ "Could not determine success / fail reason for telemetry."
+ );
+ }
+
+ lazy.logConsole.debug("Submitted clickResult telemetry", status, reason, {
+ success,
+ successStage,
+ currentStage,
+ failReason,
+ });
+ }
+
+ /**
+ * 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);
+
+ // First, we detect if the banner is shown on the page
+ let rules = await this.#detectBanner();
+
+ if (!rules.length) {
+ // The banner was never shown.
+ this.#telemetryStatus.success = false;
+ if (this.#telemetryStatus.bannerVisibilityFail) {
+ this.#telemetryStatus.failReason = "banner_not_visible";
+ } else {
+ this.#telemetryStatus.failReason = "banner_not_found";
+ }
+
+ 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)) {
+ this.#telemetryStatus.success = false;
+ this.#telemetryStatus.failReason = "no_rule_for_mode";
+ 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 };
+ }
+
+ // Hide the banner.
+ let matchedRule = this.#hideBanner(rules);
+
+ let successClick = false;
+ try {
+ successClick = await this.#clickTarget(rules);
+ } finally {
+ if (!successClick) {
+ // We cannot successfully click the target button. Show the banner on
+ // the page so that user can interact with the banner.
+ this.#showBanner(matchedRule);
+ }
+ }
+
+ if (successClick) {
+ // For telemetry, Keep track of in which stage we successfully handled the banner.
+ this.#telemetryStatus.successStage = this.#telemetryStatus.currentStage;
+ } else {
+ this.#telemetryStatus.failReason = "button_not_found";
+ this.#telemetryStatus.successStage = null;
+ }
+ this.#telemetryStatus.success = successClick;
+
+ return { bannerHandled: successClick, bannerDetected: true, matchedRule };
+ }
+
+ /**
+ * 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.#observerCleanUp) {
+ throw new Error(
+ "The promiseObserve is called before previous one resolves."
+ );
+ }
+ lazy.logConsole.debug("#promiseObserve", { didLoad: this.#didLoad });
+
+ return new Promise(resolve => {
+ let win = this.contentWindow;
+
+ let observer = new win.MutationObserver(mutationList => {
+ lazy.logConsole.debug(
+ "#promiseObserve: Mutation observed",
+ mutationList
+ );
+
+ let result = checkFn?.();
+ if (result) {
+ cleanup(result, observer);
+ }
+ });
+
+ observer.observe(win.document.body, {
+ attributes: true,
+ subtree: true,
+ childList: true,
+ });
+
+ let cleanup = (result, observer) => {
+ lazy.logConsole.debug(
+ "#promiseObserve cleanup",
+ result,
+ observer,
+ this.#observerCleanUpTimer
+ );
+ if (observer) {
+ observer.disconnect();
+ observer = null;
+ }
+
+ if (this.#observerCleanUpTimer) {
+ lazy.clearTimeout(this.#observerCleanUpTimer);
+ }
+
+ this.#observerCleanUp = null;
+ resolve(result);
+ };
+
+ // The clean up function to clean unfinished observer and timer when the
+ // actor destroys.
+ this.#observerCleanUp = () => {
+ cleanup(null, observer);
+ };
+
+ // If we already observed a load event we can start the cleanup timer
+ // straight away.
+ // Otherwise wait for the load event via the #onLoad method.
+ if (this.#didLoad) {
+ this.#startObserverCleanupTimer();
+ }
+ });
+ }
+
+ // 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;
+ }
+
+ let isVisible = this.#isVisible(banner);
+ // Store visibility of banner element to keep track of why detection
+ // failed.
+ this.#telemetryStatus.bannerVisibilityFail = !isVisible;
+
+ return isVisible;
+ });
+
+ // 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
+ );
+ this.#telemetryStatus.currentStage = "mutation_pre_load";
+ rules = await this.#promiseObserve(presenceDetector, lazy.observeTimeout);
+ }
+
+ 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;
+ }, lazy.observeTimeout);
+
+ 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,
+ });
+ }
+
+ // The helper function to hide the banner. It will store the original display
+ // value of the banner, so it can be used to show the banner later if needed.
+ #hideBanner(rules) {
+ if (this.#originalBannerDisplay) {
+ // We've already hidden the banner.
+ return null;
+ }
+
+ let banner;
+ let rule;
+ for (let r of rules) {
+ banner = this.document.querySelector(r.hide);
+ if (banner) {
+ rule = r;
+ break;
+ }
+ }
+ // Failed to find banner el to hide.
+ if (!banner) {
+ lazy.logConsole.debug(
+ "Failed to find banner element to hide from rules.",
+ rules
+ );
+ return null;
+ }
+
+ lazy.logConsole.debug("Found banner element to hide from rules.", rules);
+
+ this.#originalBannerDisplay = banner.style.display;
+
+ // Change the display of the banner right before the style flush occurs to
+ // avoid the unnecessary sync reflow.
+ banner.ownerGlobal.requestAnimationFrame(() => {
+ banner.style.display = "none";
+ });
+
+ return rule;
+ }
+
+ // The helper function to show the banner by reverting the display of the
+ // banner to the original value.
+ #showBanner({ hide }) {
+ if (this.#originalBannerDisplay === null) {
+ // We've never hidden the banner.
+ return;
+ }
+ let banner = this.document.querySelector(hide);
+
+ // Banner no longer present or destroyed or content window has been
+ // destroyed.
+ if (!banner || Cu.isDeadWrapper(banner) || !banner.ownerGlobal) {
+ return;
+ }
+
+ let originalDisplay = this.#originalBannerDisplay;
+ this.#originalBannerDisplay = null;
+
+ // Change the display of the banner right before the style flush occurs to
+ // avoid the unnecessary sync reflow.
+ banner.ownerGlobal.requestAnimationFrame(() => {
+ banner.style.display = originalDisplay;
+ });
+ }
+
+ #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);
+ });
+ }
+ }
+}