/* 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/. */ // eslint-disable-next-line mozilla/use-static-import const { newTabAttributionService } = ChromeUtils.importESModule( "resource://newtab/lib/NewTabAttributionService.sys.mjs" ); const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { return console.createInstance({ prefix: "NewTabAttributionParent", maxLogLevel: "Warn", }); }); ChromeUtils.defineESModuleGetters(lazy, { RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", }); /** * Allowed fields in the conversion event payload from advertisers. * - partnerId: Mozilla-generated UUID associated with the advertiser * - impressionType: How attribution should be determined (view/click/default) * - lookbackDays: Number of days in the past to look for an attributable interaction (1, 7, 14, or 30) */ const CONVERSION_KEYS = new Set([ "partnerId", "impressionType", "lookbackDays", ]); /** * Checks if an object is a plain object (not null, not an array, not a function). * This is necessary because JSWindowActor messages use structured clones, * which have a different prototypes than normal objects * * @param {object} obj - The value to check. * @returns {boolean} True if obj is a plain object, false otherwise. */ function isPlainObject(obj) { return ( typeof obj === "object" && obj !== null && !Array.isArray(obj) && Object.prototype.toString.call(obj) === "[object Object]" ); } const ATTRIBUTION_ALLOWLIST_COLLECTION = "newtab-attribution-allowlist"; let gAllowList = new Set([]); let gAllowListClient = null; /** * Parent-side JSWindowActor for handling attribution conversion events. * * This actor receives FirefoxConversionNotification custom events from advertiser websites * * Upon successful validation, the conversion data is passed to NewTabAttributionService */ export class AttributionParent extends JSWindowActorParent { constructor() { super(); this._onSync = this.onSync.bind(this); } /** * TEST-ONLY: Override the allowlist from a test. * * @param {Array} origins - Array of origin strings to allow. */ setAllowListForTest(origins = []) { gAllowList = new Set(origins); } /** * TEST-ONLY: Reset the Remote Settings client. */ resetRemoteSettingsClientForTest() { gAllowListClient = null; } /** * This thin wrapper around lazy.RemoteSettings makes it easier for us to write * automated tests that simulate responses from this fetch. */ RemoteSettings(...args) { return lazy.RemoteSettings(...args); } /** * Updates the global allowlist with the provided records. * * @param {Array} records - Array of Remote Settings records containing domain fields. */ updateAllowList(records) { if (records?.length) { const domains = records.map(record => record.domain); gAllowList = new Set(domains); } else { gAllowList = new Set([]); } } /** * Retrieves the allow list of advertiser origins from Remote Settings. * Populates the internal gAllowList set with the retrieved origins. */ async retrieveAllowList() { try { if (!gAllowListClient) { gAllowListClient = this.RemoteSettings( ATTRIBUTION_ALLOWLIST_COLLECTION ); gAllowListClient.on("sync", this._onSync); const records = await gAllowListClient.get(); this.updateAllowList(records); } } catch (error) { lazy.logConsole.error( `AttributionParent: failed to retrieve allow list: ${error}` ); } } /** * Handles Remote Settings sync events. * Updates the allow list when the collection changes. * * @param {object} event - The sync event object. * @param {Array} event.data.current - The current records after sync. */ onSync({ data: { current } }) { this.updateAllowList(current); } didDestroy() { if (gAllowListClient) { gAllowListClient.off("sync", this._onSync); } } /** * Validates a conversion event payload from an advertiser. * Ensures all required fields are present, correctly typed, and within valid ranges. * * @param {*} data - The conversion data to validate. * @returns {object|null} The validated conversion data object, or null if validation fails. * * Validation checks: * - Must be a plain object * - Must contain only allowed keys (partnerId, impressionType, lookbackDays) * - partnerId: must be a non-empty string * - impressionType: must be a string * - lookbackDays: must be a positive number */ validateConversion(data) { // confirm that data is an object if (!isPlainObject(data)) { return null; } // Check that only allowed keys are present for (const key of Object.keys(data)) { if (!CONVERSION_KEYS.has(key)) { return null; } } // Validate required fields are present if ( !data.partnerId || !data.impressionType || data.lookbackDays === undefined ) { return null; } // Validate types if (typeof data.partnerId !== "string") { return null; } if (typeof data.impressionType !== "string") { return null; } if (typeof data.lookbackDays !== "number" || data.lookbackDays <= 0) { return null; } return data; } /** * Receives and processes conversion event messages from the child actor. * This method is called when a FirefoxConversionNotification custom event is triggered * on an advertiser's website. * * @param {object} message - The message from the child actor. * @param {object} message.data - The message data. * @param {object} message.data.detail - The custom event detail. * @param {object} message.data.detail.conversion - The conversion payload. * @returns {Promise} */ async receiveMessage(message) { let principal = this.manager.documentPrincipal; // Only accept conversion events from secure origins (HTTPS) if (!principal.isOriginPotentiallyTrustworthy) { lazy.logConsole.error( `AttributionParent: conversion events must be sent over HTTPS` ); return; } if (!gAllowList.size) { await this.retrieveAllowList(); } // Only accept conversion events from allowlisted origins if (!gAllowList.has(principal.originNoSuffix)) { lazy.logConsole.error( `AttributionParent: conversion events must come from the allow list` ); return; } const { detail } = message.data || {}; if (detail) { const validatedConversion = this.validateConversion(detail); if (!validatedConversion) { lazy.logConsole.error( `AttributionParent: rejected invalid conversion payload from ${principal}` ); return; } const { partnerId, lookbackDays, impressionType } = validatedConversion; await newTabAttributionService.onAttributionConversion( partnerId, lookbackDays, impressionType ); } } }