Source code
Revision control
Copy as Markdown
Other Tools
/* 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
// eslint-disable-next-line mozilla/use-static-import
const { newTabAttributionService } = ChromeUtils.importESModule(
);
const lazy = {};
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
return console.createInstance({
prefix: "NewTabAttributionParent",
maxLogLevel: "Warn",
});
});
ChromeUtils.defineESModuleGetters(lazy, {
});
/**
* 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<string>} 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
);
}
}
}