summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/AbuseReporter.jsm
blob: d68e50cf7f88a8c5d8937adbfb883a1a48949d49 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
/* 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 EXPORTED_SYMBOLS = ["AbuseReporter", "AbuseReportError"];

const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);

Cu.importGlobalProperties(["fetch"]);

const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url";
const PREF_AMO_DETAILS_API_URL = "extensions.abuseReport.amoDetailsURL";

// Name associated with the report dialog window.
const DIALOG_WINDOW_NAME = "addons-abuse-report-dialog";

// Maximum length of the string properties sent to the API endpoint.
const MAX_STRING_LENGTH = 255;

// Minimum time between report submissions (in ms).
const MIN_MS_BETWEEN_SUBMITS = 30000;

// The addon types currently supported by the integrated abuse report panel.
const SUPPORTED_ADDON_TYPES = ["extension", "theme"];

XPCOMUtils.defineLazyModuleGetters(this, {
  AddonManager: "resource://gre/modules/AddonManager.jsm",
  AMTelemetry: "resource://gre/modules/AddonManager.jsm",
  AppConstants: "resource://gre/modules/AppConstants.jsm",
  ClientID: "resource://gre/modules/ClientID.jsm",
  Services: "resource://gre/modules/Services.jsm",
});

XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "ABUSE_REPORT_URL",
  PREF_ABUSE_REPORT_URL
);

XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "AMO_DETAILS_API_URL",
  PREF_AMO_DETAILS_API_URL
);

const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");

const ERROR_TYPES = Object.freeze([
  "ERROR_ABORTED_SUBMIT",
  "ERROR_ADDON_NOTFOUND",
  "ERROR_CLIENT",
  "ERROR_NETWORK",
  "ERROR_UNKNOWN",
  "ERROR_RECENT_SUBMIT",
  "ERROR_SERVER",
  "ERROR_AMODETAILS_NOTFOUND",
  "ERROR_AMODETAILS_FAILURE",
]);

class AbuseReportError extends Error {
  constructor(errorType, errorInfo = undefined) {
    if (!ERROR_TYPES.includes(errorType)) {
      throw new Error(`Unknown AbuseReportError type "${errorType}"`);
    }

    let message = errorInfo ? `${errorType} - ${errorInfo}` : errorType;

    super(message);
    this.name = "AbuseReportError";
    this.errorType = errorType;
    this.errorInfo = errorInfo;
  }
}

/**
 * Create an error info string from a fetch response object.
 *
 * @param {Response} response
 *        A fetch response object to convert into an errorInfo string.
 *
 * @returns {Promise<string>}
 *          The errorInfo string to be included in an AbuseReportError.
 */
async function responseToErrorInfo(response) {
  return JSON.stringify({
    status: response.status,
    responseText: await response.text().catch(err => ""),
  });
}

/**
 * A singleton object used to create new AbuseReport instances for a given addonId
 * and enforce a minium amount of time between two report submissions .
 */
const AbuseReporter = {
  _lastReportTimestamp: null,

  // Error types.
  updateLastReportTimestamp() {
    this._lastReportTimestamp = Date.now();
  },

  getTimeFromLastReport() {
    const currentTimestamp = Date.now();
    if (this._lastReportTimestamp > currentTimestamp) {
      // Reset the last report timestamp if it is in the future.
      this._lastReportTimestamp = null;
    }

    if (!this._lastReportTimestamp) {
      return Infinity;
    }

    return currentTimestamp - this._lastReportTimestamp;
  },

  /**
   * Create an AbuseReport instance, given the addonId and a reportEntryPoint.
   *
   * @param {string} addonId
   *        The id of the addon to create the report instance for.
   * @param {object} options
   * @param {string} options.reportEntryPoint
   *        An identifier that represent the entry point for the report flow.
   *
   * @returns {Promise<AbuseReport>}
   *          Returns a promise that resolves to an instance of the AbuseReport
   *          class, which represent an ongoing report.
   */
  async createAbuseReport(addonId, { reportEntryPoint } = {}) {
    let addon = await AddonManager.getAddonByID(addonId);

    if (!addon) {
      // The addon isn't installed, query the details from the AMO API endpoint.
      addon = await this.queryAMOAddonDetails(addonId, reportEntryPoint);
    }

    if (!addon) {
      AMTelemetry.recordReportEvent({
        addonId,
        errorType: "ERROR_ADDON_NOTFOUND",
        reportEntryPoint,
      });
      throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
    }

    const reportData = await this.getReportData(addon);

    return new AbuseReport({
      addon,
      reportData,
      reportEntryPoint,
    });
  },

  /**
   * Retrieves the addon details from the AMO API endpoint, used to create
   * abuse reports on non-installed addon-ons.
   *
   * For the addon details that may be translated (e.g. addon name, description etc.)
   * the function will try to retrieve the string localized in the same locale used
   * by Gecko (and fallback to "en-US" if that locale is unavailable).
   *
   * The addon creator properties are set to the first author available.
   *
   * @param {string} addonId
   *        The id of the addon to retrieve the details available on AMO.
   * @param {string} reportEntryPoint
   *        The entry point for the report flow (to be included in the telemetry
   *        recorded in case of failures).
   *
   * @returns {Promise<AMOAddonDetails|null>}
   *          Returns a promise that resolves to an AMOAddonDetails object,
   *          which has the subset of the AddonWrapper properties which are
   *          needed by the abuse report panel or the report data sent to
   *          the abuse report API endpoint), or null if it fails to
   *          retrieve the details from AMO.
   *
   * @typedef {object} AMOAddonDetails
   *   @prop  {string} id
   *   @prop  {string} name
   *   @prop  {string} version
   *   @prop  {string} description
   *   @prop  {string} type
   *   @prop  {string} iconURL
   *   @prop  {string} homepageURL
   *   @prop  {string} supportURL
   *   @prop  {AMOAddonCreator} creator
   *   @prop  {boolean} isRecommended
   *   @prop  {number} signedState=AddonManager.SIGNEDSTATE_UNKNOWN
   *   @prop  {object} installTelemetryInfo={ source: "not_installed" }
   *
   * @typedef {object} AMOAddonCreator
   *   @prop  {string} name
   *   @prop  {string} url
   */
  async queryAMOAddonDetails(addonId, reportEntryPoint) {
    let details;
    try {
      // This should be the API endpoint documented at:
      // https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail
      details = await fetch(`${AMO_DETAILS_API_URL}/${addonId}`, {
        credentials: "omit",
        referrerPolicy: "no-referrer",
        headers: { "Content-Type": "application/json" },
      }).then(async response => {
        if (response.status === 200) {
          return response.json();
        }

        let errorInfo = await responseToErrorInfo(response).catch(
          err => undefined
        );

        if (response.status === 404) {
          // Record a different telemetry event for 404 errors.
          throw new AbuseReportError("ERROR_AMODETAILS_NOTFOUND", errorInfo);
        }

        throw new AbuseReportError("ERROR_AMODETAILS_FAILURE", errorInfo);
      });
    } catch (err) {
      // Log the original error in the browser console.
      Cu.reportError(err);

      AMTelemetry.recordReportEvent({
        addonId,
        errorType: err.errorType || "ERROR_AMODETAILS_FAILURE",
        reportEntryPoint,
      });

      return null;
    }

    const locale = Services.locale.appLocaleAsBCP47;

    // Get a string value from a translated value
    // (https://addons-server.readthedocs.io/en/latest/topics/api/overview.html#api-overview-translations)
    const getTranslatedValue = value => {
      if (typeof value === "string") {
        return value;
      }
      return value && (value[locale] || value["en-US"]);
    };

    const getAuthorField = fieldName =>
      details.authors && details.authors[0] && details.authors[0][fieldName];

    // Normalize type "statictheme" (which is the type used on the AMO API side)
    // into "theme" (because it is the type we use and expect on the Firefox side
    // for this addon type).
    const addonType = details.type === "statictheme" ? "theme" : details.type;

    return {
      id: addonId,
      name: getTranslatedValue(details.name),
      version: details.current_version.version,
      description: getTranslatedValue(details.summary),
      type: addonType,
      iconURL: details.icon_url,
      homepageURL: getTranslatedValue(details.homepage),
      supportURL: getTranslatedValue(details.support_url),
      // Set the addon creator to the first author in the AMO details.
      creator: {
        name: getAuthorField("name"),
        url: getAuthorField("url"),
      },
      isRecommended: details.is_recommended,
      // Set signed state to unknown because it isn't installed.
      signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
      // Set the installTelemetryInfo.source to "not_installed".
      installTelemetryInfo: { source: "not_installed" },
    };
  },

  /**
   * Helper function that retrieves from an addon object all the data to send
   * as part of the submission request, besides the `reason`, `message` which are
   * going to be received from the submit method of the report object returned
   * by `createAbuseReport`.
   * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html)
   *
   * @param {AddonWrapper} addon
   *        The addon object to collect the detail from.
   *
   * @return {object}
   *         An object that contains the collected details.
   */
  async getReportData(addon) {
    const truncateString = text =>
      typeof text == "string" ? text.slice(0, MAX_STRING_LENGTH) : text;

    // Normalize addon_install_source and addon_install_method values
    // as expected by the server API endpoint. Returns null if the
    // value is not a string.
    const normalizeValue = text =>
      typeof text == "string"
        ? text.toLowerCase().replace(/[- :]/g, "_")
        : null;

    const installInfo = addon.installTelemetryInfo || {};

    const data = {
      addon: addon.id,
      addon_version: addon.version,
      addon_name: truncateString(addon.name),
      addon_summary: truncateString(addon.description),
      addon_install_origin:
        addon.sourceURI && truncateString(addon.sourceURI.spec),
      install_date: addon.installDate && addon.installDate.toISOString(),
      addon_install_source: normalizeValue(installInfo.source),
      addon_install_source_url:
        installInfo.sourceURL && truncateString(installInfo.sourceURL),
      addon_install_method: normalizeValue(installInfo.method),
    };

    switch (addon.signedState) {
      case AddonManager.SIGNEDSTATE_BROKEN:
        data.addon_signature = "broken";
        break;
      case AddonManager.SIGNEDSTATE_UNKNOWN:
        data.addon_signature = "unknown";
        break;
      case AddonManager.SIGNEDSTATE_MISSING:
        data.addon_signature = "missing";
        break;
      case AddonManager.SIGNEDSTATE_PRELIMINARY:
        data.addon_signature = "preliminary";
        break;
      case AddonManager.SIGNEDSTATE_SIGNED:
        data.addon_signature = "signed";
        break;
      case AddonManager.SIGNEDSTATE_SYSTEM:
        data.addon_signature = "system";
        break;
      case AddonManager.SIGNEDSTATE_PRIVILEGED:
        data.addon_signature = "privileged";
        break;
      default:
        data.addon_signature = `unknown: ${addon.signedState}`;
    }

    // Set "curated" as addon_signature on recommended addons
    // (addon.isRecommended internally checks that the addon is also
    // signed correctly).
    if (addon.isRecommended) {
      data.addon_signature = "curated";
    }

    data.client_id = await ClientID.getClientIdHash();

    data.app = Services.appinfo.name.toLowerCase();
    data.appversion = Services.appinfo.version;
    data.lang = Services.locale.appLocaleAsBCP47;
    data.operating_system = AppConstants.platform;
    data.operating_system_version = Services.sysinfo.getProperty("version");

    return data;
  },

  /**
   * Helper function that returns a reference to a report dialog window
   * already opened (if any).
   *
   * @returns {Window?}
   */
  getOpenDialog() {
    return Services.ww.getWindowByName(DIALOG_WINDOW_NAME, null);
  },

  /**
   * Helper function that opens an abuse report form in a new dialog window.
   *
   * @param {string} addonId
   *        The addonId being reported.
   * @param {string} reportEntryPoint
   *        The entry point from which the user has triggered the abuse report
   *        flow.
   * @param {XULElement} browser
   *        The browser element (if any) that is opening the report window.
   *
   * @return {Promise<AbuseReportDialog>}
   *         Returns an AbuseReportDialog object, rejects if it fails to open
   *         the dialog.
   *
   * @typedef {object}                        AbuseReportDialog
   *          An object that represents the abuse report dialog.
   * @prop    {function}                      close
   *          A method that closes the report dialog (used by the caller
   *          to close the dialog when the user chooses to close the window
   *          that started the abuse report flow).
   * @prop    {Promise<AbuseReport|undefined} promiseReport
   *          A promise resolved to an AbuseReport instance if the report should
   *          be submitted, or undefined if the user has cancelled the report.
   *          Rejects if it fails to create an AbuseReport instance or to open
   *          the abuse report window.
   */
  async openDialog(addonId, reportEntryPoint, browser) {
    const chromeWin = browser && browser.ownerGlobal;
    if (!chromeWin) {
      throw new Error("Abuse Reporter dialog cancelled, opener tab closed");
    }

    const dialogWin = this.getOpenDialog();

    if (dialogWin) {
      // If an abuse report dialog is already open, cancel the
      // previous report flow and start a new one.
      const {
        deferredReport,
        promiseReport,
      } = dialogWin.arguments[0].wrappedJSObject;
      deferredReport.resolve({ userCancelled: true });
      await promiseReport;
    }

    const report = await AbuseReporter.createAbuseReport(addonId, {
      reportEntryPoint,
    });

    if (!SUPPORTED_ADDON_TYPES.includes(report.addon.type)) {
      throw new Error(
        `Addon type "${report.addon.type}" is not currently supported by the integrated abuse reporting feature`
      );
    }

    const params = Cc["@mozilla.org/array;1"].createInstance(
      Ci.nsIMutableArray
    );

    const dialogInit = {
      report,
      openWebLink(url) {
        chromeWin.openWebLinkIn(url, "tab", {
          relatedToCurrent: true,
        });
      },
    };

    params.appendElement(dialogInit);

    let win;
    function closeDialog() {
      if (win && !win.closed) {
        win.close();
      }
    }

    const promiseReport = new Promise((resolve, reject) => {
      dialogInit.deferredReport = { resolve, reject };
    }).then(
      ({ userCancelled }) => {
        closeDialog();
        return userCancelled ? undefined : report;
      },
      err => {
        Cu.reportError(
          `Unexpected abuse report panel error: ${err} :: ${err.stack}`
        );
        closeDialog();
        return Promise.reject({
          message: "Unexpected abuse report panel error",
        });
      }
    );

    const promiseReportPanel = new Promise((resolve, reject) => {
      dialogInit.deferredReportPanel = { resolve, reject };
    });

    dialogInit.promiseReport = promiseReport;
    dialogInit.promiseReportPanel = promiseReportPanel;

    win = Services.ww.openWindow(
      chromeWin,
      "chrome://mozapps/content/extensions/abuse-report-frame.html",
      DIALOG_WINDOW_NAME,
      // Set the dialog window options (including a reasonable initial
      // window height size, eventually adjusted by the panel once it
      // has been rendered its content).
      "dialog,centerscreen,height=700",
      params
    );

    return {
      close: closeDialog,
      promiseReport,

      // Properties used in tests
      promiseReportPanel,
      window: win,
    };
  },
};

/**
 * Represents an ongoing abuse report. Instances of this class are created
 * by the `AbuseReporter.createAbuseReport` method.
 *
 * This object is used by the reporting UI panel and message bars to:
 *
 * - get an errorType in case of a report creation error (e.g. because of a
 *   previously submitted report)
 * - get the addon details used inside the reporting panel
 * - submit the abuse report (and re-submit if a previous submission failed
 *   and the user choose to retry to submit it again)
 * - abort an ongoing submission
 *
 * @param {object}            options
 * @param {AddonWrapper|null} options.addon
 *        AddonWrapper instance for the extension/theme being reported.
 *        (May be null if the extension has not been found).
 * @param {object|null}       options.reportData
 *        An object which contains addon and environment details to send as part of a submission
 *        (may be null if the report has a createErrorType).
 * @param {string}            options.reportEntryPoint
 *        A string that identify how the report has been triggered.
 */
class AbuseReport {
  constructor({ addon, createErrorType, reportData, reportEntryPoint }) {
    this[PRIVATE_REPORT_PROPS] = {
      aborted: false,
      abortController: new AbortController(),
      addon,
      reportData,
      reportEntryPoint,
      // message and reason are initially null, and then set by the panel
      // using the related set method.
      message: null,
      reason: null,
    };
  }

  recordTelemetry(errorType) {
    const { addon, reportEntryPoint } = this;
    AMTelemetry.recordReportEvent({
      addonId: addon.id,
      addonType: addon.type,
      errorType,
      reportEntryPoint,
    });
  }

  /**
   * Submit the current report, given a reason and a message.
   *
   * @returns {Promise<void>}
   *          Resolves once the report has been successfully submitted.
   *          It rejects with an AbuseReportError if the report couldn't be
   *          submitted for a known reason (or another Error type otherwise).
   */
  async submit() {
    const {
      aborted,
      abortController,
      message,
      reason,
      reportData,
      reportEntryPoint,
    } = this[PRIVATE_REPORT_PROPS];

    // Record telemetry event and throw an AbuseReportError.
    const rejectReportError = async (errorType, { response } = {}) => {
      this.recordTelemetry(errorType);

      // Leave errorInfo empty if there is no response or fails to
      // be converted into an error info object.
      const errorInfo = response
        ? await responseToErrorInfo(response).catch(err => undefined)
        : undefined;

      throw new AbuseReportError(errorType, errorInfo);
    };

    if (aborted) {
      // Report aborted before being actually submitted.
      return rejectReportError("ERROR_ABORTED_SUBMIT");
    }

    // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
    let msFromLastReport = AbuseReporter.getTimeFromLastReport();
    if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
      return rejectReportError("ERROR_RECENT_SUBMIT");
    }

    let response;
    try {
      response = await fetch(ABUSE_REPORT_URL, {
        signal: abortController.signal,
        method: "POST",
        credentials: "omit",
        referrerPolicy: "no-referrer",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          ...reportData,
          report_entry_point: reportEntryPoint,
          message,
          reason,
        }),
      });
    } catch (err) {
      if (err.name === "AbortError") {
        return rejectReportError("ERROR_ABORTED_SUBMIT");
      }
      Cu.reportError(err);
      return rejectReportError("ERROR_NETWORK");
    }

    if (response.ok && response.status >= 200 && response.status < 400) {
      // Ensure that the response is also a valid json format.
      try {
        await response.json();
      } catch (err) {
        this.recordTelemetry("ERROR_UNKNOWN");
        throw err;
      }
      AbuseReporter.updateLastReportTimestamp();
      this.recordTelemetry();
      return undefined;
    }

    if (response.status >= 400 && response.status < 500) {
      return rejectReportError("ERROR_CLIENT", { response });
    }

    if (response.status >= 500 && response.status < 600) {
      return rejectReportError("ERROR_SERVER", { response });
    }

    // We got an unexpected HTTP status code.
    return rejectReportError("ERROR_UNKNOWN", { response });
  }

  /**
   * Abort the report submission.
   */
  abort() {
    const { abortController } = this[PRIVATE_REPORT_PROPS];
    abortController.abort();
    this[PRIVATE_REPORT_PROPS].aborted = true;
  }

  get addon() {
    return this[PRIVATE_REPORT_PROPS].addon;
  }

  get reportEntryPoint() {
    return this[PRIVATE_REPORT_PROPS].reportEntryPoint;
  }

  /**
   * Set the open message (called from the panel when the user submit the report)
   *
   * @parm {string} message
   *         An optional string which contains a description for the reported issue.
   */
  setMessage(message) {
    this[PRIVATE_REPORT_PROPS].message = message;
  }

  /**
   * Set the report reason (called from the panel when the user submit the report)
   *
   * @parm {string} reason
   *       String identifier for the report reason.
   */
  setReason(reason) {
    this[PRIVATE_REPORT_PROPS].reason = reason;
  }
}