/* 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} * 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} * 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} * 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} * 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 { 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} * 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; } }