/* 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/. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; // Maximum length of the string properties sent to the API endpoint. const MAX_STRING_LENGTH = 255; const AMO_SUPPORTED_ADDON_TYPES = [ "extension", "theme", "sitepermission", "dictionary", ]; const PREF_ADDON_ABUSE_REPORT_URL = "extensions.addonAbuseReport.url"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", ClientID: "resource://gre/modules/ClientID.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "ADDON_ABUSE_REPORT_URL", PREF_ADDON_ABUSE_REPORT_URL ); const ERROR_TYPES = Object.freeze([ "ERROR_CLIENT", "ERROR_NETWORK", "ERROR_SERVER", "ERROR_UNKNOWN", ]); export class AbuseReportError extends Error { constructor(errorType, errorInfo = undefined) { if (!ERROR_TYPES.includes(errorType)) { throw new Error(`Unexpected 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(() => ""), }); } /** * A singleton used to manage abuse reports for add-ons. */ export const AbuseReporter = { getAMOFormURL({ addonId }) { return Services.urlFormatter .formatURLPref("extensions.abuseReport.amoFormURL") .replace(/%addonID%/g, addonId); }, isSupportedAddonType(addonType) { return AMO_SUPPORTED_ADDON_TYPES.includes(addonType); }, /** * Send an add-on abuse report using the AMO API. The data passed to this * method might be augmented with report data known by Firefox. * * @param {string} addonId * @param {{[key: string]: string|null}} data * Abuse report data to be submitting to the AMO API along with the * additional abuse report data known by Firefox. * @param {object} [options] * @param {string} [options.authorization] * An optional value of an Authorization HTTP header to be set on the * submission request. * * @returns {Promise} Return a promise that resolves to the JSON AMO * API response (or an error when something went wrong). */ async sendAbuseReport(addonId, data, options = {}) { const rejectReportError = async (errorType, { response } = {}) => { // 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(() => undefined) : undefined; throw new AbuseReportError(errorType, errorInfo); }; let abuseReport = { addon: addonId, ...data }; // If the add-on is installed, augment the data with internal report data. const addon = await lazy.AddonManager.getAddonByID(addonId); if (addon) { const metadata = await AbuseReporter.getReportData(addon); abuseReport = { ...abuseReport, ...metadata }; } const headers = { "Content-Type": "application/json" }; if (options?.authorization?.length) { headers.authorization = options.authorization; } let response; try { response = await fetch(lazy.ADDON_ABUSE_REPORT_URL, { method: "POST", credentials: "omit", referrerPolicy: "no-referrer", headers, body: JSON.stringify(abuseReport), }); } catch (err) { Cu.reportError(err); return rejectReportError("ERROR_NETWORK"); } if (response.ok && response.status >= 200 && response.status < 400) { return response.json(); } if (response.status >= 400 && response.status < 500) { return rejectReportError("ERROR_CLIENT", { response }); } if (response.status >= 500 && response.status < 600) { return rejectReportError("ERROR_SERVER", { response }); } return rejectReportError("ERROR_UNKNOWN", { response }); }, /** * 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 lazy.AddonManager.SIGNEDSTATE_BROKEN: data.addon_signature = "broken"; break; case lazy.AddonManager.SIGNEDSTATE_UNKNOWN: data.addon_signature = "unknown"; break; case lazy.AddonManager.SIGNEDSTATE_MISSING: data.addon_signature = "missing"; break; case lazy.AddonManager.SIGNEDSTATE_PRELIMINARY: data.addon_signature = "preliminary"; break; case lazy.AddonManager.SIGNEDSTATE_SIGNED: data.addon_signature = "signed"; break; case lazy.AddonManager.SIGNEDSTATE_SYSTEM: data.addon_signature = "system"; break; case lazy.AddonManager.SIGNEDSTATE_PRIVILEGED: data.addon_signature = "privileged"; break; case lazy.AddonManager.SIGNEDSTATE_NOT_REQUIRED: data.addon_signature = "not_required"; 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 lazy.ClientID.getClientIdHash(); data.app = AppConstants.platform === "android" ? "android" : "firefox"; 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; }, };