tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }