354 lines
10 KiB
JavaScript
354 lines
10 KiB
JavaScript
/* 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 = {};
|
|
|
|
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"
|
|
);
|
|
|
|
const PREF_SKIP_REMOTE_SETTINGS =
|
|
"cookiebanners.listService.testSkipRemoteSettings";
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"TEST_SKIP_REMOTE_SETTINGS",
|
|
PREF_SKIP_REMOTE_SETTINGS
|
|
);
|
|
|
|
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";
|
|
|
|
ChromeUtils.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.
|
|
ChromeUtils.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);
|
|
Services.prefs.addObserver(PREF_SKIP_REMOTE_SETTINGS, 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);
|
|
Services.prefs.removeObserver(PREF_SKIP_REMOTE_SETTINGS, 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 && prefName != PREF_SKIP_REMOTE_SETTINGS) {
|
|
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.
|
|
// Also reimport when rule import from RemoteSettings is enabled / disabled.
|
|
// Passing `doImport = false` since we trigger the import ourselves.
|
|
Services.cookieBanners.resetRules(false);
|
|
this.importAllRules();
|
|
|
|
// Reset executed records (private and normal browsing) for easier testing
|
|
// of rules.
|
|
Services.cookieBanners.removeAllExecutedRecords(false);
|
|
Services.cookieBanners.removeAllExecutedRecords(true);
|
|
}
|
|
|
|
#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));
|
|
}
|
|
|
|
#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);
|
|
});
|
|
}
|
|
|
|
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?.presence) {
|
|
return;
|
|
}
|
|
|
|
rule.addClickRule(
|
|
click.presence,
|
|
click.skipPresenceVisibilityCheck,
|
|
this.#runContextStrToNative(click.runContext),
|
|
click.hide,
|
|
click.optOut,
|
|
click.optIn
|
|
);
|
|
}
|
|
}
|