summaryrefslogtreecommitdiffstats
path: root/toolkit/components/cookiebanners
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/cookiebanners
parentInitial commit. (diff)
downloadthunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz
thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/cookiebanners')
-rw-r--r--toolkit/components/cookiebanners/CookieBannerChild.sys.mjs721
-rw-r--r--toolkit/components/cookiebanners/CookieBannerDomainPrefService.cpp484
-rw-r--r--toolkit/components/cookiebanners/CookieBannerDomainPrefService.h154
-rw-r--r--toolkit/components/cookiebanners/CookieBannerListService.sys.mjs357
-rw-r--r--toolkit/components/cookiebanners/CookieBannerParent.sys.mjs176
-rw-r--r--toolkit/components/cookiebanners/components.conf39
-rw-r--r--toolkit/components/cookiebanners/jar.mn6
-rw-r--r--toolkit/components/cookiebanners/metrics.yaml239
-rw-r--r--toolkit/components/cookiebanners/moz.build62
-rw-r--r--toolkit/components/cookiebanners/nsClickRule.cpp57
-rw-r--r--toolkit/components/cookiebanners/nsClickRule.h42
-rw-r--r--toolkit/components/cookiebanners/nsCookieBannerRule.cpp176
-rw-r--r--toolkit/components/cookiebanners/nsCookieBannerRule.h43
-rw-r--r--toolkit/components/cookiebanners/nsCookieBannerService.cpp1289
-rw-r--r--toolkit/components/cookiebanners/nsCookieBannerService.h141
-rw-r--r--toolkit/components/cookiebanners/nsCookieInjector.cpp338
-rw-r--r--toolkit/components/cookiebanners/nsCookieInjector.h52
-rw-r--r--toolkit/components/cookiebanners/nsCookieRule.cpp101
-rw-r--r--toolkit/components/cookiebanners/nsCookieRule.h41
-rw-r--r--toolkit/components/cookiebanners/nsIClickRule.idl64
-rw-r--r--toolkit/components/cookiebanners/nsICookieBannerListService.idl37
-rw-r--r--toolkit/components/cookiebanners/nsICookieBannerRule.idl92
-rw-r--r--toolkit/components/cookiebanners/nsICookieBannerService.idl135
-rw-r--r--toolkit/components/cookiebanners/nsICookieRule.idl45
-rw-r--r--toolkit/components/cookiebanners/schema/CookieBannerRule.schema.json158
-rw-r--r--toolkit/components/cookiebanners/schema/CookieBannerRuleUI.schema.json22
-rw-r--r--toolkit/components/cookiebanners/schema/README3
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser.ini36
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking.js439
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_domainPref.js166
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_events.js90
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_globalRules.js216
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_runContext.js97
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_slowLoad.js37
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_bannerClicking_visibilityOverride.js81
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebanner_telemetry.js766
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice.js637
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_domainPrefs.js403
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_getRules.js88
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_hasRuleForBCTree.js293
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_prefs.js213
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookieinjector.js625
-rw-r--r--toolkit/components/cookiebanners/test/browser/browser_cookieinjector_events.js56
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_banner.html22
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_banner_b.html22
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_banner_invisible.html22
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_delayed_banner.html48
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_delayed_banner_load.html43
-rw-r--r--toolkit/components/cookiebanners/test/browser/file_iframe_banner.html8
-rw-r--r--toolkit/components/cookiebanners/test/browser/head.js605
-rw-r--r--toolkit/components/cookiebanners/test/browser/slowSubresource.sjs18
-rw-r--r--toolkit/components/cookiebanners/test/browser/testCookieHeader.sjs5
-rw-r--r--toolkit/components/cookiebanners/test/unit/test_cookiebannerlistservice.js611
-rw-r--r--toolkit/components/cookiebanners/test/unit/xpcshell.ini2
54 files changed, 10723 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);
+ });
+ }
+ }
+}
diff --git a/toolkit/components/cookiebanners/CookieBannerDomainPrefService.cpp b/toolkit/components/cookiebanners/CookieBannerDomainPrefService.cpp
new file mode 100644
index 0000000000..4b4912f1a1
--- /dev/null
+++ b/toolkit/components/cookiebanners/CookieBannerDomainPrefService.cpp
@@ -0,0 +1,484 @@
+/* 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/. */
+
+#include "CookieBannerDomainPrefService.h"
+
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/DebugOnly.h"
+#include "mozilla/Logging.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/Services.h"
+#include "mozilla/SpinEventLoopUntil.h"
+#include "mozilla/StaticPtr.h"
+
+#include "nsIContentPrefService2.h"
+#include "nsICookieBannerService.h"
+#include "nsIObserverService.h"
+#include "nsServiceManagerUtils.h"
+#include "nsVariant.h"
+
+#define COOKIE_BANNER_CONTENT_PREF_NAME u"cookiebanner"_ns
+#define COOKIE_BANNER_CONTENT_PREF_NAME_PRIVATE u"cookiebannerprivate"_ns
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(CookieBannerDomainPrefService, nsIAsyncShutdownBlocker,
+ nsIObserver)
+
+NS_IMPL_ISUPPORTS(CookieBannerDomainPrefService::DomainPrefData, nsISupports)
+
+LazyLogModule gCookieBannerPerSitePrefLog("CookieBannerDomainPref");
+
+static StaticRefPtr<CookieBannerDomainPrefService>
+ sCookieBannerDomainPrefService;
+
+namespace {
+
+// A helper function to get the profile-before-change shutdown barrier.
+
+nsCOMPtr<nsIAsyncShutdownClient> GetShutdownBarrier() {
+ nsCOMPtr<nsIAsyncShutdownService> svc = services::GetAsyncShutdownService();
+ NS_ENSURE_TRUE(svc, nullptr);
+
+ nsCOMPtr<nsIAsyncShutdownClient> barrier;
+ nsresult rv = svc->GetProfileBeforeChange(getter_AddRefs(barrier));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+
+ return barrier;
+}
+
+} // anonymous namespace
+
+/* static */
+already_AddRefed<CookieBannerDomainPrefService>
+CookieBannerDomainPrefService::GetOrCreate() {
+ if (!sCookieBannerDomainPrefService) {
+ sCookieBannerDomainPrefService = new CookieBannerDomainPrefService();
+
+ RunOnShutdown([] {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Debug, ("RunOnShutdown."));
+
+ sCookieBannerDomainPrefService->Shutdown();
+
+ sCookieBannerDomainPrefService = nullptr;
+ });
+ }
+
+ return do_AddRef(sCookieBannerDomainPrefService);
+}
+
+void CookieBannerDomainPrefService::Init() {
+ // Make sure we won't init again.
+ if (mIsInitialized) {
+ return;
+ }
+
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+
+ if (!contentPrefService) {
+ return;
+ }
+
+ nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+
+ if (!obs) {
+ return;
+ }
+
+ // Register the observer to watch private browsing session ends. We will clean
+ // the private domain prefs when this happens.
+ nsresult rv = obs->AddObserver(this, "last-pb-context-exited", false);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to add observer for 'last-pb-context-exited'.");
+
+ auto initCallback = MakeRefPtr<InitialLoadContentPrefCallback>(this, false);
+
+ // Populate the content pref for cookie banner domain preferences.
+ rv = contentPrefService->GetByName(COOKIE_BANNER_CONTENT_PREF_NAME, nullptr,
+ initCallback);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to get all content prefs during init.");
+
+ auto initPrivateCallback =
+ MakeRefPtr<InitialLoadContentPrefCallback>(this, true);
+
+ // Populate the content pref for the private browsing.
+ rv = contentPrefService->GetByName(COOKIE_BANNER_CONTENT_PREF_NAME_PRIVATE,
+ nullptr, initPrivateCallback);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to get all content prefs during init.");
+
+ rv = AddShutdownBlocker();
+ NS_ENSURE_SUCCESS_VOID(rv);
+
+ mIsInitialized = true;
+}
+
+void CookieBannerDomainPrefService::Shutdown() {
+ // Bail out early if the service never gets initialized.
+ if (!mIsInitialized) {
+ return;
+ }
+
+ nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+
+ if (!obs) {
+ return;
+ }
+
+ DebugOnly<nsresult> rv = obs->RemoveObserver(this, "last-pb-context-exited");
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to remove observer for 'last-pb-context-exited'.");
+}
+
+Maybe<nsICookieBannerService::Modes> CookieBannerDomainPrefService::GetPref(
+ const nsACString& aDomain, bool aIsPrivate) {
+ bool isContentPrefLoaded =
+ aIsPrivate ? mIsPrivateContentPrefLoaded : mIsContentPrefLoaded;
+
+ // We return nothing if the first reading of the content pref is not completed
+ // yet. Note that, we won't be able to get the domain pref for early loads.
+ // But, we think this is acceptable because the cookie banners on the early
+ // load tabs would have interacted before when the user disabled the banner
+ // handling. So, there should be consent cookies in place to prevent banner
+ // showing. In this case, our cookie injection and banner clicking won't do
+ // anything.
+ if (!isContentPrefLoaded) {
+ return Nothing();
+ }
+
+ Maybe<RefPtr<DomainPrefData>> data =
+ aIsPrivate ? mPrefsPrivate.MaybeGet(aDomain) : mPrefs.MaybeGet(aDomain);
+
+ if (!data) {
+ return Nothing();
+ }
+
+ return Some(data.ref()->mMode);
+}
+
+nsresult CookieBannerDomainPrefService::SetPref(
+ const nsACString& aDomain, nsICookieBannerService::Modes aMode,
+ bool aIsPrivate, bool aPersistInPrivateBrowsing) {
+ MOZ_ASSERT(NS_IsMainThread());
+ // Don't do anything if we are shutting down.
+ if (NS_WARN_IF(mIsShuttingDown)) {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Warning,
+ ("Attempt to set a domain pref while shutting down."));
+ return NS_OK;
+ }
+
+ EnsureInitCompleted(aIsPrivate);
+
+ // Create the domain pref data. The data is always persistent for normal
+ // windows. For private windows, the data is only persistent if requested.
+ auto domainPrefData = MakeRefPtr<DomainPrefData>(
+ aMode, aIsPrivate ? aPersistInPrivateBrowsing : true);
+ bool wasPersistentInPrivate = false;
+
+ // Update the in-memory domain preference map.
+ if (aIsPrivate) {
+ Maybe<RefPtr<DomainPrefData>> data = mPrefsPrivate.MaybeGet(aDomain);
+
+ wasPersistentInPrivate = data ? data.ref()->mIsPersistent : false;
+ Unused << mPrefsPrivate.InsertOrUpdate(aDomain, domainPrefData);
+ } else {
+ Unused << mPrefs.InsertOrUpdate(aDomain, domainPrefData);
+ }
+
+ // For private windows, the domain prefs will only be stored in memory.
+ // Unless, this function is instructed to persist setting for private
+ // browsing. To make the disk state consistent with the memory state, we need
+ // to clear the domain pref in the disk when we no longer need to persist the
+ // domain pref for the domain in PBM.
+ if (!aPersistInPrivateBrowsing && aIsPrivate) {
+ // Clear the domain pref in disk if it was persistent.
+ if (wasPersistentInPrivate) {
+ return RemoveContentPrefForDomain(aDomain, true);
+ }
+ return NS_OK;
+ }
+
+ // Set the preference to the content pref service.
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(contentPrefService, NS_ERROR_FAILURE);
+
+ RefPtr<nsVariant> variant = new nsVariant();
+ nsresult rv = variant->SetAsUint8(aMode);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ auto callback = MakeRefPtr<WriteContentPrefCallback>(this);
+ mWritingCount++;
+
+ // Store the domain preference to the content pref service.
+ rv = contentPrefService->Set(NS_ConvertUTF8toUTF16(aDomain),
+ aIsPrivate
+ ? COOKIE_BANNER_CONTENT_PREF_NAME_PRIVATE
+ : COOKIE_BANNER_CONTENT_PREF_NAME,
+ variant, nullptr, callback);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to set cookie banner domain pref.");
+
+ return rv;
+}
+
+nsresult CookieBannerDomainPrefService::RemovePref(const nsACString& aDomain,
+ bool aIsPrivate) {
+ MOZ_ASSERT(NS_IsMainThread());
+ // Don't do anything if we are shutting down.
+ if (NS_WARN_IF(mIsShuttingDown)) {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Warning,
+ ("Attempt to remove a domain pref while shutting down."));
+ return NS_OK;
+ }
+
+ EnsureInitCompleted(aIsPrivate);
+
+ // Clear in-memory domain pref.
+ if (aIsPrivate) {
+ mPrefsPrivate.Remove(aDomain);
+ } else {
+ mPrefs.Remove(aDomain);
+ }
+
+ return RemoveContentPrefForDomain(aDomain, aIsPrivate);
+}
+
+nsresult CookieBannerDomainPrefService::RemoveAll(bool aIsPrivate) {
+ MOZ_ASSERT(NS_IsMainThread());
+ // Don't do anything if we are shutting down.
+ if (NS_WARN_IF(mIsShuttingDown)) {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Warning,
+ ("Attempt to remove all domain prefs while shutting down."));
+ return NS_OK;
+ }
+
+ EnsureInitCompleted(aIsPrivate);
+
+ // Clear in-memory domain pref.
+ if (aIsPrivate) {
+ mPrefsPrivate.Clear();
+ } else {
+ mPrefs.Clear();
+ }
+
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(contentPrefService, NS_ERROR_FAILURE);
+
+ auto callback = MakeRefPtr<WriteContentPrefCallback>(this);
+ mWritingCount++;
+
+ // Remove all the domain preferences.
+ nsresult rv = contentPrefService->RemoveByName(
+ aIsPrivate ? COOKIE_BANNER_CONTENT_PREF_NAME_PRIVATE
+ : COOKIE_BANNER_CONTENT_PREF_NAME,
+ nullptr, callback);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to remove all cookie banner domain prefs.");
+
+ return rv;
+}
+
+void CookieBannerDomainPrefService::EnsureInitCompleted(bool aIsPrivate) {
+ bool& isContentPrefLoaded =
+ aIsPrivate ? mIsPrivateContentPrefLoaded : mIsContentPrefLoaded;
+ if (isContentPrefLoaded) {
+ return;
+ }
+
+ // Wait until the service is fully initialized.
+ SpinEventLoopUntil("CookieBannerDomainPrefService::EnsureUpdateComplete"_ns,
+ [&] { return isContentPrefLoaded; });
+}
+
+nsresult CookieBannerDomainPrefService::AddShutdownBlocker() {
+ MOZ_ASSERT(!mIsShuttingDown);
+
+ nsCOMPtr<nsIAsyncShutdownClient> barrier = GetShutdownBarrier();
+ NS_ENSURE_TRUE(barrier, NS_ERROR_FAILURE);
+
+ return GetShutdownBarrier()->AddBlocker(
+ this, NS_LITERAL_STRING_FROM_CSTRING(__FILE__), __LINE__,
+ u"CookieBannerDomainPrefService: shutdown"_ns);
+}
+
+nsresult CookieBannerDomainPrefService::RemoveShutdownBlocker() {
+ MOZ_ASSERT(mIsShuttingDown);
+
+ nsCOMPtr<nsIAsyncShutdownClient> barrier = GetShutdownBarrier();
+ NS_ENSURE_TRUE(barrier, NS_ERROR_FAILURE);
+
+ return GetShutdownBarrier()->RemoveBlocker(this);
+}
+
+nsresult CookieBannerDomainPrefService::RemoveContentPrefForDomain(
+ const nsACString& aDomain, bool aIsPrivate) {
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(contentPrefService, NS_ERROR_FAILURE);
+
+ auto callback = MakeRefPtr<WriteContentPrefCallback>(this);
+ mWritingCount++;
+
+ // Remove the domain preference from the content pref service.
+ nsresult rv = contentPrefService->RemoveByDomainAndName(
+ NS_ConvertUTF8toUTF16(aDomain),
+ aIsPrivate ? COOKIE_BANNER_CONTENT_PREF_NAME_PRIVATE
+ : COOKIE_BANNER_CONTENT_PREF_NAME,
+ nullptr, callback);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Fail to remove cookie banner domain pref.");
+ return rv;
+}
+
+NS_IMPL_ISUPPORTS(CookieBannerDomainPrefService::BaseContentPrefCallback,
+ nsIContentPrefCallback2)
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::InitialLoadContentPrefCallback::HandleResult(
+ nsIContentPref* aPref) {
+ NS_ENSURE_ARG_POINTER(aPref);
+ MOZ_ASSERT(mService);
+
+ nsAutoString domain;
+ nsresult rv = aPref->GetDomain(domain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIVariant> value;
+ rv = aPref->GetValue(getter_AddRefs(value));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!value) {
+ return NS_OK;
+ }
+
+ uint8_t data;
+ rv = value->GetAsUint8(&data);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Create the domain pref data and indicate it's persistent.
+ auto domainPrefData =
+ MakeRefPtr<DomainPrefData>(nsICookieBannerService::Modes(data), true);
+
+ if (mIsPrivate) {
+ Unused << mService->mPrefsPrivate.InsertOrUpdate(
+ NS_ConvertUTF16toUTF8(domain), domainPrefData);
+ } else {
+ Unused << mService->mPrefs.InsertOrUpdate(NS_ConvertUTF16toUTF8(domain),
+ domainPrefData);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::InitialLoadContentPrefCallback::HandleCompletion(
+ uint16_t aReason) {
+ MOZ_ASSERT(mService);
+
+ if (mIsPrivate) {
+ mService->mIsPrivateContentPrefLoaded = true;
+ } else {
+ mService->mIsContentPrefLoaded = true;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::InitialLoadContentPrefCallback::HandleError(
+ nsresult error) {
+ // We don't need to do anything here because HandleCompletion is always
+ // called.
+
+ if (NS_WARN_IF(NS_FAILED(error))) {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Warning,
+ ("Fail to get content pref during initiation."));
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::WriteContentPrefCallback::HandleResult(
+ nsIContentPref* aPref) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::WriteContentPrefCallback::HandleCompletion(
+ uint16_t aReason) {
+ MOZ_ASSERT(mService);
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(mService->mWritingCount > 0);
+
+ mService->mWritingCount--;
+
+ // Remove the shutdown blocker after we complete writing to content pref.
+ if (mService->mIsShuttingDown && mService->mWritingCount == 0) {
+ mService->RemoveShutdownBlocker();
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::WriteContentPrefCallback::HandleError(
+ nsresult error) {
+ if (NS_WARN_IF(NS_FAILED(error))) {
+ MOZ_LOG(gCookieBannerPerSitePrefLog, LogLevel::Warning,
+ ("Fail to write content pref."));
+ return NS_OK;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::Observe(nsISupports* /*aSubject*/,
+ const char* aTopic,
+ const char16_t* /*aData*/) {
+ if (strcmp(aTopic, "last-pb-context-exited") != 0) {
+ MOZ_ASSERT_UNREACHABLE("unexpected topic");
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ // Clear the private browsing domain prefs that are not persistent when we
+ // observe the private browsing session has ended.
+ mPrefsPrivate.RemoveIf([](const auto& iter) {
+ const RefPtr<DomainPrefData>& data = iter.Data();
+ return !data->mIsPersistent;
+ });
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::GetName(nsAString& aName) {
+ aName.AssignLiteral(
+ "CookieBannerDomainPrefService: write content pref before "
+ "profile-before-change.");
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::BlockShutdown(nsIAsyncShutdownClient*) {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ mIsShuttingDown = true;
+
+ // If we are not writing the content pref, we can remove the shutdown blocker
+ // directly.
+ if (mWritingCount == 0) {
+ RemoveShutdownBlocker();
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+CookieBannerDomainPrefService::GetState(nsIPropertyBag**) { return NS_OK; }
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/CookieBannerDomainPrefService.h b/toolkit/components/cookiebanners/CookieBannerDomainPrefService.h
new file mode 100644
index 0000000000..b8acbd36fc
--- /dev/null
+++ b/toolkit/components/cookiebanners/CookieBannerDomainPrefService.h
@@ -0,0 +1,154 @@
+/* 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/. */
+
+#ifndef mozilla_CookieBannerDomainPrefService_h__
+#define mozilla_CookieBannerDomainPrefService_h__
+
+#include "nsIContentPrefService2.h"
+
+#include "mozilla/Maybe.h"
+#include "nsStringFwd.h"
+#include "nsTHashMap.h"
+#include "nsTHashSet.h"
+
+#include "nsICookieBannerService.h"
+#include "nsIObserver.h"
+#include "nsIAsyncShutdown.h"
+
+namespace mozilla {
+
+// The service which maintains the per-domain cookie banner preference. It uses
+// the content pref to store the per-domain preference for cookie banner
+// handling. To support the synchronous access, the service caches the
+// preferences in the memory.
+class CookieBannerDomainPrefService final : public nsIAsyncShutdownBlocker,
+ public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIASYNCSHUTDOWNBLOCKER
+ NS_DECL_NSIOBSERVER
+
+ static already_AddRefed<CookieBannerDomainPrefService> GetOrCreate();
+
+ // Get the preference for the given domain.
+ Maybe<nsICookieBannerService::Modes> GetPref(const nsACString& aDomain,
+ bool aIsPrivate);
+
+ // Set the preference for the given domain.
+ [[nodiscard]] nsresult SetPref(const nsACString& aDomain,
+ nsICookieBannerService::Modes aMode,
+ bool aIsPrivate,
+ bool aPersistInPrivateBrowsing);
+
+ // Remove the preference for the given domain.
+ [[nodiscard]] nsresult RemovePref(const nsACString& aDomain, bool aIsPrivate);
+
+ // Remove all site preferences.
+ [[nodiscard]] nsresult RemoveAll(bool aIsPrivate);
+
+ void Init();
+
+ private:
+ ~CookieBannerDomainPrefService() = default;
+
+ CookieBannerDomainPrefService()
+ : mIsInitialized(false),
+ mIsContentPrefLoaded(false),
+ mIsPrivateContentPrefLoaded(false),
+ mIsShuttingDown(false) {}
+
+ // Indicates whether the service is initialized.
+ bool mIsInitialized;
+
+ // Indicates whether the first reading of content pref completed.
+ bool mIsContentPrefLoaded;
+
+ // Indicates whether the first reading of content pref for the private
+ // browsing completed.
+ bool mIsPrivateContentPrefLoaded;
+
+ // Indicates whether we are shutting down.
+ bool mIsShuttingDown;
+
+ // A class to represent the domain pref. It's consist of the service mode and
+ // a boolean to indicated if the domain pref persists in the disk.
+ class DomainPrefData final : public nsISupports {
+ public:
+ NS_DECL_ISUPPORTS
+
+ explicit DomainPrefData(nsICookieBannerService::Modes aMode,
+ bool aIsPersistent)
+ : mMode(aMode), mIsPersistent(aIsPersistent) {}
+
+ private:
+ ~DomainPrefData() = default;
+
+ friend class CookieBannerDomainPrefService;
+
+ nsICookieBannerService::Modes mMode;
+ bool mIsPersistent;
+ };
+
+ // Map of the per site preference keyed by domain.
+ nsTHashMap<nsCStringHashKey, RefPtr<DomainPrefData>> mPrefs;
+
+ // Map of the per site preference for private windows keyed by domain.
+ nsTHashMap<nsCStringHashKey, RefPtr<DomainPrefData>> mPrefsPrivate;
+
+ // A helper function that will wait until the initialization of the content
+ // pref completed.
+ void EnsureInitCompleted(bool aIsPrivate);
+
+ nsresult AddShutdownBlocker();
+ nsresult RemoveShutdownBlocker();
+
+ nsresult RemoveContentPrefForDomain(const nsACString& aDomain,
+ bool aIsPrivate);
+
+ class BaseContentPrefCallback : public nsIContentPrefCallback2 {
+ public:
+ NS_DECL_ISUPPORTS
+
+ NS_IMETHOD HandleResult(nsIContentPref*) override = 0;
+ NS_IMETHOD HandleCompletion(uint16_t) override = 0;
+ NS_IMETHOD HandleError(nsresult) override = 0;
+
+ explicit BaseContentPrefCallback(CookieBannerDomainPrefService* aService)
+ : mService(aService) {}
+
+ protected:
+ virtual ~BaseContentPrefCallback() = default;
+ RefPtr<CookieBannerDomainPrefService> mService;
+ };
+
+ class InitialLoadContentPrefCallback final : public BaseContentPrefCallback {
+ public:
+ NS_DECL_NSICONTENTPREFCALLBACK2
+
+ explicit InitialLoadContentPrefCallback(
+ CookieBannerDomainPrefService* aService, bool aIsPrivate)
+ : BaseContentPrefCallback(aService), mIsPrivate(aIsPrivate) {}
+
+ private:
+ bool mIsPrivate;
+ };
+
+ class WriteContentPrefCallback final : public BaseContentPrefCallback {
+ public:
+ NS_DECL_NSICONTENTPREFCALLBACK2
+
+ explicit WriteContentPrefCallback(CookieBannerDomainPrefService* aService)
+ : BaseContentPrefCallback(aService) {}
+ };
+
+ // A counter to track if there is any writing is happening. We will use this
+ // to decide if we can remove the shutdown blocker.
+ uint32_t mWritingCount = 0;
+
+ void Shutdown();
+};
+
+} // namespace mozilla
+
+#endif // mozilla_CookieBannerDomainPrefService_h__
diff --git a/toolkit/components/cookiebanners/CookieBannerListService.sys.mjs b/toolkit/components/cookiebanners/CookieBannerListService.sys.mjs
new file mode 100644
index 0000000000..abca9ab5b5
--- /dev/null
+++ b/toolkit/components/cookiebanners/CookieBannerListService.sys.mjs
@@ -0,0 +1,357 @@
+/* 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/. */
+
+const lazy = {};
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DEFAULT_EXPIRY_RELATIVE",
+ "cookiebanners.cookieInjector.defaultExpiryRelative"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "TEST_SKIP_REMOTE_SETTINGS",
+ "cookiebanners.listService.testSkipRemoteSettings"
+);
+
+const PREF_TEST_RULES = "cookiebanners.listService.testRules";
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "testRulesPref", PREF_TEST_RULES);
+
+// Name of the RemoteSettings collection containing the rules.
+const COLLECTION_NAME = "cookie-banner-rules-list";
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "CookieBannerListService",
+ maxLogLevelPref: "cookiebanners.listService.logLevel",
+ });
+});
+
+// Lazy getter for the JSON schema of cookie banner rules. It is used for
+// validation of rules defined by pref.
+XPCOMUtils.defineLazyGetter(lazy, "CookieBannerRuleSchema", async () => {
+ let response = await fetch(
+ "chrome://global/content/cookiebanners/CookieBannerRule.schema.json"
+ );
+ if (!response.ok) {
+ lazy.logConsole.error("Fetch for CookieBannerRuleSchema failed", response);
+ throw new Error("Failed to fetch CookieBannerRuleSchema.");
+ }
+ return response.json();
+});
+
+/**
+ * See nsICookieBannerListService
+ */
+export class CookieBannerListService {
+ classId = Components.ID("{1d8d9470-97d3-4885-a108-44a5c4fb36e2}");
+ QueryInterface = ChromeUtils.generateQI(["nsICookieBannerListService"]);
+
+ // RemoteSettings collection holding the cookie banner rules.
+ #rs = null;
+ // Stores the this-wrapped on-sync callback so it can be unregistered on
+ // shutdown.
+ #onSyncCallback = null;
+
+ constructor() {
+ this.#rs = lazy.RemoteSettings(COLLECTION_NAME);
+ }
+
+ async init() {
+ lazy.logConsole.debug("init");
+
+ await this.importAllRules();
+
+ // Register listener to import rules when test pref changes.
+ Services.prefs.addObserver(PREF_TEST_RULES, this);
+
+ // Register callback for collection changes.
+ // Only register if not already registered.
+ if (!this.#onSyncCallback) {
+ this.#onSyncCallback = this.onSync.bind(this);
+ this.#rs.on("sync", this.#onSyncCallback);
+ }
+ }
+
+ initForTest() {
+ return this.init();
+ }
+
+ async importAllRules() {
+ lazy.logConsole.debug("importAllRules");
+
+ try {
+ let rules = await this.#rs.get();
+
+ // While getting rules from RemoteSettings the enabled state of the
+ // feature could have changed. Ensure the service is still enabled before
+ // attempting to import rules.
+ if (!Services.cookieBanners.isEnabled) {
+ lazy.logConsole.warn("Skip import nsICookieBannerService is disabled");
+ return;
+ }
+ if (!lazy.TEST_SKIP_REMOTE_SETTINGS) {
+ this.#importRules(rules);
+ }
+ } catch (error) {
+ lazy.logConsole.error(
+ "Error while importing cookie banner rules from RemoteSettings",
+ error
+ );
+ }
+
+ // We import test rules, even if fetching rules from RemoteSettings failed.
+ await this.#importTestRules();
+ }
+
+ shutdown() {
+ lazy.logConsole.debug("shutdown");
+
+ // Unregister callback for collection changes.
+ if (this.#onSyncCallback) {
+ this.#rs.off("sync", this.#onSyncCallback);
+ this.#onSyncCallback = null;
+ }
+
+ Services.prefs.removeObserver(PREF_TEST_RULES, this);
+ }
+
+ /**
+ * Called for remote settings "sync" events.
+ */
+ onSync({ data: { created, updated, deleted } }) {
+ if (lazy.TEST_SKIP_REMOTE_SETTINGS) {
+ return;
+ }
+ lazy.logConsole.debug("onSync", { created, updated, deleted });
+
+ // Remove deleted rules.
+ this.#removeRules(deleted);
+
+ // Import new rules and override updated rules.
+ this.#importRules(created.concat(updated.map(u => u.new)));
+
+ // Re-import test rules in case they need to overwrite existing rules or a test rule was deleted above.
+ this.#importTestRules();
+ }
+
+ observe(subject, topic, prefName) {
+ if (prefName != PREF_TEST_RULES) {
+ return;
+ }
+
+ // When the test rules update we need to clear all rules and import them
+ // again. This is required because we don't have a mechanism for deleting specific
+ // test rules.
+ // Passing `doImport = false` since we trigger the import ourselves.
+ Services.cookieBanners.resetRules(false);
+ this.importAllRules();
+ }
+
+ #removeRules(rules = []) {
+ lazy.logConsole.debug("removeRules", rules);
+
+ // For each js rule, construct a temporary nsICookieBannerRule to pass into
+ // Services.cookieBanners.removeRule. For removal only domain and id are
+ // relevant.
+ rules
+ .map(({ id, domain, domains }) => {
+ // Provide backwards-compatibility with single-domain rules.
+ if (domain) {
+ domains = [domain];
+ }
+
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.id = id;
+ rule.domains = domains;
+ return rule;
+ })
+ .forEach(r => {
+ Services.cookieBanners.removeRule(r);
+
+ // Clear the fact if we have reported telemetry for the domain. So, we
+ // can collect again with the updated rules.
+ Services.cookieBanners.resetDomainTelemetryRecord(r.domain);
+ });
+ }
+
+ #importRules(rules) {
+ lazy.logConsole.debug("importRules", rules);
+
+ rules.forEach(({ id, domain, domains, cookies, click }) => {
+ // Provide backwards-compatibility with single-domain rules.
+ if (domain) {
+ domains = [domain];
+ }
+
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.id = id;
+ rule.domains = domains;
+
+ // Import the cookie rule.
+ this.#importCookieRule(rule, cookies);
+
+ // Import the click rule.
+ this.#importClickRule(rule, click);
+
+ Services.cookieBanners.insertRule(rule);
+
+ // Clear the fact if we have reported telemetry for the domain. Note that
+ // this function could handle rule update and the initial rule import. In
+ // both cases, we should clear to make sure we will collect with the
+ // latest rules.
+ Services.cookieBanners.resetDomainTelemetryRecord(domain);
+ });
+ }
+
+ async #importTestRules() {
+ lazy.logConsole.debug("importTestRules");
+
+ if (!Services.prefs.prefHasUserValue(PREF_TEST_RULES)) {
+ lazy.logConsole.debug(
+ "Skip importing test rules: Pref has default value."
+ );
+ return;
+ }
+
+ // Parse array of rules from pref value string as JSON.
+ let testRules;
+ try {
+ testRules = JSON.parse(lazy.testRulesPref);
+ } catch (error) {
+ lazy.logConsole.error(
+ `Failed to parse test rules JSON string. Make sure ${PREF_TEST_RULES} contains valid JSON. ${error?.name}`,
+ error
+ );
+ return;
+ }
+
+ // Ensure we have an array we can iterate over and not an object.
+ if (!Array.isArray(testRules)) {
+ lazy.logConsole.error(
+ "Failed to parse test rules JSON String: Not an array."
+ );
+ return;
+ }
+
+ // Validate individual array elements (rules) via the schema defined in
+ // CookieBannerRule.schema.json.
+ let schema = await lazy.CookieBannerRuleSchema;
+ let validator = new lazy.JsonSchema.Validator(schema);
+ let validatedTestRules = [];
+
+ let i = 0;
+ for (let rule of testRules) {
+ let { valid, errors } = validator.validate(rule);
+
+ if (!valid) {
+ lazy.logConsole.error(
+ `Skipping invalid test rule at index ${i}. Errors: ${JSON.stringify(
+ errors,
+ null,
+ 2
+ )}`
+ );
+ lazy.logConsole.debug("Test rule validation error", rule, errors);
+
+ i += 1;
+ continue;
+ }
+
+ // Only import rules if they are valid.
+ validatedTestRules.push(rule);
+ i += 1;
+ }
+
+ this.#importRules(validatedTestRules);
+ }
+
+ #importCookieRule(rule, cookies) {
+ // Skip rules that don't have cookies.
+ if (!cookies) {
+ return;
+ }
+
+ // Import opt-in and opt-out cookies if defined.
+ for (let category of ["optOut", "optIn"]) {
+ if (!cookies[category]) {
+ continue;
+ }
+
+ let isOptOut = category == "optOut";
+
+ for (let c of cookies[category]) {
+ let { expiryRelative } = c;
+ if (expiryRelative == null || expiryRelative <= 0) {
+ expiryRelative = lazy.DEFAULT_EXPIRY_RELATIVE;
+ }
+
+ rule.addCookie(
+ isOptOut,
+ c.name,
+ c.value,
+ // The following fields are optional and may not be defined by the
+ // rule.
+ // If unset, host falls back to ".<domain>" internally.
+ c.host,
+ c.path || "/",
+ expiryRelative,
+ c.unsetValue,
+ c.isSecure,
+ c.isHTTPOnly,
+ // Default injected cookies to session expiry.
+ c.isSession ?? true,
+ c.sameSite,
+ c.schemeMap
+ );
+ }
+ }
+ }
+
+ /**
+ * Converts runContext string field to nsIClickRule::RunContext
+ * @param {('top'|'child'|'all')} runContextStr - Run context as string.
+ * @returns nsIClickRule::RunContext representation.
+ */
+ #runContextStrToNative(runContextStr) {
+ let strToNative = {
+ top: Ci.nsIClickRule.RUN_TOP,
+ child: Ci.nsIClickRule.RUN_CHILD,
+ all: Ci.nsIClickRule.RUN_ALL,
+ };
+
+ // Default to RUN_TOP;
+ return strToNative[runContextStr] ?? Ci.nsIClickRule.RUN_TOP;
+ }
+
+ #importClickRule(rule, click) {
+ // Skip importing the rule if there is no click object or the click rule is
+ // empty - it doesn't have the mandatory presence attribute.
+ if (!click || !click.presence) {
+ return;
+ }
+
+ rule.addClickRule(
+ click.presence,
+ click.skipPresenceVisibilityCheck,
+ this.#runContextStrToNative(click.runContext),
+ click.hide,
+ click.optOut,
+ click.optIn
+ );
+ }
+}
diff --git a/toolkit/components/cookiebanners/CookieBannerParent.sys.mjs b/toolkit/components/cookiebanners/CookieBannerParent.sys.mjs
new file mode 100644
index 0000000000..8c84180d0a
--- /dev/null
+++ b/toolkit/components/cookiebanners/CookieBannerParent.sys.mjs
@@ -0,0 +1,176 @@
+/* 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, {
+ 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
+);
+
+export class CookieBannerParent extends JSWindowActorParent {
+ /**
+ * Get the browser associated with this window which is the top level embedder
+ * element. Returns null if the top embedder isn't a browser.
+ */
+ get #browserElement() {
+ let topBC = this.browsingContext.top;
+
+ // Not all embedders are browsers.
+ if (topBC.embedderElementType != "browser") {
+ return null;
+ }
+
+ return topBC.embedderElement;
+ }
+
+ #isPrivateBrowsing() {
+ let browser = this.#browserElement;
+ if (!browser) {
+ return false;
+ }
+ return lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
+ }
+
+ /**
+ * Dispatches a custom "cookiebannerhandled" event on the chrome window.
+ */
+ #notifyCookieBannerState(eventType) {
+ let chromeWin = this.browsingContext.topChromeWindow;
+ if (!chromeWin) {
+ return;
+ }
+ let windowUtils = chromeWin.windowUtils;
+ if (!windowUtils) {
+ return;
+ }
+ let event = new CustomEvent(eventType, {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ windowContext: this.manager,
+ },
+ });
+ windowUtils.dispatchEventToChromeOnly(chromeWin, event);
+ }
+
+ async receiveMessage(message) {
+ if (message.name == "CookieBanner::Test-FinishClicking") {
+ Services.obs.notifyObservers(
+ null,
+ "cookie-banner-test-clicking-finish",
+ this.manager.documentPrincipal?.baseDomain
+ );
+ return undefined;
+ }
+
+ // Forwards cookie banner detected signals to frontend consumers.
+ if (message.name == "CookieBanner::DetectedBanner") {
+ this.#notifyCookieBannerState("cookiebannerdetected");
+ return undefined;
+ }
+
+ // Forwards cookie banner handled signals to frontend consumers.
+ if (message.name == "CookieBanner::HandledBanner") {
+ this.#notifyCookieBannerState("cookiebannerhandled");
+ return undefined;
+ }
+
+ if (message.name != "CookieBanner::GetClickRules") {
+ return undefined;
+ }
+
+ // TODO: Bug 1790688: consider moving this logic to the cookie banner service.
+ let mode;
+ let isPrivateBrowsing = this.#isPrivateBrowsing();
+ if (isPrivateBrowsing) {
+ mode = lazy.serviceModePBM;
+ } else {
+ mode = lazy.serviceMode;
+ }
+
+ // Check if we have a site preference of the top-level URI. If so, it
+ // takes precedence over the pref setting.
+ let topBrowsingContext = this.manager.browsingContext.top;
+ let topURI = topBrowsingContext.currentWindowGlobal?.documentURI;
+
+ // We don't need to check the domain preference if the cookie banner
+ // handling was disabled by pref.
+ if (mode != Ci.nsICookieBannerService.MODE_DISABLED && topURI) {
+ try {
+ let perDomainMode = Services.cookieBanners.getDomainPref(
+ topURI,
+ isPrivateBrowsing
+ );
+
+ if (perDomainMode != Ci.nsICookieBannerService.MODE_UNSET) {
+ mode = perDomainMode;
+ }
+ } catch (e) {
+ // getPerSitePref could throw with NS_ERROR_NOT_AVAILABLE if the service
+ // is disabled. We will fallback to global pref setting if any errors
+ // occur.
+ if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) {
+ console.error("The cookie banner handling service is not available");
+ } else {
+ console.error("Fail on getting domain pref:", e);
+ }
+ }
+ }
+
+ // Service is disabled for current context (normal or private browsing),
+ // return empty array.
+ if (mode == Ci.nsICookieBannerService.MODE_DISABLED) {
+ return [];
+ }
+
+ let domain = this.manager.documentPrincipal?.baseDomain;
+
+ if (!domain) {
+ return [];
+ }
+
+ let isTopLevel = !this.manager.browsingContext.parent;
+ let rules = Services.cookieBanners.getClickRulesForDomain(
+ domain,
+ isTopLevel
+ );
+
+ if (!rules.length) {
+ return [];
+ }
+
+ // Determine whether we can fall back to opt-in rules. This includes the
+ // detect-only mode where don't interact with the banner.
+ let modeAllowsOptIn =
+ mode == Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT;
+ return rules.map(rule => {
+ let target = rule.optOut;
+
+ if (modeAllowsOptIn && !target) {
+ target = rule.optIn;
+ }
+ return {
+ hide: rule.hide ?? rule.presence,
+ presence: rule.presence,
+ skipPresenceVisibilityCheck: rule.skipPresenceVisibilityCheck,
+ target,
+ };
+ });
+ }
+}
diff --git a/toolkit/components/cookiebanners/components.conf b/toolkit/components/cookiebanners/components.conf
new file mode 100644
index 0000000000..adf85f8d2d
--- /dev/null
+++ b/toolkit/components/cookiebanners/components.conf
@@ -0,0 +1,39 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'name': 'CookieBannerService',
+ 'cid': '{eac9cdc4-ecee-49f2-91da-7627e15c1f3c}',
+ 'interfaces': ['nsICookieBannerService'],
+ 'contract_ids': ['@mozilla.org/cookie-banner-service;1'],
+ 'type': 'mozilla::nsCookieBannerService',
+ 'headers': ['/toolkit/components/cookiebanners/nsCookieBannerService.h'],
+ 'singleton': True,
+ 'constructor': 'mozilla::nsCookieBannerService::GetSingleton',
+ 'js_name': 'cookieBanners',
+ 'categories': {
+ 'profile-after-change': 'nsCookieBannerService',
+ 'idle-daily': 'nsCookieBannerService',
+ },
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+ {
+ 'cid': '{eb1904db-e0d1-4760-a721-db76b1ca3e94}',
+ 'interfaces': ['nsICookieBannerRule'],
+ 'headers': ['/toolkit/components/cookiebanners/nsCookieBannerRule.h'],
+ 'type': 'mozilla::nsCookieBannerRule',
+ 'contract_ids': ['@mozilla.org/cookie-banner-rule;1'],
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+ {
+ 'cid': '{1d8d9470-97d3-4885-a108-44a5c4fb36e2}',
+ 'contract_ids': ['@mozilla.org/cookie-banner-list-service;1'],
+ 'esModule': 'resource://gre/modules/CookieBannerListService.sys.mjs',
+ 'constructor': 'CookieBannerListService',
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+]
diff --git a/toolkit/components/cookiebanners/jar.mn b/toolkit/components/cookiebanners/jar.mn
new file mode 100644
index 0000000000..ab0184022b
--- /dev/null
+++ b/toolkit/components/cookiebanners/jar.mn
@@ -0,0 +1,6 @@
+# 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/.
+
+toolkit.jar:
+ content/global/cookiebanners/CookieBannerRule.schema.json (schema/CookieBannerRule.schema.json)
diff --git a/toolkit/components/cookiebanners/metrics.yaml b/toolkit/components/cookiebanners/metrics.yaml
new file mode 100644
index 0000000000..e8db788378
--- /dev/null
+++ b/toolkit/components/cookiebanners/metrics.yaml
@@ -0,0 +1,239 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Core :: Privacy: Anti-Tracking'
+
+cookie.banners:
+ normal_window_service_mode:
+ type: labeled_boolean
+ description: >
+ The pref value of the cookie banner service mode for normal windows.
+ bugs:
+ - https://bugzilla.mozilla.org/1797081
+ - https://bugzilla.mozilla.org/1804259
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797081#c3
+ notification_emails:
+ - tihuang@mozilla.com
+ - pbz@mozilla.com
+ expires: never
+ labels:
+ - disabled
+ - reject
+ - reject_or_accept
+ - invalid
+ telemetry_mirror: COOKIE_BANNERS_NORMAL_WINDOW_SERVICE_MODE
+ private_window_service_mode:
+ type: labeled_boolean
+ description: >
+ The pref value of the cookie banner service mode for private windows.
+ bugs:
+ - https://bugzilla.mozilla.org/1797081
+ - https://bugzilla.mozilla.org/1804259
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797081#c3
+ notification_emails:
+ - tihuang@mozilla.com
+ - pbz@mozilla.com
+ expires: never
+ labels:
+ - disabled
+ - reject
+ - reject_or_accept
+ - invalid
+ telemetry_mirror: COOKIE_BANNERS_PRIVATE_WINDOW_SERVICE_MODE
+ service_detect_only:
+ type: boolean
+ description: >
+ Tracks the value of the cookiebanners.service.detectOnly pref.
+ bugs:
+ - https://bugzilla.mozilla.org/1809700
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1809700#c3
+ notification_emails:
+ - tihuang@mozilla.com
+ - pbz@mozilla.com
+ expires: never
+ telemetry_mirror: COOKIE_BANNERS_SERVICE_DETECT_ONLY
+ rule_lookup_by_load:
+ type: labeled_counter
+ description: >
+ Counts the number of hit/miss of cookie banner rule lookups for every
+ load. We collect three types of counters, including counters for overall
+ rule lookup, counters for cookie rule lookup and counters for click rule
+ lookup. We also divide the counter by top-level loads and iframe loads.
+ bugs:
+ - https://bugzilla.mozilla.org/1797073
+ - https://bugzilla.mozilla.org/1804259
+ - https://bugzilla.mozilla.org/1827765
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797073#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827765#c9
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - tihuang@mozilla.com
+ - pbz@mozilla.com
+ expires: 120
+ labels:
+ - top_hit
+ - top_hit_opt_in
+ - top_hit_opt_out
+ - top_miss
+ - iframe_hit
+ - iframe_hit_opt_in
+ - iframe_hit_opt_out
+ - iframe_miss
+ - top_cookie_hit
+ - top_cookie_hit_opt_in
+ - top_cookie_hit_opt_out
+ - top_cookie_miss
+ - iframe_cookie_hit
+ - iframe_cookie_hit_opt_in
+ - iframe_cookie_hit_opt_out
+ - iframe_cookie_miss
+ - top_click_hit
+ - top_click_hit_opt_in
+ - top_click_hit_opt_out
+ - top_click_miss
+ - iframe_click_hit
+ - iframe_click_hit_opt_in
+ - iframe_click_hit_opt_out
+ - iframe_click_miss
+ telemetry_mirror: COOKIE_BANNERS_RULE_LOOKUP_BY_LOAD
+ rule_lookup_by_domain:
+ type: labeled_counter
+ description: >
+ Counts the number of hit/miss of cookie banner rule lookups for domain.
+ We collect three types of counters, including counters for overall
+ rule lookup, counters for cookie rule lookup and counters for click rule
+ lookup. We also divide the counter by top-level loads and iframe loads.
+ For each domain, we will only collect once.
+ bugs:
+ - https://bugzilla.mozilla.org/1797073
+ - https://bugzilla.mozilla.org/1804259
+ - https://bugzilla.mozilla.org/1827765
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797073#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827765#c9
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - tihuang@mozilla.com
+ - pbz@mozilla.com
+ expires: 120
+ labels:
+ - top_hit
+ - top_hit_opt_in
+ - top_hit_opt_out
+ - top_miss
+ - iframe_hit
+ - iframe_hit_opt_in
+ - iframe_hit_opt_out
+ - iframe_miss
+ - top_cookie_hit
+ - top_cookie_hit_opt_in
+ - top_cookie_hit_opt_out
+ - top_cookie_miss
+ - iframe_cookie_hit
+ - iframe_cookie_hit_opt_in
+ - iframe_cookie_hit_opt_out
+ - iframe_cookie_miss
+ - top_click_hit
+ - top_click_hit_opt_in
+ - top_click_hit_opt_out
+ - top_click_miss
+ - iframe_click_hit
+ - iframe_click_hit_opt_in
+ - iframe_click_hit_opt_out
+ - iframe_click_miss
+ telemetry_mirror: COOKIE_BANNERS_RULE_LOOKUP_BY_DOMAIN
+ reload:
+ type: event
+ description: >
+ Recorded when the top-level page is reloaded. We use this event metric to
+ know whether or not the reloading domain has cookie banner rule.
+ bugs:
+ - https://bugzilla.mozilla.org/1797079
+ - https://bugzilla.mozilla.org/1827765
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797079#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827765#c9
+ notification_emails:
+ - pbz@mozilla.com
+ - tihuang@mozilla.com
+ expires: 120
+ data_sensitivity:
+ - interaction
+ extra_keys:
+ no_rule:
+ description: There is no cookie banner rule for the reloading domain.
+ type: boolean
+ has_cookie_rule:
+ description: There is a matching cookie rule for the reloading domain.
+ type: boolean
+ has_click_rule:
+ description: There is a matching click rule for the reloading domain.
+ type: boolean
+ telemetry_mirror: Cookie_banner_Reload_Browser
+cookie.banners.click:
+ handle_duration:
+ type: timing_distribution
+ time_unit: millisecond
+ description: >
+ Counts how long it takes to handle cookie banners successfully from
+ DOMContentLoaded until click.
+ bugs:
+ - https://bugzilla.mozilla.org/1797078
+ - https://bugzilla.mozilla.org/1804259
+ - https://bugzilla.mozilla.org/1827765
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797078#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827765#c9
+ notification_emails:
+ - pbz@mozilla.com
+ - tihuang@mozilla.com
+ expires: 120
+ telemetry_mirror: COOKIE_BANNERS_CLICK_HANDLE_DURATION_MS
+ # TODO: consider moving this out of "click". It also records cookie injection.
+ result:
+ type: labeled_counter
+ description: >
+ Given a matching cookie banner rule, how often do we handle or fail to
+ handle cookie banners, labelled by reason. The 'success' and 'fail'
+ counters count the total numbers independently of the reason counters.
+ Counters are incremented after the content window has been destroyed. This
+ metric additionally reports cookie injections after which we didn't see a
+ banner as "success_cookie_injected".
+ bugs:
+ - https://bugzilla.mozilla.org/1797078
+ - https://bugzilla.mozilla.org/1804259
+ - https://bugzilla.mozilla.org/1821738
+ - https://bugzilla.mozilla.org/1827765
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1797078#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827765#c9
+ notification_emails:
+ - pbz@mozilla.com
+ - tihuang@mozilla.com
+ expires: 120
+ labels:
+ - success
+ - success_cookie_injected
+ - success_dom_content_loaded
+ - success_mutation_pre_load
+ - success_mutation_post_load
+ - fail
+ - fail_banner_not_found
+ - fail_banner_not_visible
+ - fail_button_not_found
+ - fail_no_rule_for_mode
+ - fail_actor_destroyed
+ telemetry_mirror: COOKIE_BANNERS_CLICK_RESULT
diff --git a/toolkit/components/cookiebanners/moz.build b/toolkit/components/cookiebanners/moz.build
new file mode 100644
index 0000000000..537f6a294b
--- /dev/null
+++ b/toolkit/components/cookiebanners/moz.build
@@ -0,0 +1,62 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "Privacy: Anti-Tracking")
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPIDL_SOURCES += [
+ "nsIClickRule.idl",
+ "nsICookieBannerListService.idl",
+ "nsICookieBannerRule.idl",
+ "nsICookieBannerService.idl",
+ "nsICookieRule.idl",
+]
+
+XPIDL_MODULE = "toolkit_cookiebanners"
+
+EXTRA_JS_MODULES += [
+ "CookieBannerListService.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXPORTS.mozilla += [
+ "nsClickRule.h",
+ "nsCookieBannerRule.h",
+ "nsCookieBannerService.h",
+ "nsCookieInjector.h",
+ "nsCookieRule.h",
+]
+
+UNIFIED_SOURCES += [
+ "CookieBannerDomainPrefService.cpp",
+ "nsClickRule.cpp",
+ "nsCookieBannerRule.cpp",
+ "nsCookieBannerService.cpp",
+ "nsCookieInjector.cpp",
+ "nsCookieRule.cpp",
+]
+
+FINAL_TARGET_FILES.actors += [
+ "CookieBannerChild.sys.mjs",
+ "CookieBannerParent.sys.mjs",
+]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+LOCAL_INCLUDES += ["/netwerk/base", "/netwerk/cookie"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+
+FINAL_LIBRARY = "xul"
diff --git a/toolkit/components/cookiebanners/nsClickRule.cpp b/toolkit/components/cookiebanners/nsClickRule.cpp
new file mode 100644
index 0000000000..a8f63c2632
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsClickRule.cpp
@@ -0,0 +1,57 @@
+/* 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/. */
+
+#include "nsClickRule.h"
+
+#include "nsCookieBannerRule.h"
+#include "nsString.h"
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(nsClickRule, nsIClickRule)
+
+NS_IMETHODIMP
+nsClickRule::GetPresence(nsACString& aPresence) {
+ aPresence.Assign(mPresence);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsClickRule::GetSkipPresenceVisibilityCheck(
+ bool* aSkipPresenceVisibilityCheck) {
+ NS_ENSURE_ARG_POINTER(aSkipPresenceVisibilityCheck);
+
+ *aSkipPresenceVisibilityCheck = mSkipPresenceVisibilityCheck;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsClickRule::GetRunContext(nsIClickRule::RunContext* aRunContext) {
+ NS_ENSURE_ARG_POINTER(aRunContext);
+
+ *aRunContext = mRunContext;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsClickRule::GetHide(nsACString& aHide) {
+ aHide.Assign(mHide);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsClickRule::GetOptOut(nsACString& aOptOut) {
+ aOptOut.Assign(mOptOut);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsClickRule::GetOptIn(nsACString& aOptIn) {
+ aOptIn.Assign(mOptIn);
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/nsClickRule.h b/toolkit/components/cookiebanners/nsClickRule.h
new file mode 100644
index 0000000000..c9d51335cb
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsClickRule.h
@@ -0,0 +1,42 @@
+/* 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/. */
+
+#ifndef mozilla_nsclickrule_h__
+#define mozilla_nsclickrule_h__
+
+#include "nsIClickRule.h"
+#include "nsString.h"
+
+namespace mozilla {
+
+class nsClickRule final : public nsIClickRule {
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSICLICKRULE
+
+ explicit nsClickRule(const nsACString& aPresence,
+ const bool aSkipPresenceVisibilityCheck,
+ const nsIClickRule::RunContext aRunContext,
+ const nsACString& aHide, const nsACString& aOptOut,
+ const nsACString& aOptIn)
+ : mPresence(aPresence),
+ mSkipPresenceVisibilityCheck(aSkipPresenceVisibilityCheck),
+ mRunContext(aRunContext),
+ mHide(aHide),
+ mOptOut(aOptOut),
+ mOptIn(aOptIn) {}
+
+ private:
+ ~nsClickRule() = default;
+
+ nsCString mPresence;
+ bool mSkipPresenceVisibilityCheck;
+ nsIClickRule::RunContext mRunContext;
+ nsCString mHide;
+ nsCString mOptOut;
+ nsCString mOptIn;
+};
+
+} // namespace mozilla
+
+#endif // mozilla_nsclickrule_h__
diff --git a/toolkit/components/cookiebanners/nsCookieBannerRule.cpp b/toolkit/components/cookiebanners/nsCookieBannerRule.cpp
new file mode 100644
index 0000000000..47f3fe7ac2
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieBannerRule.cpp
@@ -0,0 +1,176 @@
+/* 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/. */
+
+#include "nsCookieBannerRule.h"
+
+#include "mozilla/Logging.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+#include "nsClickRule.h"
+#include "nsCookieRule.h"
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(nsCookieBannerRule, nsICookieBannerRule)
+
+LazyLogModule gCookieRuleLog("nsCookieBannerRule");
+
+NS_IMETHODIMP
+nsCookieBannerRule::ClearCookies() {
+ mCookiesOptOut.Clear();
+ mCookiesOptIn.Clear();
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::AddCookie(bool aIsOptOut, const nsACString& aName,
+ const nsACString& aValue, const nsACString& aHost,
+ const nsACString& aPath, int64_t aExpiryRelative,
+ const nsACString& aUnsetValue, bool aIsSecure,
+ bool aIsHttpOnly, bool aIsSession,
+ int32_t aSameSite,
+ nsICookie::schemeType aSchemeMap) {
+ LogRule(gCookieRuleLog, "AddCookie:", this, LogLevel::Debug);
+ MOZ_LOG(
+ gCookieRuleLog, LogLevel::Debug,
+ ("%s: aIsOptOut: %d, aHost: %s, aName: %s", __FUNCTION__, aIsOptOut,
+ nsPromiseFlatCString(aHost).get(), nsPromiseFlatCString(aName).get()));
+
+ // Create and insert cookie rule.
+ nsCOMPtr<nsICookieRule> cookieRule = new nsCookieRule(
+ aIsOptOut, aName, aValue, aHost, aPath, aExpiryRelative, aUnsetValue,
+ aIsSecure, aIsHttpOnly, aIsSession, aSameSite, aSchemeMap);
+ Cookies(aIsOptOut).AppendElement(cookieRule);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetId(nsACString& aId) {
+ aId.Assign(mId);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::SetId(const nsACString& aId) {
+ mId.Assign(aId);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetDomains(nsTArray<nsCString>& aDomains) {
+ aDomains.Clear();
+
+ AppendToArray(aDomains, mDomains);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::SetDomains(const nsTArray<nsCString>& aDomains) {
+ AppendToArray(mDomains, aDomains);
+
+ return NS_OK;
+}
+
+nsTArray<nsCOMPtr<nsICookieRule>>& nsCookieBannerRule::Cookies(bool isOptOut) {
+ if (isOptOut) {
+ return mCookiesOptOut;
+ }
+ return mCookiesOptIn;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetCookies(bool aIsOptOut, const nsACString& aDomain,
+ nsTArray<RefPtr<nsICookieRule>>& aCookies) {
+ nsTArray<nsCOMPtr<nsICookieRule>>& cookies = Cookies(aIsOptOut);
+ for (nsICookieRule* cookie : cookies) {
+ // If we don't need to set a domain we can simply return the existing rule.
+ if (aDomain.IsEmpty()) {
+ aCookies.AppendElement(cookie);
+ continue;
+ }
+ // Otherwise get a copy.
+ nsCOMPtr<nsICookieRule> ruleForDomain;
+ nsresult rv = cookie->CopyForDomain(aDomain, getter_AddRefs(ruleForDomain));
+ NS_ENSURE_SUCCESS(rv, rv);
+ aCookies.AppendElement(ruleForDomain.forget());
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetCookiesOptOut(
+ nsTArray<RefPtr<nsICookieRule>>& aCookies) {
+ return GetCookies(true, ""_ns, aCookies);
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetCookiesOptIn(nsTArray<RefPtr<nsICookieRule>>& aCookies) {
+ return GetCookies(false, ""_ns, aCookies);
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::GetClickRule(nsIClickRule** aClickRule) {
+ NS_IF_ADDREF(*aClickRule = mClickRule);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::AddClickRule(const nsACString& aPresence,
+ const bool aSkipPresenceVisibilityCheck,
+ const nsIClickRule::RunContext aRunContext,
+ const nsACString& aHide,
+ const nsACString& aOptOut,
+ const nsACString& aOptIn) {
+ mClickRule = MakeRefPtr<nsClickRule>(aPresence, aSkipPresenceVisibilityCheck,
+ aRunContext, aHide, aOptOut, aOptIn);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerRule::ClearClickRule() {
+ mClickRule = nullptr;
+
+ return NS_OK;
+}
+
+// Static
+void nsCookieBannerRule::LogRule(LazyLogModule& aLogger, const char* aMessage,
+ nsICookieBannerRule* aRule,
+ LogLevel aLogLevel) {
+ NS_ENSURE_TRUE_VOID(aMessage);
+ NS_ENSURE_TRUE_VOID(aRule);
+
+ // Exit early if logging is disabled for the given log-level.
+ if (!MOZ_LOG_TEST(aLogger, aLogLevel)) {
+ return;
+ }
+
+ nsAutoCString id;
+ nsresult rv = aRule->GetId(id);
+ NS_ENSURE_SUCCESS_VOID(rv);
+
+ nsTArray<nsCString> domains;
+ rv = aRule->GetDomains(domains);
+ NS_ENSURE_SUCCESS_VOID(rv);
+
+ // Create a comma delimited string of domains this rule supports.
+ nsAutoCString domainsStr("*");
+ for (const nsCString& domain : domains) {
+ if (domainsStr.EqualsLiteral("*")) {
+ domainsStr.Truncate();
+ } else {
+ domainsStr.AppendLiteral(",");
+ }
+ domainsStr.Append(domain);
+ }
+
+ MOZ_LOG(aLogger, aLogLevel,
+ ("%s Rule: id=%s; domains=[%s]; isGlobal: %d", aMessage, id.get(),
+ PromiseFlatCString(domainsStr).get(), domains.IsEmpty()));
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/nsCookieBannerRule.h b/toolkit/components/cookiebanners/nsCookieBannerRule.h
new file mode 100644
index 0000000000..eec6d65d95
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieBannerRule.h
@@ -0,0 +1,43 @@
+/* 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/. */
+#ifndef mozilla_nsCookieBannerRule_h__
+#define mozilla_nsCookieBannerRule_h__
+
+#include "nsICookieBannerRule.h"
+#include "nsICookieRule.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+#include "mozilla/Logging.h"
+
+class nsIClickRule;
+
+namespace mozilla {
+
+class nsCookieBannerRule final : public nsICookieBannerRule {
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSICOOKIEBANNERRULE
+
+ public:
+ nsCookieBannerRule() = default;
+
+ static void LogRule(LazyLogModule& aLogger, const char* aMessage,
+ nsICookieBannerRule* aRule, LogLevel aLogLevel);
+
+ private:
+ ~nsCookieBannerRule() = default;
+
+ nsCString mId;
+ nsTArray<nsCString> mDomains;
+ nsTArray<nsCOMPtr<nsICookieRule>> mCookiesOptOut;
+ nsTArray<nsCOMPtr<nsICookieRule>> mCookiesOptIn;
+
+ // Internal getter for easy access of cookie rule arrays.
+ nsTArray<nsCOMPtr<nsICookieRule>>& Cookies(bool isOptOut);
+
+ nsCOMPtr<nsIClickRule> mClickRule;
+};
+
+} // namespace mozilla
+
+#endif
diff --git a/toolkit/components/cookiebanners/nsCookieBannerService.cpp b/toolkit/components/cookiebanners/nsCookieBannerService.cpp
new file mode 100644
index 0000000000..3fba178120
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieBannerService.cpp
@@ -0,0 +1,1289 @@
+/* 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/. */
+
+#include "nsCookieBannerService.h"
+
+#include "CookieBannerDomainPrefService.h"
+#include "ErrorList.h"
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/dom/CanonicalBrowsingContext.h"
+#include "mozilla/dom/WindowGlobalParent.h"
+#include "mozilla/EventQueue.h"
+#include "mozilla/glean/GleanMetrics.h"
+#include "mozilla/Logging.h"
+#include "mozilla/StaticPrefs_cookiebanners.h"
+
+#include "nsCOMPtr.h"
+#include "nsCookieBannerRule.h"
+#include "nsCookieInjector.h"
+#include "nsCRT.h"
+#include "nsDebug.h"
+#include "nsError.h"
+#include "nsIClickRule.h"
+#include "nsICookieBannerListService.h"
+#include "nsICookieBannerRule.h"
+#include "nsICookie.h"
+#include "nsIEffectiveTLDService.h"
+#include "nsIPrincipal.h"
+#include "nsNetCID.h"
+#include "nsServiceManagerUtils.h"
+#include "nsStringFwd.h"
+#include "nsThreadUtils.h"
+#include "Cookie.h"
+
+#define OBSERVER_TOPIC_BC_ATTACHED "browsing-context-attached"
+#define OBSERVER_TOPIC_BC_DISCARDED "browsing-context-discarded"
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(nsCookieBannerService, nsICookieBannerService, nsIObserver,
+ nsIWebProgressListener, nsISupportsWeakReference)
+
+LazyLogModule gCookieBannerLog("nsCookieBannerService");
+
+static const char kCookieBannerServiceModePref[] = "cookiebanners.service.mode";
+static const char kCookieBannerServiceModePBMPref[] =
+ "cookiebanners.service.mode.privateBrowsing";
+
+static StaticRefPtr<nsCookieBannerService> sCookieBannerServiceSingleton;
+
+namespace {
+
+// A helper function that converts service modes to strings.
+nsCString ConvertModeToStringForTelemetry(uint32_t aModes) {
+ switch (aModes) {
+ case nsICookieBannerService::MODE_DISABLED:
+ return "disabled"_ns;
+ case nsICookieBannerService::MODE_REJECT:
+ return "reject"_ns;
+ case nsICookieBannerService::MODE_REJECT_OR_ACCEPT:
+ return "reject_or_accept"_ns;
+ default:
+ // Fall back to return "invalid" if we got any unsupported service
+ // mode. Note this this also includes MODE_UNSET.
+ return "invalid"_ns;
+ }
+}
+
+} // anonymous namespace
+
+// static
+already_AddRefed<nsCookieBannerService> nsCookieBannerService::GetSingleton() {
+ if (!sCookieBannerServiceSingleton) {
+ sCookieBannerServiceSingleton = new nsCookieBannerService();
+
+ RunOnShutdown([] {
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("RunOnShutdown. Mode: %d. Mode PBM: %d.",
+ StaticPrefs::cookiebanners_service_mode(),
+ StaticPrefs::cookiebanners_service_mode_privateBrowsing()));
+
+ // Unregister pref listeners.
+ DebugOnly<nsresult> rv = Preferences::UnregisterCallback(
+ &nsCookieBannerService::OnPrefChange, kCookieBannerServiceModePref);
+ NS_WARNING_ASSERTION(
+ NS_SUCCEEDED(rv),
+ "Unregistering kCookieBannerServiceModePref callback failed");
+ rv = Preferences::UnregisterCallback(&nsCookieBannerService::OnPrefChange,
+ kCookieBannerServiceModePBMPref);
+ NS_WARNING_ASSERTION(
+ NS_SUCCEEDED(rv),
+ "Unregistering kCookieBannerServiceModePBMPref callback failed");
+
+ rv = sCookieBannerServiceSingleton->Shutdown();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "nsCookieBannerService::Shutdown failed.");
+
+ sCookieBannerServiceSingleton = nullptr;
+ });
+ }
+
+ return do_AddRef(sCookieBannerServiceSingleton);
+}
+
+// static
+void nsCookieBannerService::OnPrefChange(const char* aPref, void* aData) {
+ RefPtr<nsCookieBannerService> service = GetSingleton();
+
+ // If the feature is enabled for normal or private browsing, init the service.
+ if (StaticPrefs::cookiebanners_service_mode() !=
+ nsICookieBannerService::MODE_DISABLED ||
+ StaticPrefs::cookiebanners_service_mode_privateBrowsing() !=
+ nsICookieBannerService::MODE_DISABLED) {
+ MOZ_LOG(
+ gCookieBannerLog, LogLevel::Info,
+ ("Initializing nsCookieBannerService after pref change. %s", aPref));
+ DebugOnly<nsresult> rv = service->Init();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "nsCookieBannerService::Init failed");
+ return;
+ }
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Info,
+ ("Disabling nsCookieBannerService after pref change. %s", aPref));
+
+ DebugOnly<nsresult> rv = service->Shutdown();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "nsCookieBannerService::Shutdown failed");
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) {
+ // Report the daily telemetry for the cookie banner service on "idle-daily".
+ if (nsCRT::strcmp(aTopic, "idle-daily") == 0) {
+ DailyReportTelemetry();
+ ResetDomainTelemetryRecord(""_ns);
+ return NS_OK;
+ }
+
+ // Initializing the cookie banner service on startup on
+ // "profile-after-change".
+ if (nsCRT::strcmp(aTopic, "profile-after-change") == 0) {
+ nsresult rv = Preferences::RegisterCallback(
+ &nsCookieBannerService::OnPrefChange, kCookieBannerServiceModePBMPref);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return Preferences::RegisterCallbackAndCall(
+ &nsCookieBannerService::OnPrefChange, kCookieBannerServiceModePref);
+ }
+
+ if (nsCRT::strcmp(aTopic, OBSERVER_TOPIC_BC_ATTACHED) == 0) {
+ return RegisterWebProgressListener(aSubject);
+ }
+
+ if (nsCRT::strcmp(aTopic, OBSERVER_TOPIC_BC_DISCARDED) == 0) {
+ return RemoveWebProgressListener(aSubject);
+ }
+
+ return NS_OK;
+}
+
+nsresult nsCookieBannerService::Init() {
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. Mode: %d. Mode PBM: %d.", __FUNCTION__,
+ StaticPrefs::cookiebanners_service_mode(),
+ StaticPrefs::cookiebanners_service_mode_privateBrowsing()));
+
+ // Check if already initialized.
+ if (mIsInitialized) {
+ return NS_OK;
+ }
+
+ // Initialize the service which fetches cookie banner rules.
+ mListService = do_GetService(NS_COOKIEBANNERLISTSERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(mListService, NS_ERROR_FAILURE);
+
+ mDomainPrefService = CookieBannerDomainPrefService::GetOrCreate();
+ NS_ENSURE_TRUE(mDomainPrefService, NS_ERROR_FAILURE);
+
+ // Setting mIsInitialized before importing rules, because the list service
+ // needs to call nsCookieBannerService methods that would throw if not
+ // marked initialized.
+ mIsInitialized = true;
+
+ // Import initial rule-set, domain preference and enable rule syncing. Uses
+ // NS_DispatchToCurrentThreadQueue with idle priority to avoid early
+ // main-thread IO caused by the list service accessing RemoteSettings.
+ nsresult rv = NS_DispatchToCurrentThreadQueue(
+ NS_NewRunnableFunction("CookieBannerListService init startup",
+ [&] {
+ if (!mIsInitialized) {
+ return;
+ }
+ mListService->Init();
+ mDomainPrefService->Init();
+ }),
+ EventQueuePriority::Idle);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Initialize the cookie injector.
+ RefPtr<nsCookieInjector> injector = nsCookieInjector::GetSingleton();
+
+ nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
+ NS_ENSURE_TRUE(obsSvc, NS_ERROR_FAILURE);
+
+ obsSvc->AddObserver(this, OBSERVER_TOPIC_BC_ATTACHED, false);
+ obsSvc->AddObserver(this, OBSERVER_TOPIC_BC_DISCARDED, false);
+
+ return NS_OK;
+}
+
+nsresult nsCookieBannerService::Shutdown() {
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. Mode: %d. Mode PBM: %d.", __FUNCTION__,
+ StaticPrefs::cookiebanners_service_mode(),
+ StaticPrefs::cookiebanners_service_mode_privateBrowsing()));
+
+ // Check if already shutdown.
+ if (!mIsInitialized) {
+ return NS_OK;
+ }
+ mIsInitialized = false;
+
+ // Shut down the list service which will stop updating mRules.
+ mListService->Shutdown();
+
+ // Clear all stored cookie banner rules. They will be imported again on Init.
+ mRules.Clear();
+
+ nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
+ NS_ENSURE_TRUE(obsSvc, NS_ERROR_FAILURE);
+
+ obsSvc->RemoveObserver(this, OBSERVER_TOPIC_BC_ATTACHED);
+ obsSvc->RemoveObserver(this, OBSERVER_TOPIC_BC_DISCARDED);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::GetIsEnabled(bool* aResult) {
+ *aResult = mIsInitialized;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::GetRules(nsTArray<RefPtr<nsICookieBannerRule>>& aRules) {
+ aRules.Clear();
+
+ // Service is disabled, throw with empty array.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Append global rules if enabled. We don't have to deduplicate here because
+ // global rules are stored by ID and every ID maps to exactly one rule.
+ if (StaticPrefs::cookiebanners_service_enableGlobalRules()) {
+ AppendToArray(aRules, mGlobalRules.Values());
+ }
+
+ // Append domain-keyed rules.
+ // Since multiple domains can map to the same rule in mRules we need to
+ // deduplicate using a set before returning a rules array.
+ nsTHashSet<nsRefPtrHashKey<nsICookieBannerRule>> rulesSet;
+
+ for (const nsCOMPtr<nsICookieBannerRule>& rule : mRules.Values()) {
+ rulesSet.Insert(rule);
+ }
+
+ AppendToArray(aRules, rulesSet);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::ResetRules(const bool doImport) {
+ // Service is disabled, throw.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ mRules.Clear();
+ mGlobalRules.Clear();
+
+ if (doImport) {
+ NS_ENSURE_TRUE(mListService, NS_ERROR_FAILURE);
+ nsresult rv = mListService->ImportAllRules();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+nsresult nsCookieBannerService::GetRuleForDomain(const nsACString& aDomain,
+ bool aIsTopLevel,
+ nsICookieBannerRule** aRule,
+ bool aReportTelemetry) {
+ NS_ENSURE_ARG_POINTER(aRule);
+ *aRule = nullptr;
+
+ // Service is disabled, throw with null.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsCOMPtr<nsICookieBannerRule> rule = mRules.Get(aDomain);
+
+ // If we are instructed to collect telemetry.
+ if (aReportTelemetry) {
+ ReportRuleLookupTelemetry(aDomain, rule, aIsTopLevel);
+ }
+
+ if (rule) {
+ rule.forget(aRule);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::GetCookiesForURI(
+ nsIURI* aURI, const bool aIsPrivateBrowsing,
+ nsTArray<RefPtr<nsICookieRule>>& aCookies) {
+ NS_ENSURE_ARG_POINTER(aURI);
+ aCookies.Clear();
+
+ // We only need URI spec for logging, avoid getting it otherwise.
+ if (MOZ_LOG_TEST(gCookieBannerLog, LogLevel::Debug)) {
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. aURI: %s. aIsPrivateBrowsing: %d", __FUNCTION__, spec.get(),
+ aIsPrivateBrowsing));
+ }
+
+ // Service is disabled, throw with empty array.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Check which cookie banner service mode applies for this request. This
+ // depends on whether the browser is in private browsing or normal browsing
+ // mode.
+ uint32_t mode;
+ if (aIsPrivateBrowsing) {
+ mode = StaticPrefs::cookiebanners_service_mode_privateBrowsing();
+ } else {
+ mode = StaticPrefs::cookiebanners_service_mode();
+ }
+ MOZ_LOG(
+ gCookieBannerLog, LogLevel::Debug,
+ ("%s. Found nsICookieBannerRule. Computed mode: %d", __FUNCTION__, mode));
+
+ // We don't need to check the domain preference if the cookie banner handling
+ // service is disabled by pref.
+ if (mode != nsICookieBannerService::MODE_DISABLED &&
+ !StaticPrefs::cookiebanners_service_detectOnly()) {
+ // Get the domain preference for the uri, the domain preference takes
+ // precedence over the pref setting. Note that the domain preference is
+ // supposed to stored only for top level URIs.
+ nsICookieBannerService::Modes domainPref;
+ nsresult rv = GetDomainPref(aURI, aIsPrivateBrowsing, &domainPref);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (domainPref != nsICookieBannerService::MODE_UNSET) {
+ mode = domainPref;
+ }
+ }
+
+ // Service is disabled for current context (normal, private browsing or domain
+ // preference), return empty array. Same for detect-only mode where no cookies
+ // should be injected.
+ if (mode == nsICookieBannerService::MODE_DISABLED ||
+ StaticPrefs::cookiebanners_service_detectOnly()) {
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. Returning empty array. Got MODE_DISABLED for "
+ "aIsPrivateBrowsing: %d.",
+ __FUNCTION__, aIsPrivateBrowsing));
+ return NS_OK;
+ }
+
+ nsresult rv;
+ // Compute the baseDomain from aURI.
+ nsCOMPtr<nsIEffectiveTLDService> eTLDService(
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString baseDomain;
+ rv = eTLDService->GetBaseDomain(aURI, 0, baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return GetCookieRulesForDomainInternal(
+ baseDomain, static_cast<nsICookieBannerService::Modes>(mode), true, false,
+ aCookies);
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::GetClickRulesForDomain(
+ const nsACString& aDomain, const bool aIsTopLevel,
+ nsTArray<RefPtr<nsIClickRule>>& aRules) {
+ return GetClickRulesForDomainInternal(aDomain, aIsTopLevel, true, aRules);
+}
+
+nsresult nsCookieBannerService::GetClickRulesForDomainInternal(
+ const nsACString& aDomain, const bool aIsTopLevel,
+ const bool aReportTelemetry, nsTArray<RefPtr<nsIClickRule>>& aRules) {
+ aRules.Clear();
+
+ // Service is disabled, throw with empty rule.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Get the cookie banner rule for the domain. Also, we instruct the function
+ // to report the rule lookup telemetry. Note that we collect telemetry here
+ // but don't when getting cookie rules because the cookie injection only apply
+ // for top-level requests. So, we won't be able to collect data for iframe
+ // cases.
+ nsCOMPtr<nsICookieBannerRule> ruleForDomain;
+ nsresult rv = GetRuleForDomain(
+ aDomain, aIsTopLevel, getter_AddRefs(ruleForDomain), aReportTelemetry);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Extract click rule from an nsICookieBannerRule and if found append it to
+ // the array returned.
+ auto appendClickRule = [&](const nsCOMPtr<nsICookieBannerRule>& bannerRule) {
+ nsCOMPtr<nsIClickRule> clickRule;
+ rv = bannerRule->GetClickRule(getter_AddRefs(clickRule));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!clickRule) {
+ return NS_OK;
+ }
+
+ // Evaluate the rule's runContext field and skip it if the caller's context
+ // doesn't match. See nsIClickRule::RunContext for possible values.
+ nsIClickRule::RunContext runContext;
+ rv = clickRule->GetRunContext(&runContext);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool runContextMatchesRule =
+ (runContext == nsIClickRule::RUN_ALL) ||
+ (runContext == nsIClickRule::RUN_TOP && aIsTopLevel) ||
+ (runContext == nsIClickRule::RUN_CHILD && !aIsTopLevel);
+
+ if (runContextMatchesRule) {
+ aRules.AppendElement(clickRule);
+ }
+
+ return NS_OK;
+ };
+
+ // If there is a domain-specific rule it takes precedence over the global
+ // rules.
+ if (ruleForDomain) {
+ return appendClickRule(ruleForDomain);
+ }
+
+ if (!StaticPrefs::cookiebanners_service_enableGlobalRules()) {
+ // Global rules are disabled, skip adding them.
+ return NS_OK;
+ }
+
+ // Append all global click rules.
+ for (nsICookieBannerRule* globalRule : mGlobalRules.Values()) {
+ rv = appendClickRule(globalRule);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::InsertRule(nsICookieBannerRule* aRule) {
+ NS_ENSURE_ARG_POINTER(aRule);
+
+ // Service is disabled, throw.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsCookieBannerRule::LogRule(gCookieBannerLog, "InsertRule:", aRule,
+ LogLevel::Debug);
+
+ nsTArray<nsCString> domains;
+ nsresult rv = aRule->GetDomains(domains);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Global rules are stored in a separate map mGlobalRules.
+ // They are identified by having an empty domains array.
+ // They are keyed by the unique ID field.
+ if (domains.IsEmpty()) {
+ nsAutoCString id;
+ rv = aRule->GetId(id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(!id.IsEmpty(), NS_ERROR_FAILURE);
+
+ // Global rules must not have cookies. We shouldn't set cookies for every
+ // site without indication that they handle banners. Click rules are
+ // different, because they have a "presence" indicator and only click if it
+ // is reasonable to do so.
+ rv = aRule->ClearCookies();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsICookieBannerRule> result =
+ mGlobalRules.InsertOrUpdate(id, aRule);
+ NS_ENSURE_TRUE(result, NS_ERROR_FAILURE);
+
+ return NS_OK;
+ }
+
+ // Multiple domains can be mapped to the same rule.
+ for (auto& domain : domains) {
+ nsCOMPtr<nsICookieBannerRule> result = mRules.InsertOrUpdate(domain, aRule);
+ NS_ENSURE_TRUE(result, NS_ERROR_FAILURE);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::RemoveRule(nsICookieBannerRule* aRule) {
+ NS_ENSURE_ARG_POINTER(aRule);
+
+ // Service is disabled, throw.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsCookieBannerRule::LogRule(gCookieBannerLog, "RemoveRule:", aRule,
+ LogLevel::Debug);
+
+ nsTArray<nsCString> domains;
+ nsresult rv = aRule->GetDomains(domains);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Remove global rule by ID.
+ if (domains.IsEmpty()) {
+ nsAutoCString id;
+ rv = aRule->GetId(id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(!id.IsEmpty(), NS_ERROR_FAILURE);
+
+ mGlobalRules.Remove(id);
+ return NS_OK;
+ }
+
+ // Remove all entries pointing to the rule.
+ for (auto& domain : domains) {
+ mRules.Remove(domain);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::HasRuleForBrowsingContextTree(
+ mozilla::dom::BrowsingContext* aBrowsingContext, bool* aResult) {
+ NS_ENSURE_ARG_POINTER(aBrowsingContext);
+ NS_ENSURE_ARG_POINTER(aResult);
+ MOZ_ASSERT(XRE_IsParentProcess());
+ *aResult = false;
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug, ("%s", __FUNCTION__));
+
+ // Service is disabled, throw.
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsresult rv = NS_OK;
+ // Keep track of the HasRuleForBrowsingContextInternal needed, used for
+ // logging.
+ uint32_t numChecks = 0;
+
+ // TODO: Optimization: We could avoid unecessary rule lookups by remembering
+ // which domains we already looked up rules for. This would also need to take
+ // isPBM and isTopLevel into account, because some rules only apply in certain
+ // contexts.
+ auto checkFn =
+ [&](dom::BrowsingContext* bc) -> dom::BrowsingContext::WalkFlag {
+ numChecks++;
+
+ bool hasClickRule = false;
+ bool hasCookieRule = false;
+ // Pass ignoreDomainPref=true: when checking whether a suitable rule exists
+ // we don't care what the domain-specific user pref is set to.
+ rv = HasRuleForBrowsingContextInternal(bc, true, hasClickRule,
+ hasCookieRule);
+ // If the method failed abort the walk. We will return the stored error
+ // result when exiting the method.
+ if (NS_FAILED(rv)) {
+ return dom::BrowsingContext::WalkFlag::Stop;
+ }
+
+ *aResult = hasClickRule || hasCookieRule;
+
+ // Greedily return when we found a rule.
+ if (*aResult) {
+ return dom::BrowsingContext::WalkFlag::Stop;
+ }
+
+ return dom::BrowsingContext::WalkFlag::Next;
+ };
+
+ // Walk the BC (sub-)tree and return greedily when a rule is found for a
+ // BrowsingContext.
+ aBrowsingContext->PreOrderWalk(checkFn);
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. success: %d, hasRule: %d, numChecks: %d", __FUNCTION__,
+ NS_SUCCEEDED(rv), *aResult, numChecks));
+
+ return rv;
+}
+
+nsresult nsCookieBannerService::HasRuleForBrowsingContextInternal(
+ mozilla::dom::BrowsingContext* aBrowsingContext, bool aIgnoreDomainPref,
+ bool& aHasClickRule, bool& aHasCookieRule) {
+ MOZ_ASSERT(XRE_IsParentProcess());
+ MOZ_ASSERT(mIsInitialized);
+ NS_ENSURE_ARG_POINTER(aBrowsingContext);
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug, ("%s", __FUNCTION__));
+
+ aHasClickRule = false;
+ aHasCookieRule = false;
+
+ // First, check if our current mode is disabled. If so there is no applicable
+ // rule.
+ nsICookieBannerService::Modes mode;
+ nsresult rv = GetServiceModeForBrowsingContext(aBrowsingContext,
+ aIgnoreDomainPref, &mode);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (mode == nsICookieBannerService::MODE_DISABLED ||
+ StaticPrefs::cookiebanners_service_detectOnly()) {
+ return NS_OK;
+ }
+
+ // In order to lookup rules we need to get the base domain associated with the
+ // BrowsingContext.
+
+ // 1. Get the window running in the BrowsingContext.
+ RefPtr<dom::WindowGlobalParent> windowGlobalParent =
+ aBrowsingContext->Canonical()->GetCurrentWindowGlobal();
+ NS_ENSURE_TRUE(windowGlobalParent, NS_ERROR_FAILURE);
+
+ // 2. Get the base domain from the content principal.
+ nsCOMPtr<nsIPrincipal> principal = windowGlobalParent->DocumentPrincipal();
+ NS_ENSURE_TRUE(principal, NS_ERROR_FAILURE);
+
+ nsCString baseDomain;
+ rv = principal->GetBaseDomain(baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(!baseDomain.IsEmpty(), NS_ERROR_FAILURE);
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. baseDomain: %s", __FUNCTION__, baseDomain.get()));
+
+ // 3. Look up click rules by baseDomain.
+ // TODO: Optimization: We currently do two nsICookieBannerRule lookups, one
+ // for click rules and one for cookie rules.
+ nsTArray<RefPtr<nsIClickRule>> clickRules;
+ rv = GetClickRulesForDomainInternal(baseDomain, aBrowsingContext->IsTop(),
+ false, clickRules);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // 3.1. Check if there is a non-empty click rule for the current environment.
+ for (RefPtr<nsIClickRule>& rule : clickRules) {
+ NS_ENSURE_TRUE(rule, NS_ERROR_NULL_POINTER);
+
+ nsAutoCString optOut;
+ rv = rule->GetOptOut(optOut);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!optOut.IsEmpty()) {
+ aHasClickRule = true;
+ break;
+ }
+
+ if (mode == nsICookieBannerService::MODE_REJECT_OR_ACCEPT) {
+ nsAutoCString optIn;
+ rv = rule->GetOptIn(optIn);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!optIn.IsEmpty()) {
+ aHasClickRule = true;
+ break;
+ }
+ }
+ }
+
+ // 4. Check for cookie rules by baseDomain.
+ nsTArray<RefPtr<nsICookieRule>> cookies;
+ rv = GetCookieRulesForDomainInternal(
+ baseDomain, mode, aBrowsingContext->IsTop(), false, cookies);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aHasCookieRule = !cookies.IsEmpty();
+
+ return NS_OK;
+}
+
+nsresult nsCookieBannerService::GetCookieRulesForDomainInternal(
+ const nsACString& aBaseDomain, const nsICookieBannerService::Modes aMode,
+ const bool aIsTopLevel, const bool aReportTelemetry,
+ nsTArray<RefPtr<nsICookieRule>>& aCookies) {
+ MOZ_ASSERT(mIsInitialized);
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. aBaseDomain: %s", __FUNCTION__,
+ PromiseFlatCString(aBaseDomain).get()));
+
+ aCookies.Clear();
+
+ // No cookie rules if disabled or in detect-only mode. Cookie injection is not
+ // supported for the detect-only mode.
+ if (aMode == nsICookieBannerService::MODE_DISABLED ||
+ StaticPrefs::cookiebanners_service_detectOnly()) {
+ return NS_OK;
+ }
+
+ // Cookies should only be injected for top-level frames.
+ if (!aIsTopLevel) {
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsICookieBannerRule> cookieBannerRule;
+ nsresult rv =
+ GetRuleForDomain(aBaseDomain, aIsTopLevel,
+ getter_AddRefs(cookieBannerRule), aReportTelemetry);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // No rule found.
+ if (!cookieBannerRule) {
+ MOZ_LOG(
+ gCookieBannerLog, LogLevel::Debug,
+ ("%s. Returning empty array. No nsICookieBannerRule matching domain.",
+ __FUNCTION__));
+ return NS_OK;
+ }
+
+ // MODE_REJECT: In this mode we only handle the banner if we can reject. We
+ // don't care about the opt-in cookies.
+ rv = cookieBannerRule->GetCookies(true, aBaseDomain, aCookies);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // MODE_REJECT_OR_ACCEPT: In this mode we will try to opt-out, but if we don't
+ // have any opt-out cookies we will fallback to the opt-in cookies.
+ if (aMode == nsICookieBannerService::MODE_REJECT_OR_ACCEPT &&
+ aCookies.IsEmpty()) {
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. Returning opt-in cookies for %s.", __FUNCTION__,
+ PromiseFlatCString(aBaseDomain).get()));
+
+ return cookieBannerRule->GetCookies(false, aBaseDomain, aCookies);
+ }
+
+ MOZ_LOG(gCookieBannerLog, LogLevel::Debug,
+ ("%s. Returning opt-out cookies for %s.", __FUNCTION__,
+ PromiseFlatCString(aBaseDomain).get()));
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::GetDomainPref(nsIURI* aTopLevelURI,
+ const bool aIsPrivate,
+ nsICookieBannerService::Modes* aModes) {
+ NS_ENSURE_ARG_POINTER(aTopLevelURI);
+ NS_ENSURE_ARG_POINTER(aModes);
+
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsresult rv;
+ nsCOMPtr<nsIEffectiveTLDService> eTLDService(
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString baseDomain;
+ rv = eTLDService->GetBaseDomain(aTopLevelURI, 0, baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return GetDomainPrefInternal(baseDomain, aIsPrivate, aModes);
+}
+
+nsresult nsCookieBannerService::GetDomainPrefInternal(
+ const nsACString& aBaseDomain, const bool aIsPrivate,
+ nsICookieBannerService::Modes* aModes) {
+ MOZ_ASSERT(mIsInitialized);
+ NS_ENSURE_ARG_POINTER(aModes);
+
+ auto pref = mDomainPrefService->GetPref(aBaseDomain, aIsPrivate);
+
+ *aModes = nsICookieBannerService::MODE_UNSET;
+
+ if (pref.isSome()) {
+ *aModes = pref.value();
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::SetDomainPref(nsIURI* aTopLevelURI,
+ nsICookieBannerService::Modes aModes,
+ const bool aIsPrivate) {
+ NS_ENSURE_ARG_POINTER(aTopLevelURI);
+
+ return SetDomainPrefInternal(aTopLevelURI, aModes, aIsPrivate, false);
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::SetDomainPrefAndPersistInPrivateBrowsing(
+ nsIURI* aTopLevelURI, nsICookieBannerService::Modes aModes) {
+ NS_ENSURE_ARG_POINTER(aTopLevelURI);
+
+ return SetDomainPrefInternal(aTopLevelURI, aModes, true, true);
+};
+
+nsresult nsCookieBannerService::SetDomainPrefInternal(
+ nsIURI* aTopLevelURI, nsICookieBannerService::Modes aModes,
+ const bool aIsPrivate, const bool aPersistInPrivateBrowsing) {
+ NS_ENSURE_ARG_POINTER(aTopLevelURI);
+
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsresult rv;
+ nsCOMPtr<nsIEffectiveTLDService> eTLDService(
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString baseDomain;
+ rv = eTLDService->GetBaseDomain(aTopLevelURI, 0, baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = mDomainPrefService->SetPref(baseDomain, aModes, aIsPrivate,
+ aPersistInPrivateBrowsing);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::RemoveDomainPref(nsIURI* aTopLevelURI,
+ const bool aIsPrivate) {
+ NS_ENSURE_ARG_POINTER(aTopLevelURI);
+
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsresult rv;
+ nsCOMPtr<nsIEffectiveTLDService> eTLDService(
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID, &rv));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCString baseDomain;
+ rv = eTLDService->GetBaseDomain(aTopLevelURI, 0, baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = mDomainPrefService->RemovePref(baseDomain, aIsPrivate);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::RemoveAllDomainPrefs(const bool aIsPrivate) {
+ if (!mIsInitialized) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsresult rv = mDomainPrefService->RemoveAll(aIsPrivate);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::ResetDomainTelemetryRecord(const nsACString& aDomain) {
+ if (aDomain.IsEmpty()) {
+ mTelemetryReportedTopDomains.Clear();
+ mTelemetryReportedIFrameDomains.Clear();
+ return NS_OK;
+ }
+
+ mTelemetryReportedTopDomains.Remove(aDomain);
+ mTelemetryReportedIFrameDomains.Remove(aDomain);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnStateChange(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest, uint32_t aStateFlags,
+ nsresult aStatus) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnProgressChange(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest,
+ int32_t aCurSelfProgress,
+ int32_t aMaxSelfProgress,
+ int32_t aCurTotalProgress,
+ int32_t aMaxTotalProgress) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnLocationChange(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest, nsIURI* aLocation,
+ uint32_t aFlags) {
+ MOZ_ASSERT(XRE_IsParentProcess());
+
+ if (!aWebProgress || !aLocation) {
+ return NS_OK;
+ }
+
+ RefPtr<dom::BrowsingContext> bc = aWebProgress->GetBrowsingContext();
+ if (!bc) {
+ return NS_OK;
+ }
+
+ // We are only interested in http/https.
+ if (!aLocation->SchemeIs("http") && !aLocation->SchemeIs("https")) {
+ return NS_OK;
+ }
+
+ Maybe<std::tuple<bool, bool>> telemetryData =
+ mReloadTelemetryData.MaybeGet(bc->Top()->Id());
+ if (!telemetryData) {
+ return NS_OK;
+ }
+
+ auto [hasClickRuleInData, hasCookieRuleInData] = telemetryData.ref();
+
+ // If the location change is triggered by a reload, we report the telemetry
+ // for the given top-level browsing context.
+ if (aFlags & LOCATION_CHANGE_RELOAD) {
+ if (!bc->IsTop()) {
+ return NS_OK;
+ }
+
+ // The static value to track if we have enabled the event telemetry for
+ // cookie banner.
+ static bool sTelemetryEventEnabled = false;
+ if (!sTelemetryEventEnabled) {
+ sTelemetryEventEnabled = true;
+ Telemetry::SetEventRecordingEnabled("cookie_banner"_ns, true);
+ }
+
+ glean::cookie_banners::ReloadExtra extra = {
+ .hasClickRule = Some(hasClickRuleInData),
+ .hasCookieRule = Some(hasCookieRuleInData),
+ .noRule = Some(!hasClickRuleInData && !hasCookieRuleInData),
+ };
+ glean::cookie_banners::reload.Record(Some(extra));
+
+ return NS_OK;
+ }
+
+ // Since we handled reload above, we only care about location change due to
+ // the navigation. In this case, the location change flag would be 0x0. For
+ // other cases, we can return from here.
+ if (aFlags) {
+ return NS_OK;
+ }
+
+ bool hasClickRule = false;
+ bool hasCookieRule = false;
+
+ nsresult rv =
+ HasRuleForBrowsingContextInternal(bc, false, hasClickRule, hasCookieRule);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ hasClickRuleInData |= hasClickRule;
+ hasCookieRuleInData |= hasCookieRule;
+
+ mReloadTelemetryData.InsertOrUpdate(
+ bc->Top()->Id(),
+ std::make_tuple(hasClickRuleInData, hasCookieRuleInData));
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnStatusChange(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest, nsresult aStatus,
+ const char16_t* aMessage) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnSecurityChange(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest, uint32_t aState) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsCookieBannerService::OnContentBlockingEvent(nsIWebProgress* aWebProgress,
+ nsIRequest* aRequest,
+ uint32_t aEvent) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+void nsCookieBannerService::DailyReportTelemetry() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Convert modes to strings
+ uint32_t mode = StaticPrefs::cookiebanners_service_mode();
+ uint32_t modePBM = StaticPrefs::cookiebanners_service_mode_privateBrowsing();
+
+ nsCString modeStr = ConvertModeToStringForTelemetry(mode);
+ nsCString modePBMStr = ConvertModeToStringForTelemetry(modePBM);
+
+ nsTArray<nsCString> serviceModeLabels = {
+ "disabled"_ns,
+ "reject"_ns,
+ "reject_or_accept"_ns,
+ "invalid"_ns,
+ };
+
+ // Record the service mode glean.
+ for (const auto& label : serviceModeLabels) {
+ glean::cookie_banners::normal_window_service_mode.Get(label).Set(
+ modeStr.Equals(label));
+ glean::cookie_banners::private_window_service_mode.Get(label).Set(
+ modePBMStr.Equals(label));
+ }
+
+ // Report the state of the cookiebanners.service.detectOnly pref.
+ glean::cookie_banners::service_detect_only.Set(
+ StaticPrefs::cookiebanners_service_detectOnly());
+}
+
+nsresult nsCookieBannerService::GetServiceModeForBrowsingContext(
+ dom::BrowsingContext* aBrowsingContext, bool aIgnoreDomainPref,
+ nsICookieBannerService::Modes* aMode) {
+ MOZ_ASSERT(XRE_IsParentProcess());
+ NS_ENSURE_ARG_POINTER(aBrowsingContext);
+ NS_ENSURE_ARG_POINTER(aMode);
+
+ bool usePBM = false;
+ nsresult rv = aBrowsingContext->GetUsePrivateBrowsing(&usePBM);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint32_t mode;
+ if (usePBM) {
+ mode = StaticPrefs::cookiebanners_service_mode_privateBrowsing();
+ } else {
+ mode = StaticPrefs::cookiebanners_service_mode();
+ }
+
+ // We can skip checking domain-specific prefs if passed the skip pref or if
+ // the mode pref disables the feature. Per-domain modes enabling the service
+ // sites while it's globally disabled is not supported.
+ if (aIgnoreDomainPref || mode == nsICookieBannerService::MODE_DISABLED) {
+ *aMode = static_cast<nsICookieBannerService::Modes>(mode);
+ return NS_OK;
+ }
+
+ // Check if there is a per-domain service mode, disabling the feature for a
+ // specific domain or overriding the mode.
+ RefPtr<dom::WindowGlobalParent> topWGP =
+ aBrowsingContext->Top()->Canonical()->GetCurrentWindowGlobal();
+ NS_ENSURE_TRUE(topWGP, NS_ERROR_FAILURE);
+
+ // Get the base domain from the content principal
+ nsCOMPtr<nsIPrincipal> principal = topWGP->DocumentPrincipal();
+ NS_ENSURE_TRUE(principal, NS_ERROR_NULL_POINTER);
+
+ nsCString baseDomain;
+ rv = principal->GetBaseDomain(baseDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(!baseDomain.IsEmpty(), NS_ERROR_FAILURE);
+
+ // Get the domain preference for the top-level baseDomain, the domain
+ // preference takes precedence over the global pref setting.
+ nsICookieBannerService::Modes domainPref;
+ rv = GetDomainPrefInternal(baseDomain, usePBM, &domainPref);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (domainPref != nsICookieBannerService::MODE_UNSET) {
+ mode = domainPref;
+ }
+
+ *aMode = static_cast<nsICookieBannerService::Modes>(mode);
+
+ return NS_OK;
+}
+
+nsresult nsCookieBannerService::RegisterWebProgressListener(
+ nsISupports* aSubject) {
+ NS_ENSURE_ARG_POINTER(aSubject);
+
+ RefPtr<dom::CanonicalBrowsingContext> bc =
+ static_cast<dom::BrowsingContext*>(aSubject)->Canonical();
+
+ if (!bc) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // We only need to register the web progress listener on the top-level
+ // content browsing context. It will also get the web progress updates on
+ // the child iframes.
+ if (!bc->IsTopContent()) {
+ return NS_OK;
+ }
+
+ mReloadTelemetryData.InsertOrUpdate(bc->Id(), std::make_tuple(false, false));
+
+ return bc->GetWebProgress()->AddProgressListener(
+ this, nsIWebProgress::NOTIFY_LOCATION);
+}
+
+nsresult nsCookieBannerService::RemoveWebProgressListener(
+ nsISupports* aSubject) {
+ NS_ENSURE_ARG_POINTER(aSubject);
+
+ RefPtr<dom::CanonicalBrowsingContext> bc =
+ static_cast<dom::BrowsingContext*>(aSubject)->Canonical();
+
+ if (!bc) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (!bc->IsTopContent()) {
+ return NS_OK;
+ }
+
+ mReloadTelemetryData.Remove(bc->Id());
+
+ // The browsing context web progress can be null when navigating to about
+ // pages.
+ nsCOMPtr<nsIWebProgress> webProgress = bc->GetWebProgress();
+ if (!webProgress) {
+ return NS_OK;
+ }
+
+ return webProgress->RemoveProgressListener(this);
+}
+
+void nsCookieBannerService::ReportRuleLookupTelemetry(
+ const nsACString& aDomain, nsICookieBannerRule* aRule, bool aIsTopLevel) {
+ nsTArray<nsCString> labelsToBeAdded;
+
+ nsAutoCString labelPrefix;
+ if (aIsTopLevel) {
+ labelPrefix.Assign("top_"_ns);
+ } else {
+ labelPrefix.Assign("iframe_"_ns);
+ }
+
+ // The lambda function to submit the telemetry.
+ auto submitTelemetry = [&]() {
+ // Add the load telemetry for every label in the list.
+ for (const auto& label : labelsToBeAdded) {
+ glean::cookie_banners::rule_lookup_by_load.Get(labelPrefix + label)
+ .Add(1);
+ }
+
+ nsTHashSet<nsCStringHashKey>& reportedDomains =
+ aIsTopLevel ? mTelemetryReportedTopDomains
+ : mTelemetryReportedIFrameDomains;
+
+ // For domain telemetry, we only submit once for each domain.
+ if (!reportedDomains.Contains(aDomain)) {
+ for (const auto& label : labelsToBeAdded) {
+ glean::cookie_banners::rule_lookup_by_domain.Get(labelPrefix + label)
+ .Add(1);
+ }
+ reportedDomains.Insert(aDomain);
+ }
+ };
+
+ // No rule found for the domain. Submit telemetry with lookup miss.
+ if (!aRule) {
+ labelsToBeAdded.AppendElement("miss"_ns);
+ labelsToBeAdded.AppendElement("cookie_miss"_ns);
+ labelsToBeAdded.AppendElement("click_miss"_ns);
+
+ submitTelemetry();
+ return;
+ }
+
+ // Check if we have a cookie rule for the domain.
+ bool hasCookieRule = false;
+ bool hasCookieOptIn = false;
+ bool hasCookieOptOut = false;
+ nsTArray<RefPtr<nsICookieRule>> cookies;
+
+ nsresult rv = aRule->GetCookiesOptIn(cookies);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return;
+ }
+
+ if (!cookies.IsEmpty()) {
+ labelsToBeAdded.AppendElement("cookie_hit_opt_in"_ns);
+ hasCookieRule = true;
+ hasCookieOptIn = true;
+ }
+
+ cookies.Clear();
+ rv = aRule->GetCookiesOptOut(cookies);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return;
+ }
+
+ if (!cookies.IsEmpty()) {
+ labelsToBeAdded.AppendElement("cookie_hit_opt_out"_ns);
+ hasCookieRule = true;
+ hasCookieOptOut = true;
+ }
+
+ if (hasCookieRule) {
+ labelsToBeAdded.AppendElement("cookie_hit"_ns);
+ } else {
+ labelsToBeAdded.AppendElement("cookie_miss"_ns);
+ }
+
+ // Check if we have a click rule for the domain.
+ bool hasClickRule = false;
+ bool hasClickOptIn = false;
+ bool hasClickOptOut = false;
+ nsCOMPtr<nsIClickRule> clickRule;
+ rv = aRule->GetClickRule(getter_AddRefs(clickRule));
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return;
+ }
+
+ if (clickRule) {
+ nsAutoCString clickOptIn;
+ nsAutoCString clickOptOut;
+
+ rv = clickRule->GetOptIn(clickOptIn);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return;
+ }
+
+ rv = clickRule->GetOptOut(clickOptOut);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return;
+ }
+
+ if (!clickOptIn.IsEmpty()) {
+ labelsToBeAdded.AppendElement("click_hit_opt_in"_ns);
+ hasClickRule = true;
+ hasClickOptIn = true;
+ }
+
+ if (!clickOptOut.IsEmpty()) {
+ labelsToBeAdded.AppendElement("click_hit_opt_out"_ns);
+ hasClickRule = true;
+ hasClickOptOut = true;
+ }
+
+ if (hasClickRule) {
+ labelsToBeAdded.AppendElement("click_hit"_ns);
+ } else {
+ labelsToBeAdded.AppendElement("click_miss"_ns);
+ }
+ } else {
+ labelsToBeAdded.AppendElement("click_miss"_ns);
+ }
+
+ if (hasCookieRule || hasClickRule) {
+ labelsToBeAdded.AppendElement("hit"_ns);
+ if (hasCookieOptIn || hasClickOptIn) {
+ labelsToBeAdded.AppendElement("hit_opt_in"_ns);
+ }
+
+ if (hasCookieOptOut || hasClickOptOut) {
+ labelsToBeAdded.AppendElement("hit_opt_out"_ns);
+ }
+ } else {
+ labelsToBeAdded.AppendElement("miss"_ns);
+ }
+
+ submitTelemetry();
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/nsCookieBannerService.h b/toolkit/components/cookiebanners/nsCookieBannerService.h
new file mode 100644
index 0000000000..b1b18c4c93
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieBannerService.h
@@ -0,0 +1,141 @@
+/* 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/. */
+#ifndef mozilla_nsCookieBannerService_h__
+#define mozilla_nsCookieBannerService_h__
+
+#include "nsICookieBannerRule.h"
+#include "nsICookieBannerService.h"
+#include "nsICookieBannerListService.h"
+#include "nsCOMPtr.h"
+#include "nsTHashMap.h"
+#include "nsTHashSet.h"
+#include "nsIObserver.h"
+#include "nsIWebProgressListener.h"
+#include "nsWeakReference.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/StaticPtr.h"
+
+namespace mozilla {
+
+class CookieBannerDomainPrefService;
+
+namespace dom {
+class BrowsingContext;
+} // namespace dom
+
+class nsCookieBannerService final : public nsIObserver,
+ public nsICookieBannerService,
+ public nsIWebProgressListener,
+ public nsSupportsWeakReference {
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+ NS_DECL_NSIWEBPROGRESSLISTENER
+
+ NS_DECL_NSICOOKIEBANNERSERVICE
+
+ public:
+ static already_AddRefed<nsCookieBannerService> GetSingleton();
+
+ private:
+ nsCookieBannerService() = default;
+ ~nsCookieBannerService() = default;
+
+ // Whether the service is enabled and ready to accept requests.
+ bool mIsInitialized = false;
+
+ nsCOMPtr<nsICookieBannerListService> mListService;
+ RefPtr<CookieBannerDomainPrefService> mDomainPrefService;
+
+ // Map of site specific cookie banner rules keyed by domain.
+ nsTHashMap<nsCStringHashKey, nsCOMPtr<nsICookieBannerRule>> mRules;
+
+ // Map of global cookie banner rules keyed by id.
+ nsTHashMap<nsCStringHashKey, nsCOMPtr<nsICookieBannerRule>> mGlobalRules;
+
+ // The hash map to track if a top-level browsing context has either click
+ // or cookie rule under its browsing context tree. We use the browsing context
+ // id as the key. And the value is a tuple with two booleans that indicate
+ // the existence of click rule and cookie rule respectively.
+ nsTHashMap<uint64_t, std::tuple<bool, bool>> mReloadTelemetryData;
+
+ // Pref change callback which initializes and shuts down the service. This is
+ // also called on startup.
+ static void OnPrefChange(const char* aPref, void* aData);
+
+ /**
+ * Initializes internal state. Will be called on profile-after-change and on
+ * pref changes.
+ */
+ [[nodiscard]] nsresult Init();
+
+ /**
+ * Cleanup method to be called on shutdown or pref change.
+ */
+ [[nodiscard]] nsresult Shutdown();
+
+ nsresult GetClickRulesForDomainInternal(
+ const nsACString& aDomain, const bool aIsTopLevel,
+ const bool aReportTelemetry, nsTArray<RefPtr<nsIClickRule>>& aRules);
+
+ nsresult GetCookieRulesForDomainInternal(
+ const nsACString& aBaseDomain, const nsICookieBannerService::Modes aMode,
+ const bool aIsTopLevel, const bool aReportTelemetry,
+ nsTArray<RefPtr<nsICookieRule>>& aCookies);
+
+ nsresult HasRuleForBrowsingContextInternal(
+ mozilla::dom::BrowsingContext* aBrowsingContext, bool aIgnoreDomainPref,
+ bool& aHasClickRule, bool& aHasCookieRule);
+
+ nsresult GetRuleForDomain(const nsACString& aDomain, bool aIsTopLevel,
+ nsICookieBannerRule** aRule,
+ bool aReportTelemetry = false);
+
+ /**
+ * Lookup a domain pref by base domain.
+ */
+ nsresult GetDomainPrefInternal(const nsACString& aBaseDomain,
+ const bool aIsPrivate,
+ nsICookieBannerService::Modes* aModes);
+
+ nsresult SetDomainPrefInternal(nsIURI* aTopLevelURI,
+ nsICookieBannerService::Modes aModes,
+ const bool aIsPrivate,
+ const bool aPersistInPrivateBrowsing);
+
+ /**
+ * Get the rule matching the provided URI.
+ * @param aURI - The URI to match the rule for.
+ * @param aIsTopLevel - Whether this rule is requested for the top level frame
+ * (true) or a child frame (false).
+ * @param aRule - Rule to be populated
+ * @param aDomain - Domain that matches the rule, computed from the URI.
+ * @param aReportTelemetry - Whether telemetry should be recorded for this
+ * call.
+ * @returns The matching rule or nullptr if no matching rule is found.
+ */
+ nsresult GetRuleForURI(nsIURI* aURI, bool aIsTopLevel,
+ nsICookieBannerRule** aRule, nsACString& aDomain,
+ bool aReportTelemetry = false);
+
+ nsresult GetServiceModeForBrowsingContext(
+ dom::BrowsingContext* aBrowsingContext, bool aIgnoreDomainPref,
+ nsICookieBannerService::Modes* aMode);
+
+ nsresult RegisterWebProgressListener(nsISupports* aSubject);
+ nsresult RemoveWebProgressListener(nsISupports* aSubject);
+
+ void DailyReportTelemetry();
+
+ // The hash sets of the domains that we have submitted telemetry. We use them
+ // to report once for each domain.
+ nsTHashSet<nsCStringHashKey> mTelemetryReportedTopDomains;
+ nsTHashSet<nsCStringHashKey> mTelemetryReportedIFrameDomains;
+
+ void ReportRuleLookupTelemetry(const nsACString& aDomain,
+ nsICookieBannerRule* aRule, bool aIsTopLevel);
+};
+
+} // namespace mozilla
+
+#endif
diff --git a/toolkit/components/cookiebanners/nsCookieInjector.cpp b/toolkit/components/cookiebanners/nsCookieInjector.cpp
new file mode 100644
index 0000000000..1a516645b9
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieInjector.cpp
@@ -0,0 +1,338 @@
+/* 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/. */
+
+#include "nsCookieInjector.h"
+
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/Logging.h"
+#include "mozilla/RefPtr.h"
+#include "nsDebug.h"
+#include "nsICookieBannerService.h"
+#include "nsICookieManager.h"
+#include "nsIObserverService.h"
+#include "nsCRT.h"
+#include "nsCOMPtr.h"
+#include "mozilla/Components.h"
+#include "Cookie.h"
+#include "nsIHttpProtocolHandler.h"
+#include "mozilla/StaticPrefs_cookiebanners.h"
+#include "nsNetUtil.h"
+
+namespace mozilla {
+
+LazyLogModule gCookieInjectorLog("nsCookieInjector");
+
+StaticRefPtr<nsCookieInjector> sCookieInjectorSingleton;
+
+static constexpr auto kHttpObserverMessage =
+ NS_HTTP_ON_MODIFY_REQUEST_BEFORE_COOKIES_TOPIC;
+
+// List of prefs the injector needs to observe changes for.
+// They are used to determine whether the component should be enabled.
+static constexpr nsLiteralCString kObservedPrefs[] = {
+ "cookiebanners.service.mode"_ns,
+ "cookiebanners.service.mode.privateBrowsing"_ns,
+ "cookiebanners.service.detectOnly"_ns,
+ "cookiebanners.cookieInjector.enabled"_ns,
+};
+
+NS_IMPL_ISUPPORTS(nsCookieInjector, nsIObserver);
+
+already_AddRefed<nsCookieInjector> nsCookieInjector::GetSingleton() {
+ if (!sCookieInjectorSingleton) {
+ sCookieInjectorSingleton = new nsCookieInjector();
+
+ // Register pref listeners.
+ for (const auto& pref : kObservedPrefs) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("Registering pref observer. %s", pref.get()));
+ DebugOnly<nsresult> rv =
+ Preferences::RegisterCallback(&nsCookieInjector::OnPrefChange, pref);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Failed to register pref listener.");
+ }
+
+ // The code above only runs on pref change. Call pref change handler for
+ // inspecting the initial pref state.
+ nsCookieInjector::OnPrefChange(nullptr, nullptr);
+
+ // Clean up on shutdown.
+ RunOnShutdown([] {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug, ("RunOnShutdown"));
+
+ // Unregister pref listeners.
+ for (const auto& pref : kObservedPrefs) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("Unregistering pref observer. %s", pref.get()));
+ DebugOnly<nsresult> rv = Preferences::UnregisterCallback(
+ &nsCookieInjector::OnPrefChange, pref);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Failed to unregister pref listener.");
+ }
+
+ DebugOnly<nsresult> rv = sCookieInjectorSingleton->Shutdown();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "nsCookieInjector::Shutdown failed.");
+ sCookieInjectorSingleton = nullptr;
+ });
+ }
+
+ return do_AddRef(sCookieInjectorSingleton);
+}
+
+// static
+bool nsCookieInjector::IsEnabledForCurrentPrefState() {
+ // For detect-only mode the component should be disabled because it does not
+ // have banner detection capabilities.
+ if (!StaticPrefs::cookiebanners_cookieInjector_enabled() ||
+ StaticPrefs::cookiebanners_service_detectOnly()) {
+ return false;
+ }
+
+ // The cookie injector is initialized if enabled by pref and the main service
+ // is enabled (either in private browsing or normal browsing).
+ return StaticPrefs::cookiebanners_service_mode() !=
+ nsICookieBannerService::MODE_DISABLED ||
+ StaticPrefs::cookiebanners_service_mode_privateBrowsing() !=
+ nsICookieBannerService::MODE_DISABLED;
+}
+
+// static
+void nsCookieInjector::OnPrefChange(const char* aPref, void* aData) {
+ RefPtr<nsCookieInjector> injector = nsCookieInjector::GetSingleton();
+
+ if (IsEnabledForCurrentPrefState()) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Initializing cookie injector after pref change. %s", aPref));
+
+ DebugOnly<nsresult> rv = injector->Init();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "nsCookieInjector::Init failed");
+ return;
+ }
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Disabling cookie injector after pref change. %s", aPref));
+ DebugOnly<nsresult> rv = injector->Shutdown();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "nsCookieInjector::Shutdown failed");
+}
+
+nsresult nsCookieInjector::Init() {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug, ("%s", __FUNCTION__));
+
+ // Check if already initialized.
+ if (mIsInitialized) {
+ return NS_OK;
+ }
+ mIsInitialized = true;
+
+ // Add http observer.
+ nsCOMPtr<nsIObserverService> observerService =
+ mozilla::services::GetObserverService();
+ NS_ENSURE_TRUE(observerService, NS_ERROR_FAILURE);
+
+ return observerService->AddObserver(this, kHttpObserverMessage, false);
+}
+
+nsresult nsCookieInjector::Shutdown() {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug, ("%s", __FUNCTION__));
+
+ // Check if already shutdown.
+ if (!mIsInitialized) {
+ return NS_OK;
+ }
+ mIsInitialized = false;
+
+ // Remove http observer.
+ nsCOMPtr<nsIObserverService> observerService =
+ mozilla::services::GetObserverService();
+ NS_ENSURE_TRUE(observerService, NS_ERROR_FAILURE);
+
+ return observerService->RemoveObserver(this, kHttpObserverMessage);
+}
+
+// nsIObserver
+NS_IMETHODIMP
+nsCookieInjector::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Verbose, ("Observe topic %s", aTopic));
+ if (nsCRT::strcmp(aTopic, kHttpObserverMessage) == 0) {
+ nsCOMPtr<nsIHttpChannel> channel = do_QueryInterface(aSubject);
+ NS_ENSURE_TRUE(channel, NS_ERROR_FAILURE);
+
+ return MaybeInjectCookies(channel, aTopic);
+ }
+
+ return NS_OK;
+}
+
+nsresult nsCookieInjector::MaybeInjectCookies(nsIHttpChannel* aChannel,
+ const char* aTopic) {
+ NS_ENSURE_ARG_POINTER(aChannel);
+ NS_ENSURE_ARG_POINTER(aTopic);
+
+ // Skip non-document loads.
+ if (!aChannel->IsDocument()) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Verbose,
+ ("%s: Skip non-document load.", aTopic));
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo();
+ NS_ENSURE_TRUE(loadInfo, NS_ERROR_FAILURE);
+
+ // Skip non browser tab loads, e.g. extension panels.
+ RefPtr<mozilla::dom::BrowsingContext> browsingContext;
+ nsresult rv = loadInfo->GetBrowsingContext(getter_AddRefs(browsingContext));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!browsingContext ||
+ !browsingContext->GetMessageManagerGroup().EqualsLiteral("browsers")) {
+ MOZ_LOG(
+ gCookieInjectorLog, LogLevel::Verbose,
+ ("%s: Skip load for BC message manager group != browsers.", aTopic));
+ return NS_OK;
+ }
+
+ // Skip non-toplevel loads.
+ if (!loadInfo->GetIsTopLevelLoad()) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("%s: Skip non-top-level load.", aTopic));
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIURI> uri;
+ rv = aChannel->GetURI(getter_AddRefs(uri));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Get hostPort string, used for logging only.
+ nsCString hostPort;
+ rv = uri->GetHostPort(hostPort);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Cookie banner handling rules are fetched from the cookie banner service.
+ nsCOMPtr<nsICookieBannerService> cookieBannerService =
+ components::CookieBannerService::Service(&rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("Looking up rules for %s.", hostPort.get()));
+ nsTArray<RefPtr<nsICookieRule>> rules;
+ rv = cookieBannerService->GetCookiesForURI(
+ uri, NS_UsePrivateBrowsing(aChannel), rules);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // No cookie rules found.
+ if (rules.IsEmpty()) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("Abort: No cookie rules for %s.", hostPort.get()));
+ return NS_OK;
+ }
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Got rules for %s.", hostPort.get()));
+
+ // Get the OA from the channel. We may need to set the cookie in a specific
+ // bucket, for example Private Browsing Mode.
+ OriginAttributes attr = loadInfo->GetOriginAttributes();
+
+ bool hasInjectedCookie = false;
+
+ rv = InjectCookiesFromRules(hostPort, rules, attr, hasInjectedCookie);
+
+ if (hasInjectedCookie) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Debug,
+ ("Setting HasInjectedCookieForCookieBannerHandling on loadInfo"));
+ loadInfo->SetHasInjectedCookieForCookieBannerHandling(true);
+ }
+
+ return rv;
+}
+
+nsresult nsCookieInjector::InjectCookiesFromRules(
+ const nsCString& aHostPort, const nsTArray<RefPtr<nsICookieRule>>& aRules,
+ OriginAttributes& aOriginAttributes, bool& aHasInjectedCookie) {
+ NS_ENSURE_TRUE(aRules.Length(), NS_ERROR_FAILURE);
+ aHasInjectedCookie = false;
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Injecting cookies for %s.", aHostPort.get()));
+
+ // Write cookies from aRules to storage via the cookie manager.
+ nsCOMPtr<nsICookieManager> cookieManager =
+ do_GetService("@mozilla.org/cookiemanager;1");
+ NS_ENSURE_TRUE(cookieManager, NS_ERROR_FAILURE);
+
+ for (nsICookieRule* cookieRule : aRules) {
+ nsCOMPtr<nsICookie> cookie;
+ nsresult rv = cookieRule->GetCookie(getter_AddRefs(cookie));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (NS_WARN_IF(!cookie)) {
+ continue;
+ }
+
+ // Convert to underlying implementer class to get fast non-xpcom property
+ // access.
+ const net::Cookie& c = cookie->AsCookie();
+
+ // Check if the cookie is already set to avoid overwriting any custom
+ // settings.
+ nsCOMPtr<nsICookie> existingCookie;
+ rv = cookieManager->GetCookieNative(c.Host(), c.Path(), c.Name(),
+ &aOriginAttributes,
+ getter_AddRefs(existingCookie));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If a cookie with the same name already exists we need to perform further
+ // checks. We can only overwrite if the rule defines the cookie's value as
+ // the "unset" state.
+ if (existingCookie) {
+ nsCString unsetValue;
+ rv = cookieRule->GetUnsetValue(unsetValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Cookie exists and the rule doesn't specify an unset value, skip.
+ if (unsetValue.IsEmpty()) {
+ MOZ_LOG(
+ gCookieInjectorLog, LogLevel::Info,
+ ("Skip setting already existing cookie. Cookie: %s, %s, %s, %s\n",
+ c.Host().get(), c.Name().get(), c.Path().get(), c.Value().get()));
+ continue;
+ }
+
+ nsAutoCString existingCookieValue;
+ rv = existingCookie->GetValue(existingCookieValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If the unset value specified by the rule does not match the cookie
+ // value. We must not overwrite, skip.
+ if (!unsetValue.Equals(existingCookieValue)) {
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Skip setting already existing cookie. Cookie: %s, %s, %s, "
+ "%s. Rule unset value: %s",
+ c.Host().get(), c.Name().get(), c.Path().get(),
+ c.Value().get(), unsetValue.get()));
+ continue;
+ }
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Overwriting cookie because of known unset value state %s.",
+ unsetValue.get()));
+ }
+
+ MOZ_LOG(gCookieInjectorLog, LogLevel::Info,
+ ("Setting cookie: %s, %s, %s, %s\n", c.Host().get(), c.Name().get(),
+ c.Path().get(), c.Value().get()));
+ rv = cookieManager->AddNative(
+ c.Host(), c.Path(), c.Name(), c.Value(), c.IsSecure(), c.IsHttpOnly(),
+ c.IsSession(), c.Expiry(), &aOriginAttributes, c.SameSite(),
+ static_cast<nsICookie::schemeType>(c.SchemeMap()));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aHasInjectedCookie = true;
+ }
+
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/nsCookieInjector.h b/toolkit/components/cookiebanners/nsCookieInjector.h
new file mode 100644
index 0000000000..377252bffd
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieInjector.h
@@ -0,0 +1,52 @@
+/* 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/. */
+#ifndef mozilla_nsCookieInjector_h__
+#define mozilla_nsCookieInjector_h__
+
+#include "nsCOMPtr.h"
+#include "nsICookieBannerRule.h"
+#include "nsIHttpChannel.h"
+#include "nsIObserver.h"
+
+namespace mozilla {
+
+class nsCookieInjector final : public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+
+ static already_AddRefed<nsCookieInjector> GetSingleton();
+
+ [[nodiscard]] nsresult Init();
+
+ [[nodiscard]] nsresult Shutdown();
+
+ private:
+ nsCookieInjector() = default;
+ ~nsCookieInjector() = default;
+
+ // Whether the component is enabled and ready to inject cookies.
+ bool mIsInitialized = false;
+
+ // Check the current pref state to determine whether the component should be
+ // enabled.
+ static bool IsEnabledForCurrentPrefState();
+
+ // Enables or disables the component when the relevant prefs change.
+ static void OnPrefChange(const char* aPref, void* aData);
+
+ // Called when the http observer topic is dispatched.
+ nsresult MaybeInjectCookies(nsIHttpChannel* aChannel, const char* aTopic);
+
+ // Inserts cookies via the cookie manager given a list of cookie injection
+ // rules.
+ nsresult InjectCookiesFromRules(const nsCString& aHostPort,
+ const nsTArray<RefPtr<nsICookieRule>>& aRules,
+ OriginAttributes& aOriginAttributes,
+ bool& hasInjectedCookie);
+};
+
+} // namespace mozilla
+
+#endif
diff --git a/toolkit/components/cookiebanners/nsCookieRule.cpp b/toolkit/components/cookiebanners/nsCookieRule.cpp
new file mode 100644
index 0000000000..13973aecd1
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieRule.cpp
@@ -0,0 +1,101 @@
+/* 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/. */
+
+#include "nsCookieRule.h"
+
+#include "mozilla/OriginAttributes.h"
+#include "nsICookie.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+#include "Cookie.h"
+#include "prtime.h"
+#include "mozilla/StaticPrefs_cookiebanners.h"
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(nsCookieRule, nsICookieRule)
+
+nsCookieRule::nsCookieRule(bool aIsOptOut, const nsACString& aName,
+ const nsACString& aValue, const nsACString& aHost,
+ const nsACString& aPath, int64_t aExpiryRelative,
+ const nsACString& aUnsetValue, bool aIsSecure,
+ bool aIsHttpOnly, bool aIsSession, int32_t aSameSite,
+ nsICookie::schemeType aSchemeMap) {
+ mExpiryRelative = aExpiryRelative;
+ mUnsetValue = aUnsetValue;
+
+ net::CookieStruct cookieData(nsCString(aName), nsCString(aValue),
+ nsCString(aHost), nsCString(aPath), 0, 0, 0,
+ aIsHttpOnly, aIsSession, aIsSecure, aSameSite,
+ aSameSite, aSchemeMap);
+
+ OriginAttributes attrs;
+ mCookie = net::Cookie::Create(cookieData, attrs);
+}
+
+nsCookieRule::nsCookieRule(const nsCookieRule& aRule) {
+ mCookie = aRule.mCookie->AsCookie().Clone();
+ mExpiryRelative = aRule.mExpiryRelative;
+ mUnsetValue = aRule.mUnsetValue;
+}
+
+/* readonly attribute int64_t expiryRelative; */
+NS_IMETHODIMP nsCookieRule::GetExpiryRelative(int64_t* aExpiryRelative) {
+ NS_ENSURE_ARG_POINTER(aExpiryRelative);
+
+ *aExpiryRelative = mExpiryRelative;
+
+ return NS_OK;
+}
+
+/* readonly attribute AUTF8String unsetValue; */
+NS_IMETHODIMP nsCookieRule::GetUnsetValue(nsACString& aUnsetValue) {
+ aUnsetValue = mUnsetValue;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsCookieRule::CopyForDomain(const nsACString& aDomain,
+ nsICookieRule** aRule) {
+ NS_ENSURE_TRUE(mCookie, NS_ERROR_FAILURE);
+ NS_ENSURE_ARG_POINTER(aRule);
+ NS_ENSURE_TRUE(!aDomain.IsEmpty(), NS_ERROR_FAILURE);
+
+ // Create a copy of the rule + cookie so we can modify the host.
+ RefPtr<nsCookieRule> ruleCopy = new nsCookieRule(*this);
+ RefPtr<net::Cookie> cookie = ruleCopy->mCookie;
+
+ // Only set the host if it's unset.
+ if (!cookie->Host().IsEmpty()) {
+ ruleCopy.forget(aRule);
+ return NS_OK;
+ }
+
+ nsAutoCString host(".");
+ host.Append(aDomain);
+ cookie->SetHost(host);
+
+ ruleCopy.forget(aRule);
+ return NS_OK;
+}
+
+/* readonly attribute nsICookie cookie; */
+NS_IMETHODIMP nsCookieRule::GetCookie(nsICookie** aCookie) {
+ NS_ENSURE_ARG_POINTER(aCookie);
+
+ // Copy cookie and update expiry, creation and last accessed time.
+ RefPtr<net::Cookie> cookieNative = mCookie->Clone();
+
+ int64_t currentTimeInUsec = PR_Now();
+ cookieNative->SetCreationTime(
+ net::Cookie::GenerateUniqueCreationTime(currentTimeInUsec));
+ cookieNative->SetLastAccessed(currentTimeInUsec);
+ cookieNative->SetExpiry((currentTimeInUsec / PR_USEC_PER_SEC) +
+ mExpiryRelative);
+
+ cookieNative.forget(aCookie);
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/cookiebanners/nsCookieRule.h b/toolkit/components/cookiebanners/nsCookieRule.h
new file mode 100644
index 0000000000..e1dd21bcbd
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsCookieRule.h
@@ -0,0 +1,41 @@
+/* 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/. */
+#ifndef mozilla_nsCookieRule_h__
+#define mozilla_nsCookieRule_h__
+
+#include "nsICookieRule.h"
+#include "nsICookie.h"
+#include "nsString.h"
+#include "nsCOMPtr.h"
+#include "mozilla/StaticPrefs_cookiebanners.h"
+
+namespace mozilla {
+
+class nsCookieRule final : public nsICookieRule {
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSICOOKIERULE
+
+ public:
+ nsCookieRule() = default;
+
+ explicit nsCookieRule(bool aIsOptOut, const nsACString& aName,
+ const nsACString& aValue, const nsACString& aHost,
+ const nsACString& aPath, int64_t aExpiryRelative,
+ const nsACString& aUnsetValue, bool aIsSecure,
+ bool aIsHttpOnly, bool aIsSession, int32_t aSameSite,
+ nsICookie::schemeType aSchemeMap);
+
+ private:
+ explicit nsCookieRule(const nsCookieRule& aRule);
+
+ ~nsCookieRule() = default;
+
+ RefPtr<net::Cookie> mCookie;
+ int64_t mExpiryRelative{};
+ nsCString mUnsetValue;
+};
+
+} // namespace mozilla
+
+#endif
diff --git a/toolkit/components/cookiebanners/nsIClickRule.idl b/toolkit/components/cookiebanners/nsIClickRule.idl
new file mode 100644
index 0000000000..340bac843c
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsIClickRule.idl
@@ -0,0 +1,64 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+/*
+ * Rule to specify the CSS selector for detecting and clicking cookie banner.
+ */
+
+[builtinclass, scriptable, uuid(7e104b32-b6db-43f3-b887-573c01acef39)]
+interface nsIClickRule : nsISupports {
+ /**
+ * Where the click rule may be executed.
+ * RUN_TOP: Only run in the top window.
+ * RUN_CHILD: Only run in child frames (including nested frames). Not in the top
+ * window.
+ * RUN_ALL: Run in both the top window and any child frames (including nested
+ * frames).
+ */
+ cenum RunContext : 8 {
+ RUN_TOP,
+ RUN_CHILD,
+ RUN_ALL,
+ };
+
+ /**
+ * The CSS selector for detecting the presence of the cookie banner.
+ */
+ [must_use] readonly attribute ACString presence;
+
+ /**
+ * Whether to skip checking if the banner is visible before clicking it.
+ */
+ [must_use] readonly attribute boolean skipPresenceVisibilityCheck;
+
+
+ /**
+ * Where the click rule should be executed. See RunContext enum. Defaults to
+ * RUN_TOP.
+ */
+ [must_use] readonly attribute nsIClickRule_RunContext runContext;
+
+ /**
+ * The CSS selector for hiding the presence of the cookie banner. If this is
+ * not given, we will use the presence selector to hide the banner.
+ *
+ * Note that we hide the cookie banner before we click it in order to prevent
+ * flickers.
+ */
+ [must_use] readonly attribute ACString hide;
+
+ /*
+ * The CSS selector to to select the element to click for the opt-out option
+ * for the cookie banner.
+ */
+ [must_use] readonly attribute ACString optOut;
+
+ /*
+ * The CSS selector to to select the element to click for the opt-in option
+ * for the cookie banner.
+ */
+ [must_use] readonly attribute ACString optIn;
+};
diff --git a/toolkit/components/cookiebanners/nsICookieBannerListService.idl b/toolkit/components/cookiebanners/nsICookieBannerListService.idl
new file mode 100644
index 0000000000..6ad0d749c8
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsICookieBannerListService.idl
@@ -0,0 +1,37 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+/**
+ * Service singleton for initializing and updating the list of cookie banner
+ * handling rules.
+ */
+[scriptable, uuid(1d8d9470-97d3-4885-a108-44a5c4fb36e2)]
+interface nsICookieBannerListService : nsISupports {
+ /**
+ * Initialize the service. This asynchronously imports the initial set of rules.
+ */
+ void init();
+
+ /**
+ * Same as init but returns a promise which resolves once init is done. Used
+ * for testing when we need to wait for all rules to be imported.
+ */
+ Promise initForTest();
+
+ /**
+ * Shutdown the service. This disables any rule updates.
+ */
+ void shutdown();
+
+ /*
+ * Asynchronously import all rules from RemoteSettings.
+ */
+ void importAllRules();
+};
+
+%{C++
+#define NS_COOKIEBANNERLISTSERVICE_CONTRACTID "@mozilla.org/cookie-banner-list-service;1"
+%}
diff --git a/toolkit/components/cookiebanners/nsICookieBannerRule.idl b/toolkit/components/cookiebanners/nsICookieBannerRule.idl
new file mode 100644
index 0000000000..d8f3b3f4e1
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsICookieBannerRule.idl
@@ -0,0 +1,92 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+ *
+ * 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/. */
+
+#include "nsISupports.idl"
+#include "nsIClickRule.idl"
+#include "nsICookieRule.idl"
+
+/**
+ * A rule containing instructions on how to handle a cookie banner for a specific
+ * domain.
+ */
+
+[builtinclass, scriptable, uuid(eb1904db-e0d1-4760-a721-db76b1ca3e94)]
+interface nsICookieBannerRule : nsISupports {
+ // Unique identifier for the rule. This is usually a UUID.
+ attribute ACString id;
+
+ // Domains of sites to handle the cookie banner for.
+ // An empty array means this is a global rule that should apply to every site.
+ attribute Array<ACString> domains;
+
+ // Cookies that reflect the opt-out or "reject all" state for the cookie baner.
+ readonly attribute Array<nsICookieRule> cookiesOptOut;
+ // Cookies that reflect the opt-in or "accept all" state for the cookie banner.
+ readonly attribute Array<nsICookieRule> cookiesOptIn;
+
+ /**
+ * Get the list of cookies associated with this rule.
+ * aIsOptOut - Whether to return opt-out cookies (true) or opt-in cookies
+ * (false).
+ * aDomain - Optional, when passed returns a copy of each rule with cookie
+ * host set to .<domain>. See nsICookieRule::copyForDomain.
+ */
+ [noscript]
+ Array<nsICookieRule> getCookies(in boolean aIsOptOut, [optional] in ACString aDomain);
+
+ /**
+ * Clear both lists of opt-in and opt-out cookies.
+ */
+ void clearCookies();
+
+ /**
+ * Add an opt-in or opt-out cookie to the rule.
+
+ * aIsOptOut - Whether this is an opt-out cookie (true) or opt-in cookie (false).
+ * aExpiryRelative - See nsICookieRule.
+ * aUnsetValue - See nsICookieRule.
+ * For a description of the other fields see nsICookieManager#addNative.
+ */
+ void addCookie(in boolean aIsOptOut,
+ in ACString aName,
+ in AUTF8String aValue,
+ in AUTF8String aHost,
+ in AUTF8String aPath,
+ in int64_t aExpiryRelative,
+ in AUTF8String aUnsetValue,
+ in boolean aIsSecure,
+ in boolean aIsHttpOnly,
+ in boolean aIsSession,
+ in int32_t aSameSite,
+ in nsICookie_schemeType aSchemeMap);
+
+ // The clicking rule that associates with this rule. The banner auto
+ // clicking will use this rule to detect and click the banner.
+ readonly attribute nsIClickRule clickRule;
+
+ /**
+ * Add a click rule to the rule.
+ *
+ * aPresence - The CSS selector for detecting the presence of the cookie
+ * banner
+ * aSkipPresenceVisibilityCheck - Whether to skip checking if the banner is
+ * visible before clicking it.
+ * aHide - The CSS selector for hiding the cookie banner
+ * aOptOut - The CSS selector for selecting the opt-out banner button
+ * aOptIn - The CSS selector for selecting the opt-in banner button
+ */
+ void addClickRule(in ACString aPresence,
+ [optional] in bool aSkipPresenceVisibilityCheck,
+ [optional] in nsIClickRule_RunContext aRunContext,
+ [optional] in ACString aHide,
+ [optional] in ACString aOptOut,
+ [optional] in ACString aOptIn);
+
+ /**
+ * Clear the click rule.
+ */
+ void clearClickRule();
+};
diff --git a/toolkit/components/cookiebanners/nsICookieBannerService.idl b/toolkit/components/cookiebanners/nsICookieBannerService.idl
new file mode 100644
index 0000000000..b6ae7e90b5
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsICookieBannerService.idl
@@ -0,0 +1,135 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+#include "nsIClickRule.idl"
+#include "nsICookieBannerRule.idl"
+#include "nsICookieRule.idl"
+#include "nsIURI.idl"
+webidl BrowsingContext;
+
+/**
+ * Service singleton which owns the cookie banner feature.
+ * This service owns the cookie banner handling rules.
+ * It initializes both the component for importing rules
+ * (nsICookieBannerListService) and injecting cookies (nsICookieInjector).
+ */
+[scriptable, uuid(eac9cdc4-ecee-49f2-91da-7627e15c1f3c)]
+interface nsICookieBannerService : nsISupports {
+
+ /**
+ * Modes for cookie banner handling
+ * MODE_DISABLED - No cookie banner handling, service disabled.
+ * MODE_REJECT - Only handle banners where selecting "reject all" is possible.
+ * MODE_REJECT_OR_ACCEPT - Prefer selecting "reject all", if not possible
+ * fall back to "accept all".
+ * MODE_UNSET - This represents the service mode is unset, the setting should
+ * fall back to default setting. This is used for the per-domain preferences.
+ */
+ cenum Modes : 8 {
+ MODE_DISABLED,
+ MODE_REJECT,
+ MODE_REJECT_OR_ACCEPT,
+ MODE_UNSET,
+ };
+
+ /**
+ * Whether the feature / service is enabled.
+ */
+ readonly attribute boolean isEnabled;
+
+ /**
+ * Getter for a list of all cookie banner rules. This includes both opt-in and opt-out rules.
+ */
+ readonly attribute Array<nsICookieBannerRule> rules;
+
+ /**
+ * Clears all imported rules. They will be imported again on startup and when
+ * enabling the service. This is currently only used for testing.
+ *
+ * doImport - Whether to import initial rule list after reset. Passing false
+ * will result in an empty rule list.
+ */
+ void resetRules([optional] in boolean doImport);
+
+ /**
+ * Look up all cookie rules for a given top-level URI. Depending on the MODE_
+ * this will return none, only reject rules or accept rules if there is no
+ * reject rule available.
+ */
+ Array<nsICookieRule> getCookiesForURI(in nsIURI aURI, in bool aIsPrivateBrowsing);
+
+ /**
+ * Look up the click rules for a given domain.
+ */
+ Array<nsIClickRule> getClickRulesForDomain(in ACString aDomain,
+ in bool aIsTopLevel);
+
+ /**
+ * Insert a cookie banner rule for a domain. If there was previously a rule
+ * stored with the same domain it will be overwritten.
+ */
+ void insertRule(in nsICookieBannerRule aRule);
+
+ /**
+ * Remove a cookie banner rule.
+ */
+ void removeRule(in nsICookieBannerRule aRule);
+
+ /**
+ * Computes whether we have a rule for the given browsing context or any of
+ * its children. This takes the current cookie banner service mode into
+ * consideration and whether the BC is in private browsing mode.
+ *
+ * This method only takes the global service mode into account. It will ignore
+ * any per-site mode overrides. It is meant for callers to find out whether an
+ * applicable rule exists, even if users have disabled the feature for the
+ * given site.
+ */
+ boolean hasRuleForBrowsingContextTree(in BrowsingContext aBrowsingContext);
+
+ /**
+ * Get the domain preference of the given top-level URI. It will return the
+ * service mode if there is a site preference for the given URI. Otherwise, it
+ * will return MODE_UNSET.
+ */
+ nsICookieBannerService_Modes getDomainPref(in nsIURI aTopLevelURI,
+ in boolean aIsPrivate);
+
+ /**
+ * Set the domain preference of the given top-level URI.
+ */
+ void setDomainPref(in nsIURI aTopLevelURI,
+ in nsICookieBannerService_Modes aMode,
+ in boolean aIsPrivate);
+
+ /**
+ * Set the domain preference of the given top-level URI. It will persist the
+ * domain preference for private browsing.
+ *
+ * WARNING: setting permanent domain preference _will_ leak data in private
+ * browsing. Only use if you understand the consequences and trade-offs. If
+ * you are unsure, |setDomainPref| is very likely what you want to use
+ * instead.
+ */
+ void setDomainPrefAndPersistInPrivateBrowsing(in nsIURI aTopLevelURI,
+ in nsICookieBannerService_Modes aMode);
+
+ /**
+ * Remove the domain preference of the given top-level URI.
+ */
+ void removeDomainPref(in nsIURI aTopLevelURI, in boolean aIsPrivate);
+
+ /**
+ * Remove all domain preferences.
+ */
+ void removeAllDomainPrefs(in boolean aIsPrivate);
+
+ /**
+ * Clears the in-memory set that we use to maintain the domains that we have
+ * reported telemetry. This function will clear the entry for the given
+ * domain. If the domain was not given, it will clear all set.
+ */
+ void resetDomainTelemetryRecord([optional] in ACString aDomain);
+};
diff --git a/toolkit/components/cookiebanners/nsICookieRule.idl b/toolkit/components/cookiebanners/nsICookieRule.idl
new file mode 100644
index 0000000000..41801cc3e5
--- /dev/null
+++ b/toolkit/components/cookiebanners/nsICookieRule.idl
@@ -0,0 +1,45 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+ *
+ * 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/. */
+
+#include "nsISupports.idl"
+#include "nsICookie.idl"
+
+/**
+ * Rule which specifies a cookie to be set in order to handle a cookie banner.
+ */
+
+[builtinclass, scriptable, uuid(bf049b1e-8a05-481f-a120-332ea1bd65ef)]
+interface nsICookieRule : nsISupports {
+ /**
+ * The cookie to set.
+ * When calling this getter creation, expiry and last accessed time are
+ * computed.
+ */
+ readonly attribute nsICookie cookie;
+
+ /**
+ * Expiry time of the cookie in seconds relative to the injection time.
+ * If you want a cookie to expire in 1 month after it has been set, set this
+ * to 2592000.
+ * Defaults to 'cookiebanners.cookieInjector.defaultExpiryRelative'.
+ */
+ readonly attribute int64_t expiryRelative;
+
+ /**
+ * If an existing cookie sets this value it may be overwritten.
+ * This is used for sites which set an explicit cookie state, even if a
+ * cookie banner is still pending.
+ */
+ readonly attribute AUTF8String unsetValue;
+
+ /**
+ * Create a copy of this rule for a domain. If the cookie host is unset it
+ * is set to ".<domain>".
+ * Non-empty host fields will be preserved.
+ */
+ [noscript]
+ nsICookieRule copyForDomain(in ACString aDomain);
+};
diff --git a/toolkit/components/cookiebanners/schema/CookieBannerRule.schema.json b/toolkit/components/cookiebanners/schema/CookieBannerRule.schema.json
new file mode 100644
index 0000000000..48fc674467
--- /dev/null
+++ b/toolkit/components/cookiebanners/schema/CookieBannerRule.schema.json
@@ -0,0 +1,158 @@
+{
+ "type": "object",
+ "definitions": {
+ "cookie": {
+ "type": "object",
+ "required": ["name", "value"],
+ "description": "JSON representation of a cookie to inject.",
+ "properties": {
+ "name": {
+ "title": "Name / Key",
+ "type": "string",
+ "description": "The name of the cookie."
+ },
+ "value": {
+ "title": "Value",
+ "type": "string",
+ "description": "The cookie value."
+ },
+ "host": {
+ "title": "Host",
+ "type": "string",
+ "description": "Host to set cookie for. Defaults to .<domain> if unset."
+ },
+ "path": {
+ "title": "Path",
+ "type": "string",
+ "description": "The path pertaining to the cookie."
+ },
+ "expiryRelative": {
+ "title": "Relative Expiry Time",
+ "type": "number",
+ "description": "Expiry time of the cookie in seconds relative to the injection time. Defaults to pref value for cookiebanners.cookieInjector.defaultExpiryRelative."
+ },
+ "unsetValue": {
+ "title": "Unset Value",
+ "type": "string",
+ "description": "If an existing cookie of the same name sets this value it may be overwritten by this rule."
+ },
+ "isSecure": {
+ "title": "Secure Cookie",
+ "type": "boolean",
+ "description": "true if the cookie was transmitted over ssl, false otherwise."
+ },
+ "sameSite": {
+ "title": "SameSite",
+ "type": "number",
+ "enum": [0, 1, 2],
+ "description": "The SameSite attribute. See nsICookie.idl."
+ },
+ "isSession": {
+ "title": "Session Cookie",
+ "type": "boolean",
+ "description": "true if the cookie is a session cookie."
+ },
+ "schemeMap": {
+ "title": "Scheme Map",
+ "type": "number",
+ "description": "Bitmap of schemes."
+ },
+ "isHTTPOnly": {
+ "title": "HTTP-Only",
+ "type": "boolean",
+ "description": "true if the cookie is an http only cookie."
+ }
+ }
+ }
+ },
+ "title": "Cookie Banner Rule",
+ "required": ["id", "domains"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "title": "ID",
+ "type": "string",
+ "description": "Unique identifier of the rule."
+ },
+ "domains": {
+ "title": "Domains",
+ "type": ["array"],
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true,
+ "description": "List of domains of the sites the rule describes. Leave empty for global rules which should apply to every site."
+ },
+ "cookies": {
+ "title": "Cookies",
+ "description": "Cookie banner related cookies to be injected when the side loads.",
+ "type": "object",
+ "properties": {
+ "optIn": {
+ "title": "Opt-in cookies",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cookie"
+ },
+ "description": "Cookies to be set to signal opt-in state."
+ },
+ "optOut": {
+ "title": "Opt-out cookies",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/cookie"
+ },
+ "description": "Cookies to be set to signal opt-out state."
+ }
+ }
+ },
+ "click": {
+ "title": "Click",
+ "description": "Rules for detection of the cookie banner and simulated clicks.",
+ "type": "object",
+ "properties": {
+ "presence": {
+ "title": "Presence Selector",
+ "type": "string",
+ "description": "Query selector to detect cookie banner element."
+ },
+ "skipPresenceVisibilityCheck": {
+ "title": "Skip Presence Visibility Check",
+ "type": "boolean",
+ "description": "Whether to skip checking if the banner is visible before clicking it."
+ },
+ "runContext": {
+ "title": "Run Context",
+ "type": "string",
+ "enum": ["top", "child", "all"],
+ "description": "Where the click rule should be executed. Defaults to only top window. top: Only in the top window; child: Only in child frames; all: Both top window and child frames."
+ },
+ "hide": {
+ "title": "Hide Selector",
+ "type": "string",
+ "description": "Query selector for element to hide while handling cookie banner. Defaults to 'presence' selector."
+ },
+ "optOut": {
+ "title": "Opt-out Selector",
+ "type": "string",
+ "description": "Query selector for opt-out / reject all button"
+ },
+ "optIn": {
+ "title": "Opt-in Selector",
+ "type": "string",
+ "description": "Query selector for opt-in / accept all button"
+ }
+ },
+ "dependencies": {
+ "hide": ["presence"],
+ "optOut": ["presence"],
+ "optIn": ["presence"]
+ }
+ },
+ "filter_expression": {
+ "type": "string",
+ "description": "This is NOT used by the cookie banner handling feature, but has special functionality in Remote Settings. See https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
+ }
+ },
+ "description": "Rule containing instructions on how to handle a cookie banner on a specific site."
+}
diff --git a/toolkit/components/cookiebanners/schema/CookieBannerRuleUI.schema.json b/toolkit/components/cookiebanners/schema/CookieBannerRuleUI.schema.json
new file mode 100644
index 0000000000..8ae680460d
--- /dev/null
+++ b/toolkit/components/cookiebanners/schema/CookieBannerRuleUI.schema.json
@@ -0,0 +1,22 @@
+{
+ "click": {
+ "ui:order": [
+ "presence",
+ "skipPresenceVisibilityCheck",
+ "hide",
+ "optOut",
+ "optIn",
+ "runContext"
+ ]
+ },
+ "domains": {
+ "ui:title": "Domains"
+ },
+ "cookies": {
+ "ui:order": ["optOut", "optIn"]
+ },
+ "filter_expression": {
+ "ui:title": "RemoteSettings Filter Expression"
+ },
+ "ui:order": ["id", "domains", "cookies", "click", "filter_expression"]
+}
diff --git a/toolkit/components/cookiebanners/schema/README b/toolkit/components/cookiebanners/schema/README
new file mode 100644
index 0000000000..74bae19265
--- /dev/null
+++ b/toolkit/components/cookiebanners/schema/README
@@ -0,0 +1,3 @@
+This directory contains the JSON schemas used for the Remote Settings collection
+which stores cookie banner rules. It is used locally to validate test rules
+imported via pref.
diff --git a/toolkit/components/cookiebanners/test/browser/browser.ini b/toolkit/components/cookiebanners/test/browser/browser.ini
new file mode 100644
index 0000000000..7de42757b2
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser.ini
@@ -0,0 +1,36 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_bannerClicking.js]
+support-files =
+ file_banner.html
+ file_banner_b.html
+ file_delayed_banner.html
+[browser_bannerClicking_events.js]
+support-files =
+ file_banner.html
+[browser_bannerClicking_runContext.js]
+support-files =
+ file_banner.html
+[browser_bannerClicking_slowLoad.js]
+support-files =
+ file_delayed_banner_load.html
+ slowSubresource.sjs
+[browser_bannerClicking_domainPref.js]
+[browser_bannerClicking_globalRules.js]
+[browser_cookiebanner_telemetry.js]
+support-files =
+ file_iframe_banner.html
+[browser_cookiebannerservice_domainPrefs.js]
+[browser_cookiebannerservice_getRules.js]
+[browser_cookiebannerservice_prefs.js]
+[browser_cookiebannerservice.js]
+[browser_cookiebannerservice_hasRuleForBCTree.js]
+[browser_cookieinjector.js]
+support-files =
+ testCookieHeader.sjs
+[browser_bannerClicking_visibilityOverride.js]
+support-files =
+ file_banner_invisible.html
+[browser_cookieinjector_events.js]
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking.js
new file mode 100644
index 0000000000..1b029a3039
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking.js
@@ -0,0 +1,439 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Test that the banner clicking won't click banner if the service is disabled or in detect-only mode.
+ */
+add_task(async function test_cookie_banner_service_disabled() {
+ for (let [serviceMode, detectOnly] of [
+ [Ci.nsICookieBannerService.MODE_DISABLED, false],
+ [Ci.nsICookieBannerService.MODE_DISABLED, true],
+ [Ci.nsICookieBannerService.MODE_REJECT, true],
+ [Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT, true],
+ ]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", serviceMode],
+ ["cookiebanners.service.detectOnly", detectOnly],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ ],
+ ],
+ });
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+/**
+ * Test that the banner clicking won't click banner if there is no rule.
+ */
+add_task(async function test_no_rules() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ // No click telemetry reported.
+ testClickResultTelemetry({});
+});
+
+/**
+ * Test the banner clicking with MODE_REJECT.
+ */
+add_task(async function test_clicking_mode_reject() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ { success: 1, success_dom_content_loaded: 1 },
+ false
+ );
+
+ // No opt out rule for the example.org, the banner shouldn't be clicked.
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ // No matching rule means we don't record any telemetry for clicks.
+ await testClickResultTelemetry({
+ success: 1,
+ success_dom_content_loaded: 1,
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ });
+});
+
+/**
+ * Test the banner clicking with MODE_REJECT_OR_ACCEPT.
+ */
+add_task(async function test_clicking_mode_reject_or_accept() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 1,
+ success_dom_content_loaded: 1,
+ },
+ false
+ );
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: false,
+ expected: "OptIn",
+ });
+
+ await testClickResultTelemetry({
+ success: 2,
+ success_dom_content_loaded: 2,
+ });
+});
+
+/**
+ * Test the banner clicking with the case where the banner is added after
+ * page loads and with a short amount of delay.
+ */
+add_task(async function test_clicking_with_delayed_banner() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ let TEST_PAGE =
+ TEST_ORIGIN_A + TEST_PATH + "file_delayed_banner.html?delay=100";
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_mutation_pre_load: 1,
+ });
+});
+
+/**
+ * Test that the banner clicking in an iframe.
+ */
+add_task(async function test_embedded_iframe() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ await openIframeAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+});
+
+/**
+ * Test banner clicking with the private browsing window.
+ */
+add_task(async function test_pbm() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ await openPageAndVerify({
+ win: pbmWindow,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+});
+
+/**
+ * Tests service mode pref combinations for normal and private browsing.
+ */
+add_task(async function test_pref_pbm_pref() {
+ info("Enable in normal browsing but disable in private browsing.");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ ],
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 1,
+ success_dom_content_loaded: 1,
+ },
+ false
+ );
+
+ await openPageAndVerify({
+ win: pbmWindow,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 1,
+ success_dom_content_loaded: 1,
+ },
+ false
+ );
+
+ info("Disable in normal browsing but enable in private browsing.");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_DISABLED],
+ ],
+ });
+
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 1,
+ success_dom_content_loaded: 1,
+ },
+ false
+ );
+
+ await openPageAndVerify({
+ win: pbmWindow,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 2,
+ success_dom_content_loaded: 2,
+ },
+ false
+ );
+
+ info(
+ "Set normal browsing to REJECT_OR_ACCEPT and private browsing to REJECT."
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ info(
+ "The normal browsing window accepts the banner according to the opt-in rule."
+ );
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: false,
+ expected: "OptIn",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 3,
+ success_dom_content_loaded: 3,
+ },
+ false
+ );
+
+ info(
+ "The private browsing window should not perform any click, because there is only an opt-in rule."
+ );
+ await openPageAndVerify({
+ win: pbmWindow,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+
+ await testClickResultTelemetry({
+ success: 3,
+ success_dom_content_loaded: 3,
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ });
+});
+
+/**
+ * Test that the banner clicking in an iframe with the private browsing window.
+ */
+add_task(async function test_embedded_iframe_pbm() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ await openIframeAndVerify({
+ win: pbmWindow,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_domainPref.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_domainPref.js
new file mode 100644
index 0000000000..ff9b725710
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_domainPref.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Test that domain preference takes precedence over pref settings.
+ */
+add_task(async function test_domain_preference() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ],
+ });
+
+ insertTestClickRules();
+
+ for (let testPBM of [false, true]) {
+ let testWin = window;
+ if (testPBM) {
+ testWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ }
+
+ await testClickResultTelemetry({});
+
+ info(
+ "Make sure the example.org follows the pref setting when there is no domain preference."
+ );
+ await openPageAndVerify({
+ win: testWin,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await testClickResultTelemetry(
+ {
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ },
+ false
+ );
+
+ info("Set the domain preference of example.org to MODE_REJECT_OR_ACCEPT");
+ let uri = Services.io.newURI(TEST_ORIGIN_B);
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ testPBM
+ );
+
+ info(
+ "Verify if domain preference takes precedence over then the pref setting for example.org"
+ );
+ await openPageAndVerify({
+ win: testWin,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: false,
+ expected: "OptIn",
+ });
+
+ Services.cookieBanners.removeAllDomainPrefs(testPBM);
+
+ if (testPBM) {
+ await BrowserTestUtils.closeWindow(testWin);
+ }
+
+ await testClickResultTelemetry({
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+ }
+});
+
+/**
+ * Test that domain preference works on the top-level domain.
+ */
+add_task(async function test_domain_preference_iframe() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ for (let testPBM of [false, true]) {
+ let testWin = window;
+ if (testPBM) {
+ testWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ }
+
+ info(
+ "Make sure the example.org follows the pref setting when there is no domain preference for the top-level example.net."
+ );
+ await openIframeAndVerify({
+ win: testWin,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ await testClickResultTelemetry(
+ {
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ },
+ false
+ );
+
+ info(
+ "Set the domain preference of the top-level domain to MODE_REJECT_OR_ACCEPT"
+ );
+ let uri = Services.io.newURI(TEST_ORIGIN_C);
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ testPBM
+ );
+
+ info(
+ "Verify if domain preference takes precedence over then the pref setting for top-level example.net"
+ );
+ await openIframeAndVerify({
+ win: testWin,
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: false,
+ expected: "OptIn",
+ });
+
+ Services.cookieBanners.removeAllDomainPrefs(testPBM);
+
+ if (testPBM) {
+ await BrowserTestUtils.closeWindow(testWin);
+ }
+
+ await testClickResultTelemetry({
+ fail: 1,
+ fail_no_rule_for_mode: 1,
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+ }
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_events.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_events.js
new file mode 100644
index 0000000000..c1e72cefc5
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_events.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Triggers cookie banner clicking and tests the events dispatched.
+ * @param {*} options - Test options.
+ * @param {nsICookieBannerService::Modes} options.mode - The cookie banner service mode to test with.
+ * @param {boolean} options.detectOnly - Whether the service should be enabled
+ * in detection only mode, where it does not handle banners.
+ * @param {*} options.openPageOptions - Options to overwrite for the openPageAndVerify call.
+ */
+async function runTest({ mode, detectOnly = false, openPageOptions = {} }) {
+ let initFn = () => {
+ // Insert rules only if the feature is enabled.
+ if (Services.cookieBanners.isEnabled) {
+ insertTestClickRules();
+ }
+ };
+
+ let shouldHandleBanner =
+ mode == Ci.nsICookieBannerService.MODE_REJECT && !detectOnly;
+ let testURL = openPageOptions.testURL || TEST_PAGE_A;
+ let triggerFn = async () => {
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL,
+ visible: !shouldHandleBanner,
+ expected: shouldHandleBanner ? "OptOut" : "NoClick",
+ keepTabOpen: true,
+ ...openPageOptions, // Allow test callers to override any options for this method.
+ });
+ };
+
+ await runEventTest({ mode, detectOnly, initFn, triggerFn, testURL });
+
+ // Clean up the test tab opened by openPageAndVerify.
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+/**
+ * Test the banner clicking events with MODE_REJECT.
+ */
+add_task(async function test_events_mode_reject() {
+ await runTest({ mode: Ci.nsICookieBannerService.MODE_REJECT });
+});
+
+/**
+ * Test the banner clicking events with detect-only mode.
+ */
+add_task(async function test_events_mode_detect_only() {
+ await runTest({
+ mode: Ci.nsICookieBannerService.MODE_REJECT,
+ detectOnly: true,
+ });
+ await runTest({
+ mode: Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ detectOnly: true,
+ });
+});
+
+/**
+ * Test the banner clicking events with detect-only mode with a click rule that
+ * only supports opt-in.
+ */
+add_task(async function test_events_mode_detect_only_opt_in_rule() {
+ await runTest({
+ mode: Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ detectOnly: true,
+ openPageOptions: {
+ // We only have an opt-in rule for DOMAIN_B. This ensures we still fire
+ // detection events for that case.
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ shouldHandleBanner: true,
+ expected: "NoClick",
+ },
+ });
+});
+
+/**
+ * Test the banner clicking events with detect-only mode.
+ */
+add_task(async function test_events_mode_disabled() {
+ await runTest({ mode: Ci.nsICookieBannerService.MODE_DISABLED });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_globalRules.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_globalRules.js
new file mode 100644
index 0000000000..c097d31529
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_globalRules.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Test the banner clicking with global rules and MODE_REJECT.
+ */
+add_task(async function test_clicking_global_rules() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.service.enableGlobalRules", true],
+ ],
+ });
+
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info("Inserting global test rules.");
+
+ info(
+ "Add global ruleA which targets an existing banner (presence) with existing buttons. This rule should handle the banner."
+ );
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.id = genUUID();
+ ruleA.domains = [];
+ ruleA.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ "button#optOut",
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleA);
+
+ info(
+ "Add global ruleC which targets an existing banner (presence) but non-existing buttons."
+ );
+ let ruleC = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleC.id = genUUID();
+ ruleC.domains = [];
+ ruleC.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ "button#nonExistingOptOut",
+ "button#nonExistingOptIn"
+ );
+ Services.cookieBanners.insertRule(ruleC);
+
+ info("Add global ruleD which targets a non-existing banner (presence).");
+ let ruleD = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleD.id = genUUID();
+ ruleD.domains = [];
+ ruleD.addClickRule(
+ "div#nonExistingBanner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ null,
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleD);
+
+ await testClickResultTelemetry({});
+
+ info("The global rule ruleA should handle both test pages with div#banner.");
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 1,
+ success_dom_content_loaded: 1,
+ },
+ false
+ );
+
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_B,
+ testURL: TEST_PAGE_B,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 2,
+ success_dom_content_loaded: 2,
+ },
+ false
+ );
+
+ info("No global rule should handle TEST_PAGE_C with div#bannerB.");
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_C,
+ testURL: TEST_PAGE_C,
+ visible: true,
+ expected: "NoClick",
+ bannerId: "bannerB",
+ });
+
+ await testClickResultTelemetry(
+ {
+ success: 2,
+ success_dom_content_loaded: 2,
+ fail: 1,
+ fail_banner_not_found: 1,
+ },
+ false
+ );
+
+ info("Test delayed banner handling with global rules.");
+ let TEST_PAGE =
+ TEST_ORIGIN_A + TEST_PATH + "file_delayed_banner.html?delay=100";
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry({
+ success: 3,
+ success_dom_content_loaded: 2,
+ fail: 1,
+ fail_banner_not_found: 1,
+ success_mutation_pre_load: 1,
+ });
+});
+
+/**
+ * Test that domain-specific rules take precedence over global rules.
+ */
+add_task(async function test_clicking_global_rules_precedence() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.service.enableGlobalRules", true],
+ ],
+ });
+
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info("Inserting global test rules.");
+
+ info(
+ "Add global ruleA which targets an existing banner (presence) with existing buttons."
+ );
+ let ruleGlobal = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleGlobal.id = genUUID();
+ ruleGlobal.domains = [];
+ ruleGlobal.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ "button#optOut",
+ null
+ );
+ Services.cookieBanners.insertRule(ruleGlobal);
+
+ info("Add domain specific rule which also targets the existing banner.");
+ let ruleDomain = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleDomain.id = genUUID();
+ ruleDomain.domains = [TEST_DOMAIN_A];
+ ruleDomain.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ null,
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleDomain);
+
+ await testClickResultTelemetry({});
+
+ info("Test that the domain-specific rule applies, not the global one.");
+ await openPageAndVerify({
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ // Because of the way the rules are setup OptOut would mean the global rule
+ // applies, opt-in means the domain specific rule applies.
+ expected: "OptIn",
+ });
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_dom_content_loaded: 1,
+ });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_runContext.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_runContext.js
new file mode 100644
index 0000000000..7acf94997f
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_runContext.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Insert a test rule with the specified runContext.
+ * @param {RunContext} - The runContext to set for the rule. See nsIClickRule
+ * for documentation.
+ */
+function insertTestRules({ runContext }) {
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info("Inserting test rules. " + JSON.stringify({ runContext }));
+
+ info("Add opt-out click rule for DOMAIN_A.");
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.id = genUUID();
+ ruleA.domains = [TEST_DOMAIN_A];
+
+ ruleA.addClickRule(
+ "div#banner",
+ false,
+ runContext,
+ null,
+ "button#optOut",
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleA);
+}
+
+/**
+ * Test that banner clicking only runs if the context matches the runContext
+ * specified in the click rule.
+ */
+add_task(async function test_embedded_iframe() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestRules({ runContext: Ci.nsIClickRule.RUN_TOP });
+
+ await openIframeAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ insertTestRules({ runContext: Ci.nsIClickRule.RUN_CHILD });
+
+ await openIframeAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: true,
+ expected: "NoClick",
+ });
+
+ insertTestRules({ runContext: Ci.nsIClickRule.RUN_ALL });
+ await openIframeAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE_A,
+ visible: false,
+ expected: "OptOut",
+ });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_slowLoad.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_slowLoad.js
new file mode 100644
index 0000000000..d0d06f3324
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_slowLoad.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(clickTestSetup);
+
+/**
+ * Test the banner clicking with the case where the banner is added shortly after
+ * page load, but the page load itself is delayed.
+ */
+add_task(async function test_clicking_with_delayed_banner() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ insertTestClickRules();
+
+ await testClickResultTelemetry({});
+
+ // A test page that has a delayed load event.
+ let TEST_PAGE = TEST_ORIGIN_A + TEST_PATH + "file_delayed_banner_load.html";
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE,
+ visible: false,
+ expected: "OptOut",
+ });
+
+ await testClickResultTelemetry({
+ success: 1,
+ success_mutation_post_load: 1,
+ });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_visibilityOverride.js b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_visibilityOverride.js
new file mode 100644
index 0000000000..b2746d7c2e
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_bannerClicking_visibilityOverride.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// A test page that has an invisible cookie banner. This simulates sites where
+// the banner is invisible whenever we test for it. See Bug 1793803.
+const TEST_PAGE = TEST_ORIGIN_A + TEST_PATH + "file_banner_invisible.html";
+
+add_setup(clickTestSetup);
+
+/**
+ * Insert a test rule with or without the skipPresenceVisibilityCheck flag.
+ * @param {boolean} skipPresenceVisibilityCheck - Whether to set the flag for
+ * the test rule.
+ */
+function insertVisibilityTestRules(skipPresenceVisibilityCheck) {
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info(
+ "Inserting test rules. " + JSON.stringify({ skipPresenceVisibilityCheck })
+ );
+
+ info("Add opt-out click rule for DOMAIN_A.");
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.id = genUUID();
+ ruleA.domains = [TEST_DOMAIN_A];
+
+ ruleA.addClickRule(
+ "div#banner",
+ skipPresenceVisibilityCheck,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ "button#optOut",
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleA);
+}
+
+/**
+ * Test that we click on an invisible banner element if
+ * skipPresenceVisibilityCheck is set.
+ */
+add_task(async function test_clicking_with_delayed_banner() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ for (let skipPresenceVisibilityCheck of [false, true]) {
+ insertVisibilityTestRules(skipPresenceVisibilityCheck);
+
+ await testClickResultTelemetry({});
+
+ await openPageAndVerify({
+ win: window,
+ domain: TEST_DOMAIN_A,
+ testURL: TEST_PAGE,
+ visible: false,
+ expected: skipPresenceVisibilityCheck ? "OptOut" : "NoClick",
+ });
+
+ let expectedTelemetry;
+ if (skipPresenceVisibilityCheck) {
+ expectedTelemetry = {
+ success: 1,
+ success_dom_content_loaded: 1,
+ };
+ } else {
+ expectedTelemetry = {
+ fail: 1,
+ fail_banner_not_visible: 1,
+ };
+ }
+ await testClickResultTelemetry(expectedTelemetry);
+ }
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebanner_telemetry.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebanner_telemetry.js
new file mode 100644
index 0000000000..f35d4c81c8
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebanner_telemetry.js
@@ -0,0 +1,766 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+const { MODE_DISABLED, MODE_REJECT, MODE_REJECT_OR_ACCEPT, MODE_UNSET } =
+ Ci.nsICookieBannerService;
+
+const TEST_MODES = [
+ MODE_DISABLED,
+ MODE_REJECT,
+ MODE_REJECT_OR_ACCEPT,
+ MODE_UNSET, // Should be recorded as invalid.
+ 99, // Invalid
+ -1, // Invalid
+];
+
+function convertModeToTelemetryString(mode) {
+ switch (mode) {
+ case MODE_DISABLED:
+ return "disabled";
+ case MODE_REJECT:
+ return "reject";
+ case MODE_REJECT_OR_ACCEPT:
+ return "reject_or_accept";
+ }
+
+ return "invalid";
+}
+
+/**
+ * A helper function to verify the cookie rule look up telemetry.
+ *
+ * @param {String} probe The telemetry probe that we want to verify
+ * @param {Array} expected An array of objects that describe the expected value.
+ */
+function verifyLookUpTelemetry(probe, expected) {
+ for (let telemetry of expected) {
+ Assert.equal(
+ telemetry.count,
+ Glean.cookieBanners[probe][telemetry.label].testGetValue()
+ );
+ }
+}
+
+/**
+ * A helper function to verify the reload telemetry.
+ *
+ * @param {Number} length The expected length of the telemetry array.
+ * @param {Number} idx The index of the telemetry to be verified.
+ * @param {Object} expected An object that describe the expected value.
+ */
+function verifyReloadTelemetry(length, idx, expected) {
+ let events = Glean.cookieBanners.reload.testGetValue();
+
+ is(events.length, length, "There is a expected number of reload events.");
+
+ let event = events[idx];
+
+ let { noRule, hasCookieRule, hasClickRule } = expected;
+ is(event.name, "reload", "The reload event has the correct name");
+ is(event.extra.no_rule, noRule, "The extra field 'no_rule' is expected");
+ is(
+ event.extra.has_cookie_rule,
+ hasCookieRule,
+ "The extra field 'has_cookie_rule' is expected"
+ );
+ is(
+ event.extra.has_click_rule,
+ hasClickRule,
+ "The extra field 'has_click_rule' is expected"
+ );
+}
+
+/**
+ * A helper function to reload the browser and wait until it loads.
+ *
+ * @param {Browser} browser The browser object.
+ * @param {String} url The URL to be loaded.
+ */
+async function reloadBrowser(browser, url) {
+ let reloaded = BrowserTestUtils.browserLoaded(browser, false, url);
+
+ // Reload as a user.
+ window.BrowserReload();
+
+ await reloaded;
+}
+/**
+ * A helper function to open the testing page for look up telemetry.
+ *
+ * @param {browser} browser The browser element
+ * @param {boolean} testInTop To indicate the page should be opened in top level
+ * @param {String} page The url of the testing page
+ * @param {String} domain The domain of the testing page
+ */
+async function openLookUpTelemetryTestPage(browser, testInTop, page, domain) {
+ let clickFinishPromise = promiseBannerClickingFinish(domain);
+
+ if (testInTop) {
+ BrowserTestUtils.loadURIString(browser, page);
+ } else {
+ BrowserTestUtils.loadURIString(browser, TEST_ORIGIN_C);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await SpecialPowers.spawn(browser, [page], async testURL => {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = testURL;
+ content.document.body.appendChild(iframe);
+ await ContentTaskUtils.waitForEvent(iframe, "load");
+ });
+ }
+
+ await clickFinishPromise;
+}
+
+add_setup(async function () {
+ // Clear telemetry before starting telemetry test.
+ Services.fog.testResetFOG();
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+ if (
+ Services.prefs.getIntPref("cookiebanners.service.mode") !=
+ Ci.nsICookieBannerService.MODE_DISABLED ||
+ Services.prefs.getIntPref("cookiebanners.service.mode.privateBrowsing") !=
+ Ci.nsICookieBannerService.MODE_DISABLED
+ ) {
+ // Restore original rules.
+ Services.cookieBanners.resetRules(true);
+ }
+
+ // Clear cookies that have been set during testing.
+ await SiteDataTestUtils.clear();
+ });
+
+ await clickTestSetup();
+});
+
+add_task(async function test_service_mode_telemetry() {
+ let service = Cc["@mozilla.org/cookie-banner-service;1"].getService(
+ Ci.nsIObserver
+ );
+
+ for (let mode of TEST_MODES) {
+ for (let modePBM of TEST_MODES) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", mode],
+ ["cookiebanners.service.mode.privateBrowsing", modePBM],
+ ],
+ });
+
+ // Trigger the idle-daily on the cookie banner service.
+ service.observe(null, "idle-daily", null);
+
+ // Verify the telemetry value.
+ for (let label of ["disabled", "reject", "reject_or_accept", "invalid"]) {
+ let expected = convertModeToTelemetryString(mode) == label;
+ let expectedPBM = convertModeToTelemetryString(modePBM) == label;
+
+ is(
+ Glean.cookieBanners.normalWindowServiceMode[label].testGetValue(),
+ expected,
+ `Has set label ${label} to ${expected} for mode ${mode}.`
+ );
+ is(
+ Glean.cookieBanners.privateWindowServiceMode[label].testGetValue(),
+ expectedPBM,
+ `Has set label '${label}' to ${expected} for mode ${modePBM}.`
+ );
+ }
+
+ await SpecialPowers.popPrefEnv();
+ }
+ }
+});
+
+add_task(async function test_rule_lookup_telemetry_no_rule() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ // Clear out all rules.
+ Services.cookieBanners.resetRules(false);
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let context of ["top", "iframe"]) {
+ let isTop = context === "top";
+
+ // Open a test domain. We should record a rule miss because there is no rule
+ // right now
+ info("Open a test domain.");
+ await openLookUpTelemetryTestPage(
+ tab.linkedBrowser,
+ isTop,
+ TEST_ORIGIN_A,
+ TEST_DOMAIN_A
+ );
+
+ let expectedTelemetryOnce = [
+ {
+ label: `${context}_miss`,
+ count: 1,
+ },
+ {
+ label: `${context}_cookie_miss`,
+ count: 1,
+ },
+ {
+ label: `${context}_click_miss`,
+ count: 1,
+ },
+ ];
+ verifyLookUpTelemetry("ruleLookupByLoad", expectedTelemetryOnce);
+ verifyLookUpTelemetry("ruleLookupByDomain", expectedTelemetryOnce);
+
+ info("Open the same domain again.");
+ // Load the same domain again, verify that the telemetry counts increases for
+ // load telemetry not not for domain telemetry.
+ await openLookUpTelemetryTestPage(
+ tab.linkedBrowser,
+ isTop,
+ TEST_ORIGIN_A,
+ TEST_DOMAIN_A
+ );
+
+ let expectedTelemetryTwice = [
+ {
+ label: `${context}_miss`,
+ count: 2,
+ },
+ {
+ label: `${context}_cookie_miss`,
+ count: 2,
+ },
+ {
+ label: `${context}_click_miss`,
+ count: 2,
+ },
+ ];
+ verifyLookUpTelemetry("ruleLookupByLoad", expectedTelemetryTwice);
+ verifyLookUpTelemetry("ruleLookupByDomain", expectedTelemetryOnce);
+ }
+
+ Services.fog.testResetFOG();
+ Services.cookieBanners.resetDomainTelemetryRecord();
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_rule_lookup_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+ insertTestClickRules();
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let type of ["click", "cookie"]) {
+ info(`Running the test for lookup telemetry for ${type} rules.`);
+ // Clear out all rules.
+ Services.cookieBanners.resetRules(false);
+
+ info("Insert rules.");
+ if (type === "click") {
+ insertTestClickRules();
+ } else {
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.domains = [TEST_DOMAIN_A];
+
+ Services.cookieBanners.insertRule(ruleA);
+ ruleA.addCookie(
+ true,
+ `cookieConsent_${TEST_DOMAIN_A}_1`,
+ "optOut1",
+ null,
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ ruleA.addCookie(
+ false,
+ `cookieConsent_${TEST_DOMAIN_A}_2`,
+ "optIn2",
+ null,
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+
+ let ruleB = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleB.domains = [TEST_DOMAIN_B];
+
+ Services.cookieBanners.insertRule(ruleB);
+ ruleB.addCookie(
+ false,
+ `cookieConsent_${TEST_DOMAIN_B}_1`,
+ "optIn1",
+ null,
+ "/",
+ 3600,
+ "UNSET",
+ false,
+ false,
+ true,
+ 0,
+ 0
+ );
+ }
+
+ for (let context of ["top", "iframe"]) {
+ info(`Test in a ${context} context.`);
+ let isTop = context === "top";
+
+ info("Load a domain with opt-in and opt-out clicking rules.");
+ await openLookUpTelemetryTestPage(
+ tab.linkedBrowser,
+ isTop,
+ TEST_ORIGIN_A,
+ TEST_DOMAIN_A
+ );
+
+ let expectedTelemetry = [
+ {
+ label: `${context}_hit`,
+ count: 1,
+ },
+ {
+ label: `${context}_hit_opt_in`,
+ count: 1,
+ },
+ {
+ label: `${context}_hit_opt_out`,
+ count: 1,
+ },
+ {
+ label: `${context}_${type}_hit`,
+ count: 1,
+ },
+ {
+ label: `${context}_${type}_hit_opt_in`,
+ count: 1,
+ },
+ {
+ label: `${context}_${type}_hit_opt_out`,
+ count: 1,
+ },
+ ];
+ verifyLookUpTelemetry("ruleLookupByLoad", expectedTelemetry);
+ verifyLookUpTelemetry("ruleLookupByDomain", expectedTelemetry);
+
+ info("Load a domain with only opt-in clicking rules");
+ await openLookUpTelemetryTestPage(
+ tab.linkedBrowser,
+ isTop,
+ TEST_ORIGIN_B,
+ TEST_DOMAIN_B
+ );
+
+ expectedTelemetry = [
+ {
+ label: `${context}_hit`,
+ count: 2,
+ },
+ {
+ label: `${context}_hit_opt_in`,
+ count: 2,
+ },
+ {
+ label: `${context}_hit_opt_out`,
+ count: 1,
+ },
+ {
+ label: `${context}_${type}_hit`,
+ count: 2,
+ },
+ {
+ label: `${context}_${type}_hit_opt_in`,
+ count: 2,
+ },
+ {
+ label: `${context}_${type}_hit_opt_out`,
+ count: 1,
+ },
+ ];
+
+ verifyLookUpTelemetry("ruleLookupByLoad", expectedTelemetry);
+ verifyLookUpTelemetry("ruleLookupByDomain", expectedTelemetry);
+
+ info(
+ "Load a domain again to verify that we don't collect domain telemetry for this time."
+ );
+ await openLookUpTelemetryTestPage(
+ tab.linkedBrowser,
+ isTop,
+ TEST_ORIGIN_A,
+ TEST_DOMAIN_A
+ );
+
+ // The domain telemetry should't be changed.
+ verifyLookUpTelemetry("ruleLookupByDomain", expectedTelemetry);
+
+ expectedTelemetry = [
+ {
+ label: `${context}_hit`,
+ count: 3,
+ },
+ {
+ label: `${context}_hit_opt_in`,
+ count: 3,
+ },
+ {
+ label: `${context}_hit_opt_out`,
+ count: 2,
+ },
+ {
+ label: `${context}_${type}_hit`,
+ count: 3,
+ },
+ {
+ label: `${context}_${type}_hit_opt_in`,
+ count: 3,
+ },
+ {
+ label: `${context}_${type}_hit_opt_out`,
+ count: 2,
+ },
+ ];
+
+ // Verify that the load telemetry still increases.
+ verifyLookUpTelemetry("ruleLookupByLoad", expectedTelemetry);
+ }
+
+ Services.fog.testResetFOG();
+ Services.cookieBanners.resetDomainTelemetryRecord();
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_reload_telemetry_no_rule() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ // Clear out all rules.
+ Services.cookieBanners.resetRules(false);
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_ORIGIN_A
+ );
+
+ // Make sure there is no reload event at the beginning.
+ let events = Glean.cookieBanners.reload.testGetValue();
+ ok(!events, "No reload event at the beginning.");
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_A + "/");
+
+ // Check the telemetry
+ verifyReloadTelemetry(1, 0, {
+ noRule: "true",
+ hasCookieRule: "false",
+ hasClickRule: "false",
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_reload_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+ insertTestClickRules();
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_ORIGIN_A
+ );
+
+ // Make sure there is no reload event at the beginning.
+ let events = Glean.cookieBanners.reload.testGetValue();
+ ok(!events, "No reload event at the beginning.");
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_A + "/");
+
+ // Check the telemetry
+ verifyReloadTelemetry(1, 0, {
+ noRule: "false",
+ hasCookieRule: "false",
+ hasClickRule: "true",
+ });
+
+ // Add a both click rule and cookie rule for another domain.
+ let cookieRule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ cookieRule.domains = [TEST_DOMAIN_B];
+
+ Services.cookieBanners.insertRule(cookieRule);
+ cookieRule.addCookie(
+ false,
+ `cookieConsent_${TEST_DOMAIN_B}_1`,
+ "optIn1",
+ null,
+ "/",
+ 3600,
+ "UNSET",
+ false,
+ false,
+ true,
+ 0,
+ 0
+ );
+ cookieRule.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_ALL,
+ null,
+ null,
+ "button#optIn"
+ );
+
+ // Load the page with another origin.
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_ORIGIN_B);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_B + "/");
+
+ // Check the telemetry
+ verifyReloadTelemetry(2, 1, {
+ noRule: "false",
+ hasCookieRule: "true",
+ hasClickRule: "true",
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_reload_telemetry_mode_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+ insertTestClickRules();
+
+ // Disable the cookie banner service in normal browsing.
+ // Keep it enabled in PBM so the service stays alive and can still collect telemetry.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_DISABLED],
+ ],
+ });
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_ORIGIN_A
+ );
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_A + "/");
+
+ // Check the telemetry. The reload telemetry should report no rule given that
+ // the service is disabled.
+ verifyReloadTelemetry(1, 0, {
+ noRule: "true",
+ hasCookieRule: "false",
+ hasClickRule: "false",
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_reload_telemetry_mode_reject() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+ insertTestClickRules();
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_ORIGIN_A
+ );
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_A + "/");
+
+ // Check the telemetry. The reload telemetry should report there is click rule
+ // for the domain has opt-out rule.
+ verifyReloadTelemetry(1, 0, {
+ noRule: "false",
+ hasCookieRule: "false",
+ hasClickRule: "true",
+ });
+
+ // Load the page with the domain only has opt-in click rule.
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_ORIGIN_B);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Trigger the reload
+ await reloadBrowser(tab.linkedBrowser, TEST_ORIGIN_B + "/");
+
+ // Check the telemetry. It should report there is no rule because the domain
+ // only has an opt-in click rule.
+ verifyReloadTelemetry(2, 1, {
+ noRule: "true",
+ hasCookieRule: "false",
+ hasClickRule: "false",
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_reload_telemetry_iframe() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ // Clear out all rules.
+ Services.cookieBanners.resetRules(false);
+
+ // Insert a click rule for an iframe case. And add a cookie rule for the same
+ // domain. We shouldn't report there is a cookie rule for iframe because
+ // cookie rules are top-level only.
+ let cookieRule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ cookieRule.domains = [TEST_DOMAIN_A];
+ Services.cookieBanners.insertRule(cookieRule);
+
+ cookieRule.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_CHILD,
+ null,
+ null,
+ "button#optIn"
+ );
+ cookieRule.addCookie(
+ false,
+ `cookieConsent_${TEST_DOMAIN_A}_1`,
+ "optIn1",
+ null,
+ "/",
+ 3600,
+ "UNSET",
+ false,
+ false,
+ true,
+ 0,
+ 0
+ );
+
+ // Open a tab for testing.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_ORIGIN_C + TEST_PATH + "file_iframe_banner.html"
+ );
+
+ // Trigger the reload
+ await reloadBrowser(
+ tab.linkedBrowser,
+ TEST_ORIGIN_C + TEST_PATH + "file_iframe_banner.html"
+ );
+
+ // Check the telemetry
+ verifyReloadTelemetry(1, 0, {
+ noRule: "false",
+ hasCookieRule: "false",
+ hasClickRule: "true",
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_service_detectOnly_telemetry() {
+ let service = Cc["@mozilla.org/cookie-banner-service;1"].getService(
+ Ci.nsIObserver
+ );
+
+ for (let detectOnly of [true, false, true]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["cookiebanners.service.detectOnly", detectOnly]],
+ });
+
+ // Trigger the idle-daily on the cookie banner service.
+ service.observe(null, "idle-daily", null);
+
+ is(
+ Glean.cookieBanners.serviceDetectOnly.testGetValue(),
+ detectOnly,
+ `Has set detect-only metric to ${detectOnly}.`
+ );
+
+ await SpecialPowers.popPrefEnv();
+ }
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice.js
new file mode 100644
index 0000000000..35ebe607ce
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice.js
@@ -0,0 +1,637 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+ if (
+ Services.prefs.getIntPref("cookiebanners.service.mode") !=
+ Ci.nsICookieBannerService.MODE_DISABLED ||
+ Services.prefs.getIntPref("cookiebanners.service.mode.privateBrowsing") !=
+ Ci.nsICookieBannerService.MODE_DISABLED
+ ) {
+ // Restore original rules.
+ Services.cookieBanners.resetRules(true);
+ }
+ });
+});
+
+add_task(async function test_insertAndGetRule() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Clear any preexisting rules");
+ Services.cookieBanners.resetRules(false);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 0,
+ "Cookie banner service has no rules initially."
+ );
+
+ info("Test that we can't import rules with empty domain field.");
+ let ruleInvalid = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.insertRule(ruleInvalid);
+ },
+ /NS_ERROR_FAILURE/,
+ "Inserting an invalid rule missing a domain should throw."
+ );
+
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.domains = ["example.com"];
+
+ Services.cookieBanners.insertRule(rule);
+
+ is(
+ rule.cookiesOptOut.length,
+ 0,
+ "Should not have any opt-out cookies initially"
+ );
+ is(
+ rule.cookiesOptIn.length,
+ 0,
+ "Should not have any opt-in cookies initially"
+ );
+
+ info("Clearing preexisting cookies rules for example.com.");
+ rule.clearCookies();
+
+ info("Adding cookies to the rule for example.com.");
+ rule.addCookie(
+ true,
+ "foo",
+ "bar",
+ "example.com",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ rule.addCookie(
+ true,
+ "foobar",
+ "barfoo",
+ "example.com",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ rule.addCookie(
+ false,
+ "foo",
+ "bar",
+ "foo.example.com",
+ "/myPath",
+ 3600,
+ "",
+ false,
+ false,
+ true,
+ 0,
+ 0
+ );
+
+ info("Adding a click rule to the rule for example.com.");
+ rule.addClickRule(
+ "div#presence",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ "div#hide",
+ "div#optOut",
+ "div#optIn"
+ );
+
+ is(rule.cookiesOptOut.length, 2, "Should have two opt-out cookies.");
+ is(rule.cookiesOptIn.length, 1, "Should have one opt-in cookie.");
+
+ is(
+ Services.cookieBanners.rules.length,
+ 1,
+ "Cookie Banner Service has one rule."
+ );
+
+ let rule2 = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule2.domains = ["example.org"];
+
+ Services.cookieBanners.insertRule(rule2);
+ info("Clearing preexisting cookies rules for example.org.");
+ rule2.clearCookies();
+
+ info("Adding a cookie to the rule for example.org.");
+ rule2.addCookie(
+ false,
+ "foo2",
+ "bar2",
+ "example.org",
+ "/",
+ 0,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+
+ info("Adding a click rule to the rule for example.org.");
+ rule2.addClickRule(
+ "div#presence",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ null,
+ null,
+ "div#optIn"
+ );
+
+ is(
+ Services.cookieBanners.rules.length,
+ 2,
+ "Cookie Banner Service has two rules."
+ );
+
+ info("Getting cookies by URI for example.com.");
+ let ruleArray = Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://example.com"),
+ false
+ );
+ ok(
+ ruleArray && Array.isArray(ruleArray),
+ "getCookiesForURI should return a rule array."
+ );
+ is(ruleArray.length, 2, "rule array should contain 2 rules.");
+ ruleArray.every(rule => {
+ ok(rule instanceof Ci.nsICookieRule, "Rule should have correct type.");
+ is(rule.cookie.host, "example.com", "Rule should have correct host.");
+ });
+
+ info("Clearing cookies of rule.");
+ rule.clearCookies();
+ is(rule.cookiesOptOut.length, 0, "Should have no opt-out cookies.");
+ is(rule.cookiesOptIn.length, 0, "Should have no opt-in cookies.");
+
+ info("Getting the click rule for example.com.");
+ let clickRules = Services.cookieBanners.getClickRulesForDomain(
+ "example.com",
+ true
+ );
+ is(
+ clickRules.length,
+ 1,
+ "There should be one domain-specific click rule for example.com"
+ );
+ let [clickRule] = clickRules;
+
+ is(
+ clickRule.presence,
+ "div#presence",
+ "Should have the correct presence selector."
+ );
+ is(clickRule.hide, "div#hide", "Should have the correct hide selector.");
+ is(
+ clickRule.optOut,
+ "div#optOut",
+ "Should have the correct optOut selector."
+ );
+ is(clickRule.optIn, "div#optIn", "Should have the correct optIn selector.");
+
+ info("Getting cookies by URI for example.org.");
+ let ruleArray2 = Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://example.org"),
+ false
+ );
+ ok(
+ ruleArray2 && Array.isArray(ruleArray2),
+ "getCookiesForURI should return a rule array."
+ );
+ is(
+ ruleArray2.length,
+ 0,
+ "rule array should contain no rules in MODE_REJECT (opt-out only)"
+ );
+
+ info("Getting the click rule for example.org.");
+ let clickRules2 = Services.cookieBanners.getClickRulesForDomain(
+ "example.org",
+ true
+ );
+ is(
+ clickRules2.length,
+ 1,
+ "There should be one domain-specific click rule for example.org"
+ );
+ let [clickRule2] = clickRules2;
+ is(
+ clickRule2.presence,
+ "div#presence",
+ "Should have the correct presence selector."
+ );
+ ok(!clickRule2.hide, "Should have no hide selector.");
+ ok(!clickRule2.optOut, "Should have no target selector.");
+ is(clickRule.optIn, "div#optIn", "Should have the correct optIn selector.");
+
+ info("Switching cookiebanners.service.mode to MODE_REJECT_OR_ACCEPT.");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ ruleArray2 = Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://example.org"),
+ false
+ );
+ ok(
+ ruleArray2 && Array.isArray(ruleArray2),
+ "getCookiesForURI should return a rule array."
+ );
+ is(
+ ruleArray2.length,
+ 1,
+ "rule array should contain one rule in mode MODE_REJECT_OR_ACCEPT (opt-out or opt-in)"
+ );
+
+ info("Calling getCookiesForURI for unknown domain.");
+ let ruleArrayUnknown = Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://example.net"),
+ false
+ );
+ ok(
+ ruleArrayUnknown && Array.isArray(ruleArrayUnknown),
+ "getCookiesForURI should return a rule array."
+ );
+ is(ruleArrayUnknown.length, 0, "rule array should contain no rules.");
+
+ // Cleanup.
+ Services.cookieBanners.resetRules(false);
+});
+
+add_task(async function test_removeRule() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Clear any preexisting rules");
+ Services.cookieBanners.resetRules(false);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 0,
+ "Cookie banner service has no rules initially."
+ );
+
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.id = genUUID();
+ rule.domains = ["example.com"];
+
+ Services.cookieBanners.insertRule(rule);
+
+ let rule2 = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule2.id = genUUID();
+ rule2.domains = ["example.org"];
+
+ Services.cookieBanners.insertRule(rule2);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 2,
+ "Cookie banner service two rules after insert."
+ );
+
+ info("Removing rule for non existent example.net");
+ let ruleExampleNet = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleExampleNet.id = genUUID();
+ ruleExampleNet.domains = ["example.net"];
+ Services.cookieBanners.removeRule(ruleExampleNet);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 2,
+ "Cookie banner service still has two rules."
+ );
+
+ info("Removing rule for non existent global rule.");
+ let ruleGlobal = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleGlobal.id = genUUID();
+ ruleGlobal.domains = [];
+ Services.cookieBanners.removeRule(ruleGlobal);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 2,
+ "Cookie banner service still has two rules."
+ );
+
+ info("Removing rule for example.com");
+ Services.cookieBanners.removeRule(rule);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 1,
+ "Cookie banner service should have one rule left after remove."
+ );
+
+ is(
+ Services.cookieBanners.rules[0].domains[0],
+ "example.org",
+ "It should be the example.org rule."
+ );
+
+ // Cleanup.
+ Services.cookieBanners.resetRules(false);
+});
+
+add_task(async function test_overwriteRule() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Clear any preexisting rules");
+ Services.cookieBanners.resetRules(false);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 0,
+ "Cookie banner service has no rules initially."
+ );
+
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.domains = ["example.com"];
+
+ info("Adding a cookie so we can detect if the rule updates.");
+ rule.addCookie(
+ true,
+ "foo",
+ "original",
+ "example.com",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+
+ info("Adding a click rule so we can detect if the rule updates.");
+ rule.addClickRule("div#original");
+
+ Services.cookieBanners.insertRule(rule);
+
+ let { cookie } = Services.cookieBanners.rules[0].cookiesOptOut[0];
+
+ is(cookie.name, "foo", "Should have set the correct cookie name.");
+ is(cookie.value, "original", "Should have set the correct cookie value.");
+
+ info("Add a new rule with the same domain. It should be overwritten.");
+
+ let ruleNew = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleNew.domains = ["example.com"];
+
+ ruleNew.addCookie(
+ true,
+ "foo",
+ "new",
+ "example.com",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+
+ ruleNew.addClickRule("div#new");
+
+ Services.cookieBanners.insertRule(ruleNew);
+
+ let { cookie: cookieNew } = Services.cookieBanners.rules[0].cookiesOptOut[0];
+ is(cookieNew.name, "foo", "Should have set the original cookie name.");
+ is(cookieNew.value, "new", "Should have set the updated cookie value.");
+
+ let { presence: presenceNew } = Services.cookieBanners.rules[0].clickRule;
+ is(presenceNew, "div#new", "Should have set the updated presence value");
+
+ // Cleanup.
+ Services.cookieBanners.resetRules(false);
+});
+
+add_task(async function test_globalRules() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.service.enableGlobalRules", true],
+ ],
+ });
+
+ info("Clear any preexisting rules");
+ Services.cookieBanners.resetRules(false);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 0,
+ "Cookie banner service has no rules initially."
+ );
+
+ info("Insert a site-specific rule for example.com");
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.id = genUUID();
+ rule.domains = ["example.com"];
+ rule.addCookie(
+ true,
+ "foo",
+ "new",
+ "example.com",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ rule.addClickRule(
+ "#cookieBannerExample",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ "#btnOptOut",
+ "#btnOptIn"
+ );
+ Services.cookieBanners.insertRule(rule);
+
+ info(
+ "Insert a global rule with a cookie and a click rule. The cookie rule shouldn't be used."
+ );
+ let ruleGlobalA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleGlobalA.id = genUUID();
+ ruleGlobalA.domains = [];
+ ruleGlobalA.addCookie(
+ true,
+ "foo",
+ "new",
+ "example.net",
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ ruleGlobalA.addClickRule(
+ "#globalCookieBanner",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ "#btnOptOut",
+ "#btnOptIn"
+ );
+ Services.cookieBanners.insertRule(ruleGlobalA);
+
+ info("Insert a second global rule");
+ let ruleGlobalB = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleGlobalB.id = genUUID();
+ ruleGlobalB.domains = [];
+ ruleGlobalB.addClickRule(
+ "#globalCookieBannerB",
+ false,
+ Ci.nsIClickRule.RUN_TOP,
+ "#btnOptOutB",
+ "#btnOptIn"
+ );
+ Services.cookieBanners.insertRule(ruleGlobalB);
+
+ is(
+ Services.cookieBanners.rules.length,
+ 3,
+ "Cookie Banner Service has three rules."
+ );
+
+ is(
+ Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://example.com"),
+ false
+ ).length,
+ 1,
+ "There should be a cookie rule for example.com"
+ );
+
+ is(
+ Services.cookieBanners.getClickRulesForDomain("example.com", true).length,
+ 1,
+ "There should be a a click rule for example.com"
+ );
+
+ is(
+ Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://thishasnorule.com"),
+ false
+ ).length,
+ 0,
+ "There should be no cookie rule for thishasnorule.com"
+ );
+
+ let clickRules = Services.cookieBanners.getClickRulesForDomain(
+ Services.io.newURI("http://thishasnorule.com"),
+ true
+ );
+ is(
+ clickRules.length,
+ 2,
+ "There should be two click rules for thishasnorule.com"
+ );
+ ok(
+ clickRules.every(rule => rule.presence.startsWith("#globalCookieBanner")),
+ "The returned click rules should be global rules."
+ );
+
+ info("Disabling global rules");
+ await SpecialPowers.pushPrefEnv({
+ set: [["cookiebanners.service.enableGlobalRules", false]],
+ });
+
+ is(
+ Services.cookieBanners.rules.length,
+ 1,
+ "Cookie Banner Service has 1 rule."
+ );
+
+ is(
+ Services.cookieBanners.rules[0].id,
+ rule.id,
+ "It should be the domain specific rule"
+ );
+
+ is(
+ Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("http://thishasnorule.com"),
+ false
+ ).length,
+ 0,
+ "There should be no cookie rule for thishasnorule.com"
+ );
+
+ is(
+ Services.cookieBanners.getClickRulesForDomain(
+ Services.io.newURI("http://thishasnorule.com"),
+ true
+ ).length,
+ 0,
+ "There should be no click rules for thishasnorule.com since global rules are disabled"
+ );
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_domainPrefs.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_domainPrefs.js
new file mode 100644
index 0000000000..700155ada3
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_domainPrefs.js
@@ -0,0 +1,403 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+ if (
+ Services.prefs.getIntPref("cookiebanners.service.mode") !=
+ Ci.nsICookieBannerService.MODE_DISABLED ||
+ Services.prefs.getIntPref("cookiebanners.service.mode.privateBrowsing") !=
+ Ci.nsICookieBannerService.MODE_DISABLED
+ ) {
+ // Restore original rules.
+ Services.cookieBanners.resetRules(true);
+ }
+ });
+});
+
+add_task(async function test_domain_preference() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uri = Services.io.newURI("http://example.com");
+
+ // Check no site preference at the beginning
+ is(
+ Services.cookieBanners.getDomainPref(uri, false),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "There should be no per site preference at the beginning."
+ );
+
+ // Check setting and getting a site preference.
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ false
+ );
+
+ is(
+ Services.cookieBanners.getDomainPref(uri, false),
+ Ci.nsICookieBannerService.MODE_REJECT,
+ "Can get site preference for example.com with the correct value."
+ );
+
+ // Check site preference is shared between http and https.
+ let uriHttps = Services.io.newURI("https://example.com");
+ is(
+ Services.cookieBanners.getDomainPref(uriHttps, false),
+ Ci.nsICookieBannerService.MODE_REJECT,
+ "Can get site preference for example.com in secure context."
+ );
+
+ // Check site preference in the other domain, example.org.
+ let uriOther = Services.io.newURI("https://example.org");
+ is(
+ Services.cookieBanners.getDomainPref(uriOther, false),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "There should be no domain preference for example.org."
+ );
+
+ // Check setting site preference won't affect the other domain.
+ Services.cookieBanners.setDomainPref(
+ uriOther,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ false
+ );
+
+ is(
+ Services.cookieBanners.getDomainPref(uriOther, false),
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ "Can get domain preference for example.org with the correct value."
+ );
+ is(
+ Services.cookieBanners.getDomainPref(uri, false),
+ Ci.nsICookieBannerService.MODE_REJECT,
+ "Can get site preference for example.com"
+ );
+
+ // Check nsICookieBannerService.setDomainPrefAndPersistInPrivateBrowsing().
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT
+ );
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ "Can get site preference for example.com"
+ );
+
+ // Check removing the site preference.
+ Services.cookieBanners.removeDomainPref(uri, false);
+ is(
+ Services.cookieBanners.getDomainPref(uri, false),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "There should be no site preference for example.com."
+ );
+
+ // Check remove all site preferences.
+ Services.cookieBanners.removeAllDomainPrefs(false);
+ is(
+ Services.cookieBanners.getDomainPref(uri, false),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "There should be no site preference for example.com."
+ );
+ is(
+ Services.cookieBanners.getDomainPref(uriOther, false),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "There should be no site preference for example.org."
+ );
+});
+
+add_task(async function test_domain_preference_dont_override_disable_pref() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Adding a domain preference for example.com");
+ let uri = Services.io.newURI("https://example.com");
+
+ // Set a domain preference.
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ false
+ );
+
+ info("Disabling the cookie banner service.");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_DISABLED],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ ],
+ ],
+ });
+
+ info("Verifying if the cookie banner service is disabled.");
+ Assert.throws(
+ () => {
+ Services.cookieBanners.getDomainPref(uri, false);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for getDomainPref."
+ );
+
+ info("Enable the service again in order to clear the domain prefs.");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+ Services.cookieBanners.removeAllDomainPrefs(false);
+});
+
+/**
+ * Test that domain preference is properly cleared when private browsing session
+ * ends.
+ */
+add_task(async function test_domain_preference_cleared_PBM_ends() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Adding a domain preference for example.com in PBM");
+ let uri = Services.io.newURI("https://example.com");
+
+ info("Open a private browsing window.");
+ let PBMWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Set a domain preference for PBM.
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ true
+ );
+
+ info("Verifying if the cookie banner domain pref is set for PBM.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ "The domain pref is properly set for PBM."
+ );
+
+ info("Trigger an ending of a private browsing window session");
+ let PBMSessionEndsObserved = TestUtils.topicObserved(
+ "last-pb-context-exited"
+ );
+
+ // Close the PBM window and wait until it finishes.
+ await BrowserTestUtils.closeWindow(PBMWin);
+ await PBMSessionEndsObserved;
+
+ info("Verify if the private domain pref is cleared.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "The domain pref is properly set for PBM."
+ );
+});
+
+/**
+ * Test that the persistent domain preference won't be cleared when private
+ * browsing session ends.
+ */
+add_task(async function test_persistent_domain_preference_remain_PBM_ends() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Adding a domain preference for example.com in PBM");
+ let uri = Services.io.newURI("https://example.com");
+
+ info("Open a private browsing window.");
+ let PBMWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Set a domain preference for PBM.
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ true
+ );
+
+ info("Verifying if the cookie banner domain pref is set for PBM.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ "The domain pref is properly set for PBM."
+ );
+
+ info("Adding a persistent domain preference for example.org in PBM");
+ let uriPersistent = Services.io.newURI("https://example.org");
+
+ // Set a persistent domain preference for PBM.
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uriPersistent,
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+
+ info("Trigger an ending of a private browsing window session");
+ let PBMSessionEndsObserved = TestUtils.topicObserved(
+ "last-pb-context-exited"
+ );
+
+ // Close the PBM window and wait until it finishes.
+ await BrowserTestUtils.closeWindow(PBMWin);
+ await PBMSessionEndsObserved;
+
+ info("Verify if the private domain pref is cleared.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "The domain pref is properly set for PBM."
+ );
+
+ info("Verify if the persistent private domain pref remains.");
+ is(
+ Services.cookieBanners.getDomainPref(uriPersistent, true),
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ "The persistent domain pref remains for PBM after private session ends."
+ );
+});
+
+add_task(async function test_remove_persistent_domain_pref_in_PBM() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Adding a domain preference for example.com in PBM");
+ let uri = Services.io.newURI("https://example.com");
+
+ info("Open a private browsing window.");
+ let PBMWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Set a persistent domain preference for PBM.
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uri,
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+
+ info("Verifying if the cookie banner domain pref is set for PBM.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ "The domain pref is properly set for PBM."
+ );
+
+ info("Remove the persistent domain pref.");
+ Services.cookieBanners.removeDomainPref(uri, true);
+
+ info("Trigger an ending of a private browsing window session");
+ let PBMSessionEndsObserved = TestUtils.topicObserved(
+ "last-pb-context-exited"
+ );
+
+ // Close the PBM window and wait until it finishes.
+ await BrowserTestUtils.closeWindow(PBMWin);
+ await PBMSessionEndsObserved;
+
+ info(
+ "Verify if the private domain pref is no longer persistent and cleared."
+ );
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "The domain pref is properly set for PBM."
+ );
+});
+
+/**
+ * Test that the persistent state of a domain pref in PMB can be override by new
+ * call without persistent state.
+ */
+add_task(async function test_override_persistent_state_in_PBM() {
+ info("Enabling cookie banner service with MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ info("Adding a domain preference for example.com in PBM");
+ let uri = Services.io.newURI("https://example.com");
+
+ info("Open a private browsing window.");
+ let PBMWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Set a persistent domain preference for PBM.
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uri,
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+
+ info("Trigger an ending of a private browsing window session");
+ let PBMSessionEndsObserved = TestUtils.topicObserved(
+ "last-pb-context-exited"
+ );
+
+ // Close the PBM window and wait until it finishes.
+ await BrowserTestUtils.closeWindow(PBMWin);
+ await PBMSessionEndsObserved;
+
+ info("Verify if the persistent private domain pref remains.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ "The persistent domain pref remains for PBM after private session ends."
+ );
+
+ info("Open a private browsing window again.");
+ PBMWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Override the persistent domain pref with non-persistent domain pref.");
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ true
+ );
+
+ info("Trigger an ending of a private browsing window session again");
+ PBMSessionEndsObserved = TestUtils.topicObserved("last-pb-context-exited");
+
+ // Close the PBM window and wait until it finishes.
+ await BrowserTestUtils.closeWindow(PBMWin);
+ await PBMSessionEndsObserved;
+
+ info("Verify if the private domain pref is cleared.");
+ is(
+ Services.cookieBanners.getDomainPref(uri, true),
+ Ci.nsICookieBannerService.MODE_UNSET,
+ "The domain pref is properly set for PBM."
+ );
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_getRules.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_getRules.js
new file mode 100644
index 0000000000..fcc6078e28
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_getRules.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let testRules = [
+ // Cookie rule with multiple domains.
+ {
+ id: "0e4cdbb8-b688-47e0-9c8b-4db620398dbd",
+ click: {},
+ cookies: {
+ optIn: [
+ {
+ name: "foo",
+ value: "bar",
+ },
+ ],
+ },
+ domains: [TEST_DOMAIN_A, TEST_DOMAIN_B],
+ },
+ // Click rule with single domain.
+ {
+ id: "0560e02c-a50f-4e7b-86e0-d6b7d258eb5f",
+ click: {
+ optOut: "#optOutBtn",
+ presence: "#cookieBanner",
+ },
+ cookies: {},
+ domains: [TEST_DOMAIN_C],
+ },
+];
+
+add_setup(async function () {
+ // Enable the service and insert the test rules.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.listService.testSkipRemoteSettings", true],
+ ["cookiebanners.listService.testRules", JSON.stringify(testRules)],
+ ["cookiebanners.listService.logLevel", "Debug"],
+ ],
+ });
+
+ Services.cookieBanners.resetRules(true);
+});
+
+function ruleCountForDomain(domain) {
+ return Services.cookieBanners.rules.filter(rule =>
+ rule.domains.includes(domain)
+ ).length;
+}
+
+/**
+ * Tests that the rules getter does not return duplicate rules for rules with
+ * multiple domains.
+ */
+add_task(async function test_rules_getter_no_duplicates() {
+ // The rule import is async because it needs to fetch rules from
+ // RemoteSettings. Wait for the test rules to be applied.
+ // See CookieBannerListService#importAllRules.
+ await BrowserTestUtils.waitForCondition(
+ () => Services.cookieBanners.rules.length,
+ "Waiting for test rules to be imported."
+ );
+ is(
+ Services.cookieBanners.rules.length,
+ 2,
+ "Rules getter should only return the two test rules."
+ );
+ is(
+ ruleCountForDomain(TEST_DOMAIN_A),
+ 1,
+ "There should only be one rule with TEST_DOMAIN_A."
+ );
+ is(
+ ruleCountForDomain(TEST_DOMAIN_B),
+ 1,
+ "There should only be one rule with TEST_DOMAIN_B."
+ );
+ is(
+ ruleCountForDomain(TEST_DOMAIN_C),
+ 1,
+ "There should only be one rule with TEST_DOMAIN_C."
+ );
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_hasRuleForBCTree.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_hasRuleForBCTree.js
new file mode 100644
index 0000000000..815fd115e9
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_hasRuleForBCTree.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+let testRules = [
+ // Top-level cookie rule.
+ {
+ id: "87815b2d-a840-4155-8713-f8a26d1f483a",
+ click: {},
+ cookies: {
+ optIn: [
+ {
+ name: "foo",
+ value: "bar",
+ },
+ ],
+ },
+ domains: [TEST_DOMAIN_B],
+ },
+ // Child click rule.
+ {
+ id: "d42bbaee-f96e-47e7-8e81-efc642518e97",
+ click: {
+ optOut: "#optOutBtn",
+ presence: "#cookieBanner",
+ runContext: "child",
+ },
+ cookies: {},
+ domains: [TEST_DOMAIN_C],
+ },
+ // Top level click rule.
+ {
+ id: "19dd1f52-f3e6-4a24-a926-d77f553d1b15",
+ click: {
+ optOut: "#optOutBtn",
+ presence: "#cookieBanner",
+ },
+ cookies: {},
+ domains: [TEST_DOMAIN_A],
+ },
+];
+
+/**
+ * Insert an iframe and wait for it to load.
+ * @param {BrowsingContext} parentBC - The BC the frame to insert under.
+ * @param {string} uri - The URI to load in the frame.
+ * @returns {Promise} - A Promise which resolves once the frame has loaded.
+ */
+function insertIframe(parentBC, uri) {
+ return SpecialPowers.spawn(parentBC, [uri], async testURL => {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = testURL;
+ content.document.body.appendChild(iframe);
+ await ContentTaskUtils.waitForEvent(iframe, "load");
+ return iframe.browsingContext;
+ });
+}
+
+add_setup(async function () {
+ // Enable the service and insert the test rules. We only test
+ // MODE_REJECT_OR_ACCEPT here as the other modes are covered by other tests
+ // already and hasRuleForBrowsingContextTree mostly shares logic with other
+ // service getters.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.listService.testSkipRemoteSettings", true],
+ ["cookiebanners.listService.testRules", JSON.stringify(testRules)],
+ ],
+ });
+
+ // Ensure the test rules have been applied before the first test starts.
+ Services.cookieBanners.resetRules();
+
+ // Visiting sites in this test can set cookies. Clean them up on test exit.
+ registerCleanupFunction(async () => {
+ await SiteDataTestUtils.clear();
+ });
+});
+
+add_task(async function test_unsupported() {
+ let unsupportedURIs = {
+ "about:preferences": /NS_ERROR_FAILURE/,
+ "about:blank": false,
+ };
+
+ for (let [key, value] of Object.entries(unsupportedURIs)) {
+ await BrowserTestUtils.withNewTab(key, async browser => {
+ if (typeof value == "object") {
+ // It's an error code.
+ Assert.throws(
+ () => {
+ Services.cookieBanners.hasRuleForBrowsingContextTree(
+ browser.browsingContext
+ );
+ },
+ value,
+ `Should throw ${value} for hasRuleForBrowsingContextTree call for '${key}'.`
+ );
+ } else {
+ is(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(
+ browser.browsingContext
+ ),
+ value,
+ `Should return expected value for hasRuleForBrowsingContextTree for '${key}'`
+ );
+ }
+ });
+ }
+});
+
+add_task(async function test_hasRuleForBCTree() {
+ info("Test with top level A");
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN_A, async browser => {
+ let bcTop = browser.browsingContext;
+
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top BC for A"
+ );
+
+ info("inserting frame with TEST_ORIGIN_A");
+ let bcChildA = await insertIframe(bcTop, TEST_ORIGIN_A);
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should still have rule when called with top BC for A."
+ );
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildA),
+ "Should not have rule when called with child BC for A, because A has no child click-rule."
+ );
+ });
+
+ info("Test with top level C");
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN_C, async browser => {
+ let bcTop = browser.browsingContext;
+
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have no rule when called with top BC for C, because C only has a child click rule."
+ );
+
+ info("inserting frame with TEST_ORIGIN_C");
+ let bcChildC = await insertIframe(bcTop, TEST_ORIGIN_C);
+
+ info("inserting unrelated frames");
+ await insertIframe(bcTop, "https://itisatracker.org");
+ await insertIframe(bcChildC, "https://itisatracker.org");
+
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top BC for C, because frame C has a child click rule."
+ );
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildC),
+ "Should have rule when called with child BC for C, because it has a child click rule."
+ );
+ });
+
+ info("Test with unrelated top level");
+ await BrowserTestUtils.withNewTab("http://mochi.test:8888", async browser => {
+ let bcTop = browser.browsingContext;
+
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should not have rule for unrelated site."
+ );
+
+ info("inserting frame with TEST_ORIGIN_A");
+ let bcChildA = await insertIframe(bcTop, TEST_ORIGIN_A);
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should still have no rule when called with top BC for A, because click rule for A only applies top-level."
+ );
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildA),
+ "Should have no rule when called with child BC for A."
+ );
+
+ info("inserting frame with TEST_ORIGIN_B");
+ let bcChildB = await insertIframe(bcTop, TEST_ORIGIN_B);
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should still have no rule when called with top BC for A, because cookie rule for B only applies top-level."
+ );
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildA),
+ "Should have no rule when called with child BC for A."
+ );
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildB),
+ "Should have no rule when called with child BC for B."
+ );
+
+ info("inserting nested frame with TEST_ORIGIN_C");
+ let bcChildC = await insertIframe(bcChildB, TEST_ORIGIN_C);
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top level BC because rule for nested iframe C applies."
+ );
+ ok(
+ !Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildA),
+ "Should have no rule when called with child BC for A."
+ );
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildB),
+ "Should have rule when called with child BC for B, because C rule for nested iframe C applies."
+ );
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcChildC),
+ "Should have rule when called with child BC for C, because C rule for nested iframe C applies."
+ );
+ });
+});
+
+/**
+ * Tests that domain prefs are not considered when evaluating whether the
+ * service has an applicable rule for the given BrowsingContext.
+ */
+add_task(async function test_hasRuleForBCTree_ignoreDomainPrefs() {
+ info("Test with top level A");
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN_A, async browser => {
+ let bcTop = browser.browsingContext;
+
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top BC for A"
+ );
+
+ // Disable for current site per domain pref.
+ Services.cookieBanners.setDomainPref(
+ browser.currentURI,
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ false
+ );
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top BC for A, even if mechanism is disabled for A."
+ );
+
+ // Change mode via domain pref.
+ Services.cookieBanners.setDomainPref(
+ browser.currentURI,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ false
+ );
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should still have rule when called with top BC for A, even with custom mode for A"
+ );
+
+ // Cleanup.
+ Services.cookieBanners.removeAllDomainPrefs(false);
+ });
+
+ info("Test with top level B");
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN_B, async browser => {
+ let bcTop = browser.browsingContext;
+
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should have rule when called with top BC for B"
+ );
+
+ // Change mode via domain pref.
+ Services.cookieBanners.setDomainPref(
+ browser.currentURI,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ false
+ );
+ // Rule for B has no opt-out option. Since the mode is overridden to
+ // MODE_REJECT for B we don't have any applicable rule for it. This should
+ // however not be considered for the hasRule getter, it should ignore
+ // per-domain preferences and evaluate based on the global service mode
+ // instead.
+ ok(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(bcTop),
+ "Should still have rule when called with top BC for B, even with custom mode for B"
+ );
+
+ // Cleanup.
+ Services.cookieBanners.removeAllDomainPrefs(false);
+ });
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_prefs.js b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_prefs.js
new file mode 100644
index 0000000000..6e9197281c
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookiebannerservice_prefs.js
@@ -0,0 +1,213 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+ if (
+ Services.prefs.getIntPref("cookiebanners.service.mode") !=
+ Ci.nsICookieBannerService.MODE_DISABLED ||
+ Services.prefs.getIntPref("cookiebanners.service.mode.privateBrowsing") !=
+ Ci.nsICookieBannerService.MODE_DISABLED
+ ) {
+ // Restore original rules.
+ Services.cookieBanners.resetRules(true);
+ }
+ });
+});
+
+add_task(async function test_enabled_pref() {
+ info("Disabling cookie banner service.");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_DISABLED],
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ ],
+ ],
+ });
+
+ ok(Services.cookieBanners, "Services.cookieBanners is defined.");
+ ok(
+ Services.cookieBanners instanceof Ci.nsICookieBannerService,
+ "Services.cookieBanners is nsICookieBannerService"
+ );
+
+ info(
+ "Testing that methods throw NS_ERROR_NOT_AVAILABLE if the service is disabled."
+ );
+
+ Assert.throws(
+ () => {
+ Services.cookieBanners.rules;
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for rules getter."
+ );
+
+ // Create a test rule to attempt to insert.
+ let rule = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ rule.id = genUUID();
+ rule.domains = ["example.com"];
+
+ Assert.throws(
+ () => {
+ Services.cookieBanners.insertRule(rule);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for insertRule."
+ );
+
+ Assert.throws(
+ () => {
+ Services.cookieBanners.removeRule(rule);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for removeRule."
+ );
+
+ Assert.throws(
+ () => {
+ Services.cookieBanners.getCookiesForURI(
+ Services.io.newURI("https://example.com"),
+ false
+ );
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for rules getCookiesForURI."
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.getClickRulesForDomain("example.com", true);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for rules getClickRuleForDomain."
+ );
+ let uri = Services.io.newURI("https://example.com");
+ Assert.throws(
+ () => {
+ Services.cookieBanners.getDomainPref(uri, false);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for getDomainPref."
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ false
+ );
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for setDomainPref."
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT
+ );
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for setDomainPrefAndPersistInPrivateBrowsing."
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.removeDomainPref(uri, false);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for removeDomainPref."
+ );
+ Assert.throws(
+ () => {
+ Services.cookieBanners.removeAllDomainPrefs(false);
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Should have thrown NS_ERROR_NOT_AVAILABLE for removeAllSitePref."
+ );
+
+ info("Enabling cookie banner service. MODE_REJECT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ],
+ });
+
+ let rules = Services.cookieBanners.rules;
+ ok(
+ Array.isArray(rules),
+ "Rules getter should not throw but return an array."
+ );
+
+ info("Enabling cookie banner service. MODE_REJECT_OR_ACCEPT");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ],
+ });
+
+ rules = Services.cookieBanners.rules;
+ ok(
+ Array.isArray(rules),
+ "Rules getter should not throw but return an array."
+ );
+});
+
+/**
+ * Test both service mode pref combinations to ensure the cookie banner service
+ * is (un-)initialized correctly.
+ */
+add_task(async function test_enabled_pref_pbm_combinations() {
+ const MODES = [
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ Ci.nsICookieBannerService.MODE_REJECT,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ];
+
+ // Test all pref combinations
+ MODES.forEach(modeNormal => {
+ MODES.forEach(modePrivate => {
+ info(
+ `cookiebanners.service.mode=${modeNormal}; cookiebanners.service.mode.privateBrowsing=${modePrivate}`
+ );
+ Services.prefs.setIntPref("cookiebanners.service.mode", modeNormal);
+ Services.prefs.setIntPref(
+ "cookiebanners.service.mode.privateBrowsing",
+ modePrivate
+ );
+
+ if (
+ modeNormal == Ci.nsICookieBannerService.MODE_DISABLED &&
+ modePrivate == Ci.nsICookieBannerService.MODE_DISABLED
+ ) {
+ Assert.throws(
+ () => {
+ Services.cookieBanners.rules;
+ },
+ /NS_ERROR_NOT_AVAILABLE/,
+ "Cookie banner service should be disabled. Should throw NS_ERROR_NOT_AVAILABLE for rules getter."
+ );
+ } else {
+ ok(
+ Services.cookieBanners.rules,
+ "Cookie banner service should be enabled, rules getter should not throw."
+ );
+ }
+ });
+ });
+
+ // Cleanup.
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookieinjector.js b/toolkit/components/cookiebanners/test/browser/browser_cookieinjector.js
new file mode 100644
index 0000000000..58fdab7114
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookieinjector.js
@@ -0,0 +1,625 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+const DOMAIN_A = "example.com";
+const DOMAIN_B = "example.org";
+const DOMAIN_C = "example.net";
+
+const ORIGIN_A = "https://" + DOMAIN_A;
+const ORIGIN_A_SUB = `https://test1.${DOMAIN_A}`;
+const ORIGIN_B = "https://" + DOMAIN_B;
+const ORIGIN_C = "https://" + DOMAIN_C;
+
+const TEST_COOKIE_HEADER_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "testCookieHeader.sjs";
+
+/**
+ * Tests that the test domains have no cookies set.
+ */
+function assertNoCookies() {
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_A),
+ "Should not set any cookies for ORIGIN_A"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_C),
+ "Should not set any cookies for ORIGIN_C"
+ );
+}
+
+/**
+ * Loads a list of urls consecutively from the same tab.
+ * @param {string[]} urls - List of urls to load.
+ */
+async function visitTestSites(urls = [ORIGIN_A, ORIGIN_B, ORIGIN_C]) {
+ let tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+
+ for (let url of urls) {
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_setup(cookieInjectorTestSetup);
+
+/**
+ * Tests that no cookies are set if the cookie injection component is disabled
+ * by pref, but the cookie banner service is enabled.
+ */
+add_task(async function test_cookie_injector_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", false],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ await visitTestSites();
+ assertNoCookies();
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Tests that no cookies are set if the cookie injection component is enabled
+ * by pref, but the cookie banner service is disabled or in detect-only mode.
+ */
+add_task(async function test_cookie_banner_service_disabled() {
+ // Enable in PBM so the service is always initialized.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ],
+ });
+
+ for (let [serviceMode, detectOnly] of [
+ [Ci.nsICookieBannerService.MODE_DISABLED, false],
+ [Ci.nsICookieBannerService.MODE_DISABLED, true],
+ [Ci.nsICookieBannerService.MODE_REJECT, true],
+ [Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT, true],
+ ]) {
+ info(`Testing with serviceMode=${serviceMode}; detectOnly=${detectOnly}`);
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", serviceMode],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ["cookiebanners.service.detectOnly", detectOnly],
+ ],
+ });
+
+ await visitTestSites();
+ assertNoCookies();
+
+ await SiteDataTestUtils.clear();
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+/**
+ * Tests that we don't inject cookies if there are no matching rules.
+ */
+add_task(async function test_no_rules() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ await visitTestSites();
+ assertNoCookies();
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Tests that inject the correct cookies for matching rules and MODE_REJECT.
+ */
+add_task(async function test_mode_reject() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ await visitTestSites();
+
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_A, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_A"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+ // RULE_A also includes DOMAIN_C.
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_C, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_C"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Tests that inject the correct cookies for matching rules and
+ * MODE_REJECT_OR_ACCEPT.
+ */
+add_task(async function test_mode_reject_or_accept() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ await visitTestSites();
+
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_A, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_A"
+ );
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_B, [
+ {
+ key: `cookieConsent_${DOMAIN_B}_1`,
+ value: "optIn1",
+ },
+ ]),
+ "Should set opt-in cookies for ORIGIN_B"
+ );
+ // Rule a also includes DOMAIN_C
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_C, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_C"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Test that embedded third-parties do not trigger cookie injection.
+ */
+add_task(async function test_embedded_third_party() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ info("Loading example.com with an iframe for example.org.");
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = "https://example.org";
+ content.document.body.appendChild(iframe);
+ await ContentTaskUtils.waitForEvent(iframe, "load");
+ });
+ });
+
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_A, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for top-level ORIGIN_A"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for embedded ORIGIN_B"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Test that the injected cookies are present in the cookie header for the
+ * initial top level document request.
+ */
+add_task(async function test_cookie_header_and_document() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+ insertTestCookieRules();
+
+ await BrowserTestUtils.withNewTab(TEST_COOKIE_HEADER_URL, async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ const EXPECTED_COOKIE_STR =
+ "cookieConsent_example.com_1=optOut1; cookieConsent_example.com_2=optOut2";
+ is(
+ content.document.body.innerText,
+ EXPECTED_COOKIE_STR,
+ "Sent the correct cookie header."
+ );
+ is(
+ content.document.cookie,
+ EXPECTED_COOKIE_STR,
+ "document.cookie has the correct cookie string."
+ );
+ });
+ });
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Test that cookies get properly injected for private browsing mode.
+ */
+add_task(async function test_pbm() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+ insertTestCookieRules();
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tab = BrowserTestUtils.addTab(pbmWindow.gBrowser, "about:blank");
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, ORIGIN_A);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ SiteDataTestUtils.hasCookies(
+ tab.linkedBrowser.contentPrincipal.origin,
+ [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ],
+ true
+ ),
+ "Should set opt-out cookies for top-level ORIGIN_A in private browsing."
+ );
+
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_A),
+ "Should not set any cookies for ORIGIN_A without PBM origin attribute."
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Test that cookies get properly injected for container tabs.
+ */
+add_task(async function test_container_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+ insertTestCookieRules();
+
+ info("Loading ORIGIN_B in a container tab.");
+ let tab = BrowserTestUtils.addTab(gBrowser, ORIGIN_B, {
+ userContextId: 1,
+ });
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, ORIGIN_B);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_A),
+ "Should not set any cookies for ORIGIN_A"
+ );
+ ok(
+ SiteDataTestUtils.hasCookies(tab.linkedBrowser.contentPrincipal.origin, [
+ {
+ key: `cookieConsent_${DOMAIN_B}_1`,
+ value: "optIn1",
+ },
+ ]),
+ "Should set opt-out cookies for top-level ORIGIN_B in user context 1."
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B in default user context"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Test that if there is already a cookie with the given key, we don't overwrite
+ * it. If the rule sets the unsetValue field, this cookie may be overwritten if
+ * the value matches.
+ */
+add_task(async function test_no_overwrite() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ info("Pre-setting a cookie that should not be overwritten.");
+ SiteDataTestUtils.addToCookies({
+ origin: ORIGIN_A,
+ host: `.${DOMAIN_A}`,
+ path: "/",
+ name: `cookieConsent_${DOMAIN_A}_1`,
+ value: "KEEPME",
+ });
+
+ info("Pre-setting a cookie that should be overwritten, based on its value");
+ SiteDataTestUtils.addToCookies({
+ origin: ORIGIN_B,
+ host: `.${DOMAIN_B}`,
+ path: "/",
+ name: `cookieConsent_${DOMAIN_B}_1`,
+ value: "UNSET",
+ });
+
+ await visitTestSites();
+
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_A, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "KEEPME",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should retain pre-set opt-in cookies for ORIGIN_A, but write new secondary cookie from rules."
+ );
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_B, [
+ {
+ key: `cookieConsent_${DOMAIN_B}_1`,
+ value: "optIn1",
+ },
+ ]),
+ "Should have overwritten cookie for ORIGIN_B, based on its value and the unsetValue rule property."
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+/**
+ * Tests that cookies are injected for the base domain when visiting a
+ * subdomain.
+ */
+add_task(async function test_subdomain() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ await visitTestSites([ORIGIN_A_SUB, ORIGIN_B]);
+
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_A, [
+ {
+ key: `cookieConsent_${DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_A when visiting subdomain."
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function test_site_preference() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", Ci.nsICookieBannerService.MODE_REJECT],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ await visitTestSites();
+
+ ok(
+ !SiteDataTestUtils.hasCookies(ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+
+ info("Set the site preference of example.org to MODE_REJECT_OR_ACCEPT.");
+ let uri = Services.io.newURI(ORIGIN_B);
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ false
+ );
+
+ await visitTestSites();
+ ok(
+ SiteDataTestUtils.hasCookies(ORIGIN_B, [
+ {
+ key: `cookieConsent_${DOMAIN_B}_1`,
+ value: "optIn1",
+ },
+ ]),
+ "Should set opt-in cookies for ORIGIN_B"
+ );
+
+ Services.cookieBanners.removeAllDomainPrefs(false);
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function test_site_preference_pbm() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_REJECT,
+ ],
+ ["cookiebanners.cookieInjector.enabled", true],
+ ],
+ });
+
+ insertTestCookieRules();
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tab = BrowserTestUtils.addTab(pbmWindow.gBrowser, "about:blank");
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, ORIGIN_B);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ !SiteDataTestUtils.hasCookies(
+ tab.linkedBrowser.contentPrincipal.origin,
+ null,
+ true
+ ),
+ "Should not set any cookies for ORIGIN_B in the private window"
+ );
+
+ info(
+ "Set the site preference of example.org to MODE_REJECT_OR_ACCEPT. in the private window."
+ );
+ let uri = Services.io.newURI(ORIGIN_B);
+ Services.cookieBanners.setDomainPref(
+ uri,
+ Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT,
+ true
+ );
+
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, ORIGIN_B);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ SiteDataTestUtils.hasCookies(
+ tab.linkedBrowser.contentPrincipal.origin,
+ [
+ {
+ key: `cookieConsent_${DOMAIN_B}_1`,
+ value: "optIn1",
+ },
+ ],
+ true
+ ),
+ "Should set opt-in cookies for ORIGIN_B"
+ );
+
+ Services.cookieBanners.removeAllDomainPrefs(true);
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(pbmWindow);
+ await SiteDataTestUtils.clear();
+});
diff --git a/toolkit/components/cookiebanners/test/browser/browser_cookieinjector_events.js b/toolkit/components/cookiebanners/test/browser/browser_cookieinjector_events.js
new file mode 100644
index 0000000000..0459731a46
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/browser_cookieinjector_events.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+add_setup(cookieInjectorTestSetup);
+
+/**
+ * Tests that we dispatch cookiebannerhandled and cookiebannerdetected events
+ * for cookie injection.
+ */
+add_task(async function test_events() {
+ let tab;
+
+ let triggerFn = async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_ORIGIN_A);
+ };
+
+ await runEventTest({
+ mode: Ci.nsICookieBannerService.MODE_REJECT,
+ initFn: insertTestCookieRules,
+ triggerFn,
+ testURL: `${TEST_ORIGIN_A}/`,
+ });
+
+ // Clean up the test tab opened by triggerFn.
+ BrowserTestUtils.removeTab(tab);
+
+ ok(
+ SiteDataTestUtils.hasCookies(TEST_ORIGIN_A, [
+ {
+ key: `cookieConsent_${TEST_DOMAIN_A}_1`,
+ value: "optOut1",
+ },
+ {
+ key: `cookieConsent_${TEST_DOMAIN_A}_2`,
+ value: "optOut2",
+ },
+ ]),
+ "Should set opt-out cookies for ORIGIN_A"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(TEST_ORIGIN_B),
+ "Should not set any cookies for ORIGIN_B"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(TEST_ORIGIN_C),
+ "Should not set any cookies for ORIGIN_C"
+ );
+
+ await SiteDataTestUtils.clear();
+});
diff --git a/toolkit/components/cookiebanners/test/browser/file_banner.html b/toolkit/components/cookiebanners/test/browser/file_banner.html
new file mode 100644
index 0000000000..145a21413e
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_banner.html
@@ -0,0 +1,22 @@
+<html>
+<head>
+ <title>A top-level page with cookie banner</title>
+ <script>
+ function clickOptOut() {
+ document.getElementById("result").textContent = "OptOut";
+ }
+
+ function clickOptIn() {
+ document.getElementById("result").textContent = "OptIn";
+ }
+ </script>
+</head>
+<body>
+ <h1>This is the top-level page</h1>
+ <div id="banner">
+ <button id="optOut" onclick="clickOptOut()">OptOut</button>
+ <button id="optIn" onclick="clickOptIn()">OptIn</button>
+ </div>
+ <p id="result">NoClick</p>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/file_banner_b.html b/toolkit/components/cookiebanners/test/browser/file_banner_b.html
new file mode 100644
index 0000000000..bf8df76bd3
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_banner_b.html
@@ -0,0 +1,22 @@
+<html>
+<head>
+ <title>A top-level page with cookie banner</title>
+ <script>
+ function clickOptOut() {
+ document.getElementById("result").textContent = "OptOut";
+ }
+
+ function clickOptIn() {
+ document.getElementById("result").textContent = "OptIn";
+ }
+ </script>
+</head>
+<body>
+ <h1>This is the top-level page</h1>
+ <div id="bannerB">
+ <button id="optOut" onclick="clickOptOut()">OptOut</button>
+ <button id="optIn" onclick="clickOptIn()">OptIn</button>
+ </div>
+ <p id="result">NoClick</p>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/file_banner_invisible.html b/toolkit/components/cookiebanners/test/browser/file_banner_invisible.html
new file mode 100644
index 0000000000..6d6a3a4fe6
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_banner_invisible.html
@@ -0,0 +1,22 @@
+<html>
+<head>
+ <title>A top-level page with cookie banner</title>
+ <script>
+ function clickOptOut() {
+ document.getElementById("result").textContent = "OptOut";
+ }
+
+ function clickOptIn() {
+ document.getElementById("result").textContent = "OptIn";
+ }
+ </script>
+</head>
+<body>
+ <h1>This is the top-level page</h1>
+ <div id="banner" style="display: none;">
+ <button id="optOut" onclick="clickOptOut()">OptOut</button>
+ <button id="optIn" onclick="clickOptIn()">OptIn</button>
+ </div>
+ <p id="result">NoClick</p>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/file_delayed_banner.html b/toolkit/components/cookiebanners/test/browser/file_delayed_banner.html
new file mode 100644
index 0000000000..870a94afc4
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_delayed_banner.html
@@ -0,0 +1,48 @@
+<html>
+<head>
+ <title>A top-level page with delayed cookie banner</title>
+ <script>
+ function clickOptOut() {
+ document.getElementById("result").textContent = "OptOut";
+ }
+
+ function clickOptIn() {
+ document.getElementById("result").textContent = "OptIn";
+ }
+
+ function generateBanner() {
+ let banner = document.createElement("div");
+ banner.id = "banner";
+
+ let buttonOptOut = document.createElement("button");
+ buttonOptOut.id = "OptOut";
+ buttonOptOut.onclick = () => {clickOptOut();};
+
+ let buttonOptIn = document.createElement("button");
+ buttonOptIn.id = "OptIn";
+ buttonOptIn.onclick = () => {clickOptIn();};
+
+ banner.appendChild(buttonOptOut);
+ banner.appendChild(buttonOptIn);
+ document.body.appendChild(banner);
+ }
+
+ window.onload = () => {
+ const params = (new URL(document.location)).searchParams;
+ let delay = 0;
+
+ if (params.has("delay")) {
+ delay = parseInt(params.get("delay"));
+ }
+
+ window.setTimeout(() => {
+ generateBanner();
+ }, delay);
+ };
+ </script>
+</head>
+<body>
+ <h1>This is the top-level page</h1>
+ <p id="result">NoClick</p>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/file_delayed_banner_load.html b/toolkit/components/cookiebanners/test/browser/file_delayed_banner_load.html
new file mode 100644
index 0000000000..011ea3ca97
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_delayed_banner_load.html
@@ -0,0 +1,43 @@
+<html>
+<head>
+ <title>A top-level page with delayed cookie banner</title>
+ <script>
+ function clickOptOut() {
+ document.getElementById("result").textContent = "OptOut";
+ }
+
+ function clickOptIn() {
+ document.getElementById("result").textContent = "OptIn";
+ }
+
+ function generateBanner() {
+ let banner = document.createElement("div");
+ banner.id = "banner";
+
+ let buttonOptOut = document.createElement("button");
+ buttonOptOut.id = "OptOut";
+ buttonOptOut.onclick = () => {clickOptOut();};
+
+ let buttonOptIn = document.createElement("button");
+ buttonOptIn.id = "OptIn";
+ buttonOptIn.onclick = () => {clickOptIn();};
+
+ banner.appendChild(buttonOptOut);
+ banner.appendChild(buttonOptIn);
+ document.body.appendChild(banner);
+ }
+
+ window.onload = () => {
+ generateBanner();
+ };
+ </script>
+ <!-- This will cause DOMContentLoaded and load to be further apart which is
+ required for certain test cases. slowSubresource.sjs will delay the page load
+ event.-->
+ <link rel="stylesheet" href="slowSubresource.sjs">
+</head>
+<body>
+ <h1>This is the top-level page</h1>
+ <p id="result">NoClick</p>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/file_iframe_banner.html b/toolkit/components/cookiebanners/test/browser/file_iframe_banner.html
new file mode 100644
index 0000000000..8bf81a9222
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/file_iframe_banner.html
@@ -0,0 +1,8 @@
+<html>
+<head>
+ <title>A top-level page with iframe cookie banner</title>
+</head>
+<body>
+<iframe src="https://example.com/browser/toolkit/components/cookiebanners/test/browser/file_banner.html"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/cookiebanners/test/browser/head.js b/toolkit/components/cookiebanners/test/browser/head.js
new file mode 100644
index 0000000000..d88de7e20b
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/head.js
@@ -0,0 +1,605 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_DOMAIN_A = "example.com";
+const TEST_DOMAIN_B = "example.org";
+const TEST_DOMAIN_C = "example.net";
+
+const TEST_ORIGIN_A = "https://" + TEST_DOMAIN_A;
+const TEST_ORIGIN_B = "https://" + TEST_DOMAIN_B;
+const TEST_ORIGIN_C = "https://" + TEST_DOMAIN_C;
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ ""
+);
+
+const TEST_PAGE_A = TEST_ORIGIN_A + TEST_PATH + "file_banner.html";
+const TEST_PAGE_B = TEST_ORIGIN_B + TEST_PATH + "file_banner.html";
+// Page C has a different banner element ID than A and B.
+const TEST_PAGE_C = TEST_ORIGIN_C + TEST_PATH + "file_banner_b.html";
+
+function genUUID() {
+ return Services.uuid.generateUUID().number.slice(1, -1);
+}
+
+/**
+ * Common setup function for cookie banner handling tests.
+ */
+async function testSetup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable debug logging.
+ ["cookiebanners.listService.logLevel", "Debug"],
+ // Avoid importing rules from RemoteSettings. They may interfere with test
+ // rules / assertions.
+ ["cookiebanners.listService.testSkipRemoteSettings", true],
+ ],
+ });
+
+ // Reset GLEAN (FOG) telemetry to avoid data bleeding over from other tests.
+ Services.fog.testResetFOG();
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("cookiebanners.service.mode");
+ Services.prefs.clearUserPref("cookiebanners.service.mode.privateBrowsing");
+ if (Services.cookieBanners.isEnabled) {
+ // Restore original rules.
+ Services.cookieBanners.resetRules(true);
+ }
+ });
+}
+
+/**
+ * Setup function for click tests.
+ */
+async function clickTestSetup() {
+ await testSetup();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable debug logging.
+ ["cookiebanners.bannerClicking.logLevel", "Debug"],
+ ["cookiebanners.bannerClicking.testing", true],
+ ["cookiebanners.bannerClicking.timeout", 500],
+ ["cookiebanners.bannerClicking.enabled", true],
+ ["cookiebanners.cookieInjector.enabled", false],
+ ],
+ });
+}
+
+/**
+ * Setup function for cookie injector tests.
+ */
+async function cookieInjectorTestSetup() {
+ await testSetup();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.cookieInjector.enabled", true],
+ // Required to dispatch cookiebanner events.
+ ["cookiebanners.bannerClicking.enabled", true],
+ ],
+ });
+}
+
+/**
+ * A helper function returns a promise which resolves when the banner clicking
+ * is finished for the given domain.
+ *
+ * @param {String} domain the domain that should run the banner clicking.
+ */
+function promiseBannerClickingFinish(domain) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject, topic, data) {
+ if (data != domain) {
+ return;
+ }
+
+ Services.obs.removeObserver(
+ observer,
+ "cookie-banner-test-clicking-finish"
+ );
+ resolve();
+ }, "cookie-banner-test-clicking-finish");
+ });
+}
+
+/**
+ * A helper function to verify the banner state of the given browsingContext.
+ *
+ * @param {BrowsingContext} bc - the browsing context
+ * @param {boolean} visible - if the banner should be visible.
+ * @param {boolean} expected - the expected banner click state.
+ * @param {string} [bannerId] - id of the cookie banner element.
+ */
+async function verifyBannerState(bc, visible, expected, bannerId = "banner") {
+ info("Verify the cookie banner state.");
+
+ await SpecialPowers.spawn(
+ bc,
+ [visible, expected, bannerId],
+ (visible, expected, bannerId) => {
+ let banner = content.document.getElementById(bannerId);
+
+ is(
+ banner.checkVisibility({
+ checkOpacity: true,
+ checkVisibilityCSS: true,
+ }),
+ visible,
+ `The banner element should be ${visible ? "visible" : "hidden"}`
+ );
+
+ let result = content.document.getElementById("result");
+
+ is(result.textContent, expected, "The build click state is correct.");
+ }
+ );
+}
+
+/**
+ * A helper function to open the test page and verify the banner state.
+ *
+ * @param {Window} [win] - the chrome window object.
+ * @param {String} domain - the domain of the testing page.
+ * @param {String} testURL - the url of the testing page.
+ * @param {boolean} visible - if the banner should be visible.
+ * @param {boolean} expected - the expected banner click state.
+ * @param {string} [bannerId] - id of the cookie banner element.
+ * @param {boolean} [keepTabOpen] - whether to leave the tab open after the test
+ * function completed.
+ */
+async function openPageAndVerify({
+ win = window,
+ domain,
+ testURL,
+ visible,
+ expected,
+ bannerId = "banner",
+ keepTabOpen = false,
+}) {
+ info(`Opening ${testURL}`);
+
+ let promise = promiseBannerClickingFinish(domain);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, testURL);
+
+ await promise;
+
+ await verifyBannerState(tab.linkedBrowser, visible, expected, bannerId);
+
+ if (!keepTabOpen) {
+ BrowserTestUtils.removeTab(tab);
+ }
+}
+
+/**
+ * A helper function to open the test page in an iframe and verify the banner
+ * state in the iframe.
+ *
+ * @param {Window} win - the chrome window object.
+ * @param {String} domain - the domain of the testing iframe page.
+ * @param {String} testURL - the url of the testing iframe page.
+ * @param {boolean} visible - if the banner should be visible.
+ * @param {boolean} expected - the expected banner click state.
+ */
+async function openIframeAndVerify({
+ win,
+ domain,
+ testURL,
+ visible,
+ expected,
+}) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TEST_ORIGIN_C
+ );
+
+ let promise = promiseBannerClickingFinish(domain);
+
+ let iframeBC = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [testURL],
+ async testURL => {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = testURL;
+ content.document.body.appendChild(iframe);
+ await ContentTaskUtils.waitForEvent(iframe, "load");
+
+ return iframe.browsingContext;
+ }
+ );
+
+ await promise;
+ await verifyBannerState(iframeBC, visible, expected);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+/**
+ * A helper function to insert testing rules.
+ */
+function insertTestClickRules() {
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info("Inserting test rules.");
+
+ info("Add opt-out click rule for DOMAIN_A.");
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.id = genUUID();
+ ruleA.domains = [TEST_DOMAIN_A];
+
+ ruleA.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_ALL,
+ null,
+ "button#optOut",
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleA);
+
+ info("Add opt-in click rule for DOMAIN_B.");
+ let ruleB = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleB.id = genUUID();
+ ruleB.domains = [TEST_DOMAIN_B];
+
+ ruleB.addClickRule(
+ "div#banner",
+ false,
+ Ci.nsIClickRule.RUN_ALL,
+ null,
+ null,
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleB);
+
+ info("Add global ruleC which targets a non-existing banner (presence).");
+ let ruleC = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleC.id = genUUID();
+ ruleC.domains = [];
+ ruleC.addClickRule(
+ "div#nonExistingBanner",
+ false,
+ Ci.nsIClickRule.RUN_ALL,
+ null,
+ null,
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleC);
+
+ info("Add global ruleD which targets a non-existing banner (presence).");
+ let ruleD = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleD.id = genUUID();
+ ruleD.domains = [];
+ ruleD.addClickRule(
+ "div#nonExistingBanner2",
+ false,
+ Ci.nsIClickRule.RUN_ALL,
+ null,
+ "button#optOut",
+ "button#optIn"
+ );
+ Services.cookieBanners.insertRule(ruleD);
+}
+
+/**
+ * Inserts cookie injection test rules for TEST_DOMAIN_A and TEST_DOMAIN_B.
+ */
+function insertTestCookieRules() {
+ info("Clearing existing rules");
+ Services.cookieBanners.resetRules(false);
+
+ info("Inserting test rules.");
+
+ let ruleA = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleA.domains = [TEST_DOMAIN_A, TEST_DOMAIN_C];
+
+ Services.cookieBanners.insertRule(ruleA);
+ ruleA.addCookie(
+ true,
+ `cookieConsent_${TEST_DOMAIN_A}_1`,
+ "optOut1",
+ null, // empty host to fall back to .<domain>
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+ ruleA.addCookie(
+ true,
+ `cookieConsent_${TEST_DOMAIN_A}_2`,
+ "optOut2",
+ null,
+ "/",
+ 3600,
+ "",
+ false,
+ false,
+ false,
+ 0,
+ 0
+ );
+
+ // An opt-in cookie rule for DOMAIN_B.
+ let ruleB = Cc["@mozilla.org/cookie-banner-rule;1"].createInstance(
+ Ci.nsICookieBannerRule
+ );
+ ruleB.domains = [TEST_DOMAIN_B];
+
+ Services.cookieBanners.insertRule(ruleB);
+ ruleB.addCookie(
+ false,
+ `cookieConsent_${TEST_DOMAIN_B}_1`,
+ "optIn1",
+ TEST_DOMAIN_B,
+ "/",
+ 3600,
+ "UNSET",
+ false,
+ false,
+ true,
+ 0,
+ 0
+ );
+}
+
+/**
+ * Test the Glean.cookieBannersClick.result metric.
+ * @param {*} expected - Object mapping labels to counters. Omitted labels are
+ * asserted to be in initial state (undefined =^ 0)
+ * @param {boolean} [resetFOG] - Whether to reset all FOG telemetry after the
+ * method has finished.
+ */
+async function testClickResultTelemetry(expected, resetFOG = true) {
+ // TODO: Bug 1805653: Enable tests for Linux.
+ if (AppConstants.platform == "linux") {
+ ok(true, "Skip click telemetry tests on linux.");
+ return;
+ }
+
+ // Ensure we have all data from the content process.
+ await Services.fog.testFlushAllChildren();
+
+ let labels = [
+ "success",
+ "success_cookie_injected",
+ "success_dom_content_loaded",
+ "success_mutation_pre_load",
+ "success_mutation_post_load",
+ "fail",
+ "fail_banner_not_found",
+ "fail_banner_not_visible",
+ "fail_button_not_found",
+ "fail_no_rule_for_mode",
+ "fail_actor_destroyed",
+ ];
+
+ let testMetricState = doAssert => {
+ for (let label of labels) {
+ if (doAssert) {
+ is(
+ Glean.cookieBannersClick.result[label].testGetValue(),
+ expected[label],
+ `Counter for label '${label}' has correct state.`
+ );
+ } else if (
+ Glean.cookieBannersClick.result[label].testGetValue() !==
+ expected[label]
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ // Wait for the labeled counter to match the expected state. Returns greedy on
+ // mismatch.
+ try {
+ await TestUtils.waitForCondition(
+ testMetricState,
+ "Waiting for cookieBannersClick.result metric to match."
+ );
+ } finally {
+ // Test again but this time with assertions and test all labels.
+ testMetricState(true);
+
+ // Reset telemetry, even if the test condition above throws. This is to
+ // avoid failing subsequent tests in case of a test failure.
+ if (resetFOG) {
+ Services.fog.testResetFOG();
+ }
+ }
+}
+
+/**
+ * Triggers a cookie banner handling feature and tests the events dispatched.
+ * @param {*} options - Test options.
+ * @param {nsICookieBannerService::Modes} options.mode - The cookie banner
+ * service mode to test with.
+ * @param {boolean} options.detectOnly - Whether the service should be enabled
+ * in detection only mode, where it does not handle banners.
+ * @param {function} options.initFn - Function to call for test initialization.
+ * @param {function} options.triggerFn - Function to call to trigger the banner
+ * handling feature.
+ * @param {string} options.testURL - URL where the test will trigger the banner
+ * handling feature.
+ * @returns {Promise} Resolves when the test completes, after cookie banner
+ * events.
+ */
+async function runEventTest({ mode, detectOnly, initFn, triggerFn, testURL }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", mode],
+ ["cookiebanners.service.detectOnly", detectOnly],
+ ],
+ });
+
+ await initFn();
+
+ let expectEventDetected = mode != Ci.nsICookieBannerService.MODE_DISABLED;
+ let expectEventHandled =
+ !detectOnly &&
+ (mode == Ci.nsICookieBannerService.MODE_REJECT ||
+ mode == Ci.nsICookieBannerService.MODE_REJECT_OR_ACCEPT);
+
+ let eventObservedDetected = false;
+ let eventObservedHandled = false;
+
+ // This is a bit hacky, we use side effects caused by the checkFn we pass into
+ // waitForEvent to keep track of whether an event fired.
+ let promiseEventDetected = BrowserTestUtils.waitForEvent(
+ window,
+ "cookiebannerdetected",
+ false,
+ () => {
+ eventObservedDetected = true;
+ return true;
+ }
+ );
+ let promiseEventHandled = BrowserTestUtils.waitForEvent(
+ window,
+ "cookiebannerhandled",
+ false,
+ () => {
+ eventObservedHandled = true;
+ return true;
+ }
+ );
+
+ // If we expect any events check which one comes first.
+ let firstEventPromise;
+ if (expectEventDetected || expectEventHandled) {
+ firstEventPromise = Promise.race([
+ promiseEventHandled,
+ promiseEventDetected,
+ ]);
+ }
+
+ await triggerFn();
+
+ let eventDetected;
+ if (expectEventDetected) {
+ eventDetected = await promiseEventDetected;
+
+ is(
+ eventDetected.type,
+ "cookiebannerdetected",
+ "Should dispatch cookiebannerdetected event."
+ );
+ }
+
+ let eventHandled;
+ if (expectEventHandled) {
+ eventHandled = await promiseEventHandled;
+ is(
+ eventHandled.type,
+ "cookiebannerhandled",
+ "Should dispatch cookiebannerhandled event."
+ );
+ }
+
+ // For MODE_DISABLED this array will be empty, because we don't expect any
+ // events to be dispatched.
+ let eventsToTest = [eventDetected, eventHandled].filter(event => !!event);
+
+ for (let event of eventsToTest) {
+ info(`Testing properties of event ${event.type}`);
+
+ let { windowContext } = event.detail;
+ ok(
+ windowContext,
+ `Event ${event.type} detail should contain a WindowContext`
+ );
+
+ let browser = windowContext.browsingContext.top.embedderElement;
+ ok(
+ browser,
+ "WindowContext should have an associated top embedder element."
+ );
+ is(
+ browser.tagName,
+ "browser",
+ "The top embedder element should be a browser"
+ );
+ let chromeWin = browser.ownerGlobal;
+ is(
+ chromeWin,
+ window,
+ "The chrome window associated with the browser should match the window where the cookie banner was handled."
+ );
+ is(
+ chromeWin.gBrowser.selectedBrowser,
+ browser,
+ "The browser associated with the event should be the selected browser."
+ );
+ is(
+ browser.currentURI.spec,
+ testURL,
+ "The browser's URI spec should match the cookie banner test page."
+ );
+ }
+
+ let firstEvent = await firstEventPromise;
+ is(
+ expectEventDetected || expectEventHandled,
+ !!firstEvent,
+ "Should have observed the first event if banner clicking is enabled."
+ );
+
+ if (expectEventDetected || expectEventHandled) {
+ is(
+ firstEvent.type,
+ "cookiebannerdetected",
+ "Detected event should be dispatched first"
+ );
+ }
+
+ is(
+ eventObservedDetected,
+ expectEventDetected,
+ `Should ${
+ expectEventDetected ? "" : "not "
+ }have observed 'cookiebannerdetected' event for mode ${mode}`
+ );
+ is(
+ eventObservedHandled,
+ expectEventHandled,
+ `Should ${
+ expectEventHandled ? "" : "not "
+ }have observed 'cookiebannerhandled' event for mode ${mode}`
+ );
+
+ // Clean up pending promises by dispatching artificial cookiebanner events.
+ // Otherwise the test fails because of pending event listeners which
+ // BrowserTestUtils.waitForEvent registered.
+ for (let eventType of ["cookiebannerdetected", "cookiebannerhandled"]) {
+ let event = new CustomEvent(eventType, {
+ bubbles: true,
+ cancelable: false,
+ });
+ window.windowUtils.dispatchEventToChromeOnly(window, event);
+ }
+
+ await promiseEventDetected;
+ await promiseEventHandled;
+}
diff --git a/toolkit/components/cookiebanners/test/browser/slowSubresource.sjs b/toolkit/components/cookiebanners/test/browser/slowSubresource.sjs
new file mode 100644
index 0000000000..1f14c2e9cf
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/slowSubresource.sjs
@@ -0,0 +1,18 @@
+"use strict";
+
+let timer;
+
+const DELAY_MS = 5000;
+function handleRequest(request, response) {
+ response.processAsync();
+ timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ response.setHeader("Content-Type", "text/css ", false);
+ response.write("body { background-color: red; }");
+ response.finish();
+ },
+ DELAY_MS,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/toolkit/components/cookiebanners/test/browser/testCookieHeader.sjs b/toolkit/components/cookiebanners/test/browser/testCookieHeader.sjs
new file mode 100644
index 0000000000..2d7cac5e45
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/browser/testCookieHeader.sjs
@@ -0,0 +1,5 @@
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 200);
+ // Write the cookie header value to the body for the test to inspect.
+ response.write(request.getHeader("Cookie"));
+}
diff --git a/toolkit/components/cookiebanners/test/unit/test_cookiebannerlistservice.js b/toolkit/components/cookiebanners/test/unit/test_cookiebannerlistservice.js
new file mode 100644
index 0000000000..bc69a49d0f
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/unit/test_cookiebannerlistservice.js
@@ -0,0 +1,611 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+let { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+// Name of the RemoteSettings collection containing the rules.
+const COLLECTION_NAME = "cookie-banner-rules-list";
+
+// Name of pref used to import test rules.
+const PREF_TEST_RULES = "cookiebanners.listService.testRules";
+
+let rulesInserted = [];
+let rulesRemoved = [];
+let insertCallback = null;
+
+function genUUID() {
+ return Services.uuid.generateUUID().number.slice(1, -1);
+}
+
+const RULE_A_ORIGINAL = {
+ id: genUUID(),
+ click: {
+ optOut: "#fooOut",
+ presence: "#foobar",
+ },
+ domains: ["example.com"],
+ cookies: {
+ optOut: [
+ {
+ name: "doOptOut",
+ value: "true",
+ isSecure: true,
+ isSession: false,
+ },
+ ],
+ },
+};
+
+const RULE_B = {
+ id: genUUID(),
+ click: {
+ optOut: "#fooOutB",
+ presence: "#foobarB",
+ },
+ domains: ["example.org"],
+ cookies: {
+ optOut: [
+ {
+ name: "doOptOutB",
+ value: "true",
+ isSecure: true,
+ },
+ ],
+ },
+};
+
+const RULE_C = {
+ id: genUUID(),
+ click: {
+ optOut: "#fooOutC",
+ presence: "#foobarC",
+ },
+ domains: ["example.net"],
+ cookies: {
+ optIn: [
+ {
+ name: "gdpr",
+ value: "1",
+ path: "/myPath",
+ host: "foo.example.net",
+ },
+ ],
+ },
+};
+
+const RULE_A_UPDATED = {
+ id: RULE_A_ORIGINAL.id,
+ click: {
+ optOut: "#fooOut",
+ optIn: "#barIn",
+ presence: "#foobar",
+ },
+ domains: ["example.com"],
+ cookies: {
+ optOut: [
+ {
+ name: "doOptOutUpdated",
+ value: "true",
+ isSecure: true,
+ },
+ {
+ name: "hideBanner",
+ value: "1",
+ },
+ ],
+ },
+};
+
+const INVALID_RULE_CLICK = {
+ id: genUUID(),
+ domains: ["foobar.com"],
+ click: {
+ presence: 1,
+ optIn: "#foo",
+ },
+};
+
+const INVALID_RULE_EMPTY = {};
+
+const RULE_D_GLOBAL = {
+ id: genUUID(),
+ click: {
+ optOut: "#globalOptOutD",
+ presence: "#globalBannerD",
+ },
+ domains: [],
+ cookies: {},
+};
+
+const RULE_E_GLOBAL = {
+ id: genUUID(),
+ click: {
+ optOut: "#globalOptOutE",
+ presence: "#globalBannerE",
+ },
+ domains: [],
+ cookies: {},
+};
+
+const RULE_F_EMPTY_CLICK = {
+ id: genUUID(),
+ click: {},
+ domains: ["example.com"],
+ cookies: {
+ optOut: [
+ {
+ name: "doOptOut",
+ value: "true",
+ isSecure: true,
+ },
+ {
+ name: "hideBanner",
+ value: "1",
+ },
+ ],
+ },
+};
+
+// Testing with RemoteSettings requires a profile.
+do_get_profile();
+
+add_setup(async () => {
+ // Enable debug logging.
+ Services.prefs.setStringPref("cookiebanners.listService.logLevel", "Debug");
+
+ // Stub some nsICookieBannerService methods for easy testing.
+ let removeRule = sinon.stub().callsFake(rule => {
+ rulesRemoved.push(rule);
+ });
+
+ let insertRule = sinon.stub().callsFake(rule => {
+ rulesInserted.push(rule);
+ insertCallback?.();
+ });
+
+ let oldCookieBanners = Services.cookieBanners;
+ Services.cookieBanners = {
+ isEnabled: true,
+ insertRule,
+ removeRule,
+ resetRules() {},
+ resetDomainTelemetryRecord() {},
+ };
+
+ // Remove stubs on test end.
+ registerCleanupFunction(() => {
+ Services.cookieBanners = oldCookieBanners;
+ Services.prefs.clearUserPref("cookiebanners.listService.logLevel");
+ });
+});
+
+/**
+ * Promise wrapper to listen for Services.cookieBanners.insertRule calls from
+ * the CookieBannerListService.
+ * @param {function} checkFn - Function which returns true or false to indicate
+ * if this is the insert the caller is looking for.
+ * @returns {Promise} - Promise which resolves when checkFn matches after
+ * insertRule call.
+ */
+function waitForInsert(checkFn) {
+ return new Promise(resolve => {
+ insertCallback = () => {
+ if (checkFn()) {
+ insertCallback = null;
+ resolve();
+ }
+ };
+ });
+}
+
+/**
+ * Tests that the cookie banner list service imports all rules on init.
+ */
+add_task(async function test_initial_import() {
+ info("Initializing RemoteSettings collection " + COLLECTION_NAME);
+
+ let db = RemoteSettings(COLLECTION_NAME).db;
+ db.clear();
+ await db.create(RULE_A_ORIGINAL);
+ await db.create(RULE_C);
+ await db.importChanges({}, Date.now());
+
+ Assert.equal(rulesInserted.length, 0, "No inserted rules initially.");
+ Assert.equal(rulesRemoved.length, 0, "No removed rules initially.");
+
+ let insertPromise = waitForInsert(() => rulesInserted.length >= 2);
+
+ info(
+ "Initializing the cookie banner list service which triggers a collection get call"
+ );
+ let cookieBannerListService = Cc[
+ "@mozilla.org/cookie-banner-list-service;1"
+ ].getService(Ci.nsICookieBannerListService);
+ await cookieBannerListService.initForTest();
+
+ await insertPromise;
+
+ Assert.equal(rulesInserted.length, 2, "Two inserted rules after init.");
+ Assert.equal(rulesRemoved.length, 0, "No removed rules after init.");
+
+ let ruleA = rulesInserted.find(rule => rule.id == RULE_A_UPDATED.id);
+ let cookieRuleA = ruleA.cookiesOptOut[0].cookie;
+ let ruleC = rulesInserted.find(rule => rule.id == RULE_C.id);
+ let cookieRuleC = ruleC.cookiesOptIn[0].cookie;
+
+ Assert.ok(ruleA, "Has rule A.");
+ Assert.deepEqual(
+ ruleA.domains,
+ RULE_A_UPDATED.domains,
+ "Domains for ruleA should match."
+ );
+ // Test the defaults which CookieBannerListService sets when the rule does
+ // not.
+ Assert.equal(
+ cookieRuleA.isSession,
+ false,
+ "Cookie for rule A should not be a session cookie."
+ );
+ Assert.equal(
+ cookieRuleA.host,
+ null,
+ "Cookie host for rule A should be default."
+ );
+ Assert.equal(
+ cookieRuleA.path,
+ "/",
+ "Cookie path for rule A should be default."
+ );
+
+ Assert.ok(ruleC, "Has rule C.");
+ Assert.deepEqual(
+ ruleC.domains,
+ RULE_C.domains,
+ "Domains for ruleA should match."
+ );
+ Assert.equal(
+ ruleC.cookiesOptIn[0].cookie.isSession,
+ true,
+ "Cookie for rule C should should be a session cookie."
+ );
+ Assert.equal(
+ cookieRuleC.host,
+ "foo.example.net",
+ "Cookie host for rule C should be custom."
+ );
+ Assert.equal(
+ cookieRuleC.path,
+ "/myPath",
+ "Cookie path for rule C should be custom."
+ );
+
+ // Cleanup
+ cookieBannerListService.shutdown();
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ db.clear();
+ await db.importChanges({}, Date.now());
+});
+
+/**
+ * Tests that the cookie banner list service updates rules on sync.
+ */
+add_task(async function test_remotesettings_sync() {
+ // Initialize the cookie banner list service so it subscribes to
+ // RemoteSettings updates.
+ let cookieBannerListService = Cc[
+ "@mozilla.org/cookie-banner-list-service;1"
+ ].getService(Ci.nsICookieBannerListService);
+ await cookieBannerListService.initForTest();
+
+ const payload = {
+ current: [RULE_A_ORIGINAL, RULE_C, RULE_D_GLOBAL],
+ created: [RULE_B, RULE_E_GLOBAL],
+ updated: [{ old: RULE_A_ORIGINAL, new: RULE_A_UPDATED }],
+ deleted: [RULE_C, RULE_D_GLOBAL],
+ };
+
+ Assert.equal(rulesInserted.length, 0, "No inserted rules initially.");
+ Assert.equal(rulesRemoved.length, 0, "No removed rules initially.");
+
+ info("Dispatching artificial RemoteSettings sync event");
+ await RemoteSettings(COLLECTION_NAME).emit("sync", { data: payload });
+
+ Assert.equal(rulesInserted.length, 3, "Three inserted rules after sync.");
+ Assert.equal(rulesRemoved.length, 2, "Two removed rules after sync.");
+
+ let ruleA = rulesInserted.find(rule => rule.id == RULE_A_UPDATED.id);
+ let ruleB = rulesInserted.find(rule => rule.id == RULE_B.id);
+ let ruleE = rulesInserted.find(rule => rule.id == RULE_E_GLOBAL.id);
+ let ruleC = rulesRemoved[0];
+
+ info("Testing that service inserted updated version of RULE_A.");
+ Assert.deepEqual(
+ ruleA.domains,
+ RULE_A_UPDATED.domains,
+ "Domains should match RULE_A."
+ );
+ Assert.equal(
+ ruleA.cookiesOptOut.length,
+ RULE_A_UPDATED.cookies.optOut.length,
+ "cookiesOptOut length should match RULE_A."
+ );
+
+ info("Testing opt-out cookies of RULE_A");
+ for (let i = 0; i < RULE_A_UPDATED.cookies.optOut.length; i += 1) {
+ Assert.equal(
+ ruleA.cookiesOptOut[i].cookie.name,
+ RULE_A_UPDATED.cookies.optOut[i].name,
+ "cookiesOptOut cookie name should match RULE_A."
+ );
+ Assert.equal(
+ ruleA.cookiesOptOut[i].cookie.value,
+ RULE_A_UPDATED.cookies.optOut[i].value,
+ "cookiesOptOut cookie value should match RULE_A."
+ );
+ }
+
+ Assert.equal(ruleB.id, RULE_B.id, "Should have inserted RULE_B");
+ Assert.equal(ruleC.id, RULE_C.id, "Should have removed RULE_C");
+ Assert.equal(
+ ruleE.id,
+ RULE_E_GLOBAL.id,
+ "Should have inserted RULE_E_GLOBAL"
+ );
+
+ // Cleanup
+ cookieBannerListService.shutdown();
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ let { db } = RemoteSettings(COLLECTION_NAME);
+ db.clear();
+ await db.importChanges({}, Date.now());
+});
+
+/**
+ * Tests the cookie banner rule test pref.
+ */
+add_task(async function test_rule_test_pref() {
+ Services.prefs.setStringPref(
+ PREF_TEST_RULES,
+ JSON.stringify([RULE_A_ORIGINAL, RULE_B])
+ );
+
+ Assert.equal(rulesInserted.length, 0, "No inserted rules initially.");
+ Assert.equal(rulesRemoved.length, 0, "No removed rules initially.");
+
+ let insertPromise = waitForInsert(() => rulesInserted.length >= 2);
+
+ // Initialize the cookie banner list service so it imports test rules and listens for pref changes.
+ let cookieBannerListService = Cc[
+ "@mozilla.org/cookie-banner-list-service;1"
+ ].getService(Ci.nsICookieBannerListService);
+ await cookieBannerListService.initForTest();
+
+ info("Wait for rules to be inserted");
+ await insertPromise;
+
+ Assert.equal(rulesInserted.length, 2, "Should have inserted two rules.");
+ Assert.equal(rulesRemoved.length, 0, "Should not have removed any rules.");
+
+ Assert.ok(
+ rulesInserted.some(rule => rule.id == RULE_A_ORIGINAL.id),
+ "Should have inserted RULE_A"
+ );
+ Assert.ok(
+ rulesInserted.some(rule => rule.id == RULE_B.id),
+ "Should have inserted RULE_B"
+ );
+
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ let insertPromise2 = waitForInsert(() => rulesInserted.length >= 3);
+
+ info(
+ "Updating test rules via pref. The list service should detect the pref change."
+ );
+ // This includes some invalid rules, they should be skipped.
+ Services.prefs.setStringPref(
+ PREF_TEST_RULES,
+ JSON.stringify([
+ RULE_A_ORIGINAL,
+ RULE_B,
+ INVALID_RULE_EMPTY,
+ RULE_C,
+ INVALID_RULE_CLICK,
+ ])
+ );
+
+ info("Wait for rules to be inserted");
+ await insertPromise2;
+
+ Assert.equal(rulesInserted.length, 3, "Should have inserted three rules.");
+ Assert.equal(rulesRemoved.length, 0, "Should not have removed any rules.");
+
+ Assert.ok(
+ rulesInserted.some(rule => rule.id == RULE_A_ORIGINAL.id),
+ "Should have inserted RULE_A"
+ );
+ Assert.ok(
+ rulesInserted.some(rule => rule.id == RULE_B.id),
+ "Should have inserted RULE_B"
+ );
+ Assert.ok(
+ rulesInserted.some(rule => rule.id == RULE_C.id),
+ "Should have inserted RULE_C"
+ );
+
+ // Cleanup
+ cookieBannerListService.shutdown();
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ Services.prefs.clearUserPref(PREF_TEST_RULES);
+});
+
+/**
+ * Tests that runContext string values get properly translated into nsIClickRule::RunContext.
+ */
+add_task(async function test_runContext_conversion() {
+ info("Initializing RemoteSettings collection " + COLLECTION_NAME);
+
+ let ruleA = {
+ id: genUUID(),
+ click: {
+ presence: "#foobar",
+ runContext: "child",
+ },
+ domains: ["a.com"],
+ };
+ let ruleB = {
+ id: genUUID(),
+ click: {
+ presence: "#foobar",
+ },
+ domains: ["b.com"],
+ };
+ let ruleC = {
+ id: genUUID(),
+ click: {
+ presence: "#foobar",
+ runContext: "all",
+ },
+ domains: ["c.com"],
+ };
+ let ruleD = {
+ id: genUUID(),
+ click: {
+ presence: "#foobar",
+ runContext: "top",
+ },
+ domains: ["d.com"],
+ };
+ let ruleE = {
+ id: genUUID(),
+ click: {
+ presence: "#foobar",
+ runContext: "thisIsNotValid",
+ },
+ domains: ["e.com"],
+ };
+
+ let db = RemoteSettings(COLLECTION_NAME).db;
+ db.clear();
+ await db.create(ruleA);
+ await db.create(ruleB);
+ await db.create(ruleC);
+ await db.create(ruleD);
+ await db.create(ruleE);
+ await db.importChanges({}, Date.now());
+
+ let insertPromise = waitForInsert(() => rulesInserted.length >= 4);
+
+ info(
+ "Initializing the cookie banner list service which triggers a collection get call"
+ );
+ let cookieBannerListService = Cc[
+ "@mozilla.org/cookie-banner-list-service;1"
+ ].getService(Ci.nsICookieBannerListService);
+ await cookieBannerListService.initForTest();
+
+ await insertPromise;
+
+ let resultA = rulesInserted.find(rule =>
+ rule.domains.includes("a.com")
+ ).clickRule;
+ let resultB = rulesInserted.find(rule =>
+ rule.domains.includes("b.com")
+ ).clickRule;
+ let resultC = rulesInserted.find(rule =>
+ rule.domains.includes("c.com")
+ ).clickRule;
+ let resultD = rulesInserted.find(rule =>
+ rule.domains.includes("d.com")
+ ).clickRule;
+ let resultE = rulesInserted.find(rule =>
+ rule.domains.includes("e.com")
+ ).clickRule;
+
+ Assert.equal(
+ resultA.runContext,
+ Ci.nsIClickRule.RUN_CHILD,
+ "Rule A should have been imported with the correct runContext"
+ );
+ Assert.equal(
+ resultB.runContext,
+ Ci.nsIClickRule.RUN_TOP,
+ "Rule B should have fallen back to default runContext"
+ );
+ Assert.equal(
+ resultC.runContext,
+ Ci.nsIClickRule.RUN_ALL,
+ "Rule C should have been imported with the correct runContext"
+ );
+ Assert.equal(
+ resultD.runContext,
+ Ci.nsIClickRule.RUN_TOP,
+ "Rule D should have been imported with the correct runContext"
+ );
+ Assert.equal(
+ resultE.runContext,
+ Ci.nsIClickRule.RUN_TOP,
+ "Rule E should have fallen back to default runContext"
+ );
+
+ // Cleanup
+ cookieBannerListService.shutdown();
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ db.clear();
+ await db.importChanges({}, Date.now());
+});
+
+/**
+ * Tests empty click rules don't get imported.
+ */
+add_task(async function test_empty_click_rule() {
+ info("Initializing RemoteSettings collection " + COLLECTION_NAME);
+
+ let db = RemoteSettings(COLLECTION_NAME).db;
+ db.clear();
+ await db.create(RULE_F_EMPTY_CLICK);
+ await db.importChanges({}, Date.now());
+
+ let insertPromise = waitForInsert(() => rulesInserted.length >= 1);
+
+ info(
+ "Initializing the cookie banner list service which triggers a collection get call"
+ );
+ let cookieBannerListService = Cc[
+ "@mozilla.org/cookie-banner-list-service;1"
+ ].getService(Ci.nsICookieBannerListService);
+ await cookieBannerListService.initForTest();
+
+ await insertPromise;
+
+ let ruleF = rulesInserted.find(rule => rule.id == RULE_F_EMPTY_CLICK.id);
+
+ Assert.ok(ruleF, "Has rule F.");
+ Assert.ok(ruleF.cookiesOptOut?.length, "Should have imported a cookie rule.");
+ Assert.equal(ruleF.clickRule, null, "Should not have imported a click rule.");
+
+ // Cleanup
+ cookieBannerListService.shutdown();
+ rulesInserted = [];
+ rulesRemoved = [];
+
+ db.clear();
+ await db.importChanges({}, Date.now());
+});
diff --git a/toolkit/components/cookiebanners/test/unit/xpcshell.ini b/toolkit/components/cookiebanners/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..b343ee6c12
--- /dev/null
+++ b/toolkit/components/cookiebanners/test/unit/xpcshell.ini
@@ -0,0 +1,2 @@
+[test_cookiebannerlistservice.js]
+