NewTabAttributionParent.sys.mjs (7255B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 // eslint-disable-next-line mozilla/use-static-import 6 const { newTabAttributionService } = ChromeUtils.importESModule( 7 "resource://newtab/lib/NewTabAttributionService.sys.mjs" 8 ); 9 10 const lazy = {}; 11 12 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 13 return console.createInstance({ 14 prefix: "NewTabAttributionParent", 15 maxLogLevel: "Warn", 16 }); 17 }); 18 19 ChromeUtils.defineESModuleGetters(lazy, { 20 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 21 }); 22 23 /** 24 * Allowed fields in the conversion event payload from advertisers. 25 * - partnerId: Mozilla-generated UUID associated with the advertiser 26 * - impressionType: How attribution should be determined (view/click/default) 27 * - lookbackDays: Number of days in the past to look for an attributable interaction (1, 7, 14, or 30) 28 */ 29 const CONVERSION_KEYS = new Set([ 30 "partnerId", 31 "impressionType", 32 "lookbackDays", 33 ]); 34 35 /** 36 * Checks if an object is a plain object (not null, not an array, not a function). 37 * This is necessary because JSWindowActor messages use structured clones, 38 * which have a different prototypes than normal objects 39 * 40 * @param {object} obj - The value to check. 41 * @returns {boolean} True if obj is a plain object, false otherwise. 42 */ 43 function isPlainObject(obj) { 44 return ( 45 typeof obj === "object" && 46 obj !== null && 47 !Array.isArray(obj) && 48 Object.prototype.toString.call(obj) === "[object Object]" 49 ); 50 } 51 52 const ATTRIBUTION_ALLOWLIST_COLLECTION = "newtab-attribution-allowlist"; 53 54 let gAllowList = new Set([]); 55 let gAllowListClient = null; 56 57 /** 58 * Parent-side JSWindowActor for handling attribution conversion events. 59 * 60 * This actor receives FirefoxConversionNotification custom events from advertiser websites 61 * 62 * Upon successful validation, the conversion data is passed to NewTabAttributionService 63 */ 64 export class AttributionParent extends JSWindowActorParent { 65 constructor() { 66 super(); 67 this._onSync = this.onSync.bind(this); 68 } 69 70 /** 71 * TEST-ONLY: Override the allowlist from a test. 72 * 73 * @param {Array<string>} origins - Array of origin strings to allow. 74 */ 75 setAllowListForTest(origins = []) { 76 gAllowList = new Set(origins); 77 } 78 79 /** 80 * TEST-ONLY: Reset the Remote Settings client. 81 */ 82 resetRemoteSettingsClientForTest() { 83 gAllowListClient = null; 84 } 85 86 /** 87 * This thin wrapper around lazy.RemoteSettings makes it easier for us to write 88 * automated tests that simulate responses from this fetch. 89 */ 90 RemoteSettings(...args) { 91 return lazy.RemoteSettings(...args); 92 } 93 94 /** 95 * Updates the global allowlist with the provided records. 96 * 97 * @param {Array} records - Array of Remote Settings records containing domain fields. 98 */ 99 updateAllowList(records) { 100 if (records?.length) { 101 const domains = records.map(record => record.domain); 102 gAllowList = new Set(domains); 103 } else { 104 gAllowList = new Set([]); 105 } 106 } 107 108 /** 109 * Retrieves the allow list of advertiser origins from Remote Settings. 110 * Populates the internal gAllowList set with the retrieved origins. 111 */ 112 async retrieveAllowList() { 113 try { 114 if (!gAllowListClient) { 115 gAllowListClient = this.RemoteSettings( 116 ATTRIBUTION_ALLOWLIST_COLLECTION 117 ); 118 gAllowListClient.on("sync", this._onSync); 119 const records = await gAllowListClient.get(); 120 this.updateAllowList(records); 121 } 122 } catch (error) { 123 lazy.logConsole.error( 124 `AttributionParent: failed to retrieve allow list: ${error}` 125 ); 126 } 127 } 128 129 /** 130 * Handles Remote Settings sync events. 131 * Updates the allow list when the collection changes. 132 * 133 * @param {object} event - The sync event object. 134 * @param {Array} event.data.current - The current records after sync. 135 */ 136 onSync({ data: { current } }) { 137 this.updateAllowList(current); 138 } 139 140 didDestroy() { 141 if (gAllowListClient) { 142 gAllowListClient.off("sync", this._onSync); 143 } 144 } 145 146 /** 147 * Validates a conversion event payload from an advertiser. 148 * Ensures all required fields are present, correctly typed, and within valid ranges. 149 * 150 * @param {*} data - The conversion data to validate. 151 * @returns {object|null} The validated conversion data object, or null if validation fails. 152 * 153 * Validation checks: 154 * - Must be a plain object 155 * - Must contain only allowed keys (partnerId, impressionType, lookbackDays) 156 * - partnerId: must be a non-empty string 157 * - impressionType: must be a string 158 * - lookbackDays: must be a positive number 159 */ 160 validateConversion(data) { 161 // confirm that data is an object 162 if (!isPlainObject(data)) { 163 return null; 164 } 165 166 // Check that only allowed keys are present 167 for (const key of Object.keys(data)) { 168 if (!CONVERSION_KEYS.has(key)) { 169 return null; 170 } 171 } 172 173 // Validate required fields are present 174 if ( 175 !data.partnerId || 176 !data.impressionType || 177 data.lookbackDays === undefined 178 ) { 179 return null; 180 } 181 182 // Validate types 183 if (typeof data.partnerId !== "string") { 184 return null; 185 } 186 187 if (typeof data.impressionType !== "string") { 188 return null; 189 } 190 191 if (typeof data.lookbackDays !== "number" || data.lookbackDays <= 0) { 192 return null; 193 } 194 195 return data; 196 } 197 198 /** 199 * Receives and processes conversion event messages from the child actor. 200 * This method is called when a FirefoxConversionNotification custom event is triggered 201 * on an advertiser's website. 202 * 203 * @param {object} message - The message from the child actor. 204 * @param {object} message.data - The message data. 205 * @param {object} message.data.detail - The custom event detail. 206 * @param {object} message.data.detail.conversion - The conversion payload. 207 * @returns {Promise} 208 */ 209 async receiveMessage(message) { 210 let principal = this.manager.documentPrincipal; 211 212 // Only accept conversion events from secure origins (HTTPS) 213 if (!principal.isOriginPotentiallyTrustworthy) { 214 lazy.logConsole.error( 215 `AttributionParent: conversion events must be sent over HTTPS` 216 ); 217 return; 218 } 219 220 if (!gAllowList.size) { 221 await this.retrieveAllowList(); 222 } 223 224 // Only accept conversion events from allowlisted origins 225 if (!gAllowList.has(principal.originNoSuffix)) { 226 lazy.logConsole.error( 227 `AttributionParent: conversion events must come from the allow list` 228 ); 229 return; 230 } 231 232 const { detail } = message.data || {}; 233 234 if (detail) { 235 const validatedConversion = this.validateConversion(detail); 236 237 if (!validatedConversion) { 238 lazy.logConsole.error( 239 `AttributionParent: rejected invalid conversion payload from ${principal}` 240 ); 241 return; 242 } 243 244 const { partnerId, lookbackDays, impressionType } = validatedConversion; 245 await newTabAttributionService.onAttributionConversion( 246 partnerId, 247 lookbackDays, 248 impressionType 249 ); 250 } 251 } 252 }