diff options
Diffstat (limited to 'toolkit/components/cookiebanners')
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] + |