/* 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 https://mozilla.org/MPL/2.0/. */ /** @type {lazy} */ const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "console", () => { return console.createInstance({ prefix: "CaptchaDetectionParent", maxLogLevelPref: "captchadetection.loglevel", }); }); ChromeUtils.defineESModuleGetters(lazy, { CaptchaDetectionPingUtils: "resource://gre/modules/CaptchaDetectionPingUtils.sys.mjs", CaptchaResponseObserver: "resource://gre/modules/CaptchaResponseObserver.sys.mjs", }); /** * Holds the state of captchas for each top document. * Currently, only used by google reCAPTCHA v2 and hCaptcha. * The state is an object with the following structure: * [key: topBrowsingContextId]: typeof ReturnType */ class DocCaptchaState { #state; constructor() { this.#state = new Map(); } /** * @param {number} topId - The top bc id. * @returns {Map} - The state of the top bc. */ get(topId) { return this.#state.get(topId); } static #defaultValue() { return new Map(); } /** * @param {number} topId - The top bc id. * @param {(state: ReturnType) => void} updateFunction - The function to update the state. */ update(topId, updateFunction) { if (!this.#state.has(topId)) { this.#state.set(topId, DocCaptchaState.#defaultValue()); } updateFunction(this.#state.get(topId)); } /** * @param {number} topId - The top doc id. */ clear(topId) { this.#state.delete(topId); } } const docState = new DocCaptchaState(); /** * This actor parent is responsible for recording the state of captchas * or communicating with parent browsing context. */ class CaptchaDetectionParent extends JSWindowActorParent { #responseObserver; actorCreated() { lazy.console.debug("actorCreated"); } actorDestroy() { lazy.console.debug("actorDestroy()"); this.#onPageHidden(); } /** @type {CaptchaStateUpdateFunction} */ #updateGRecaptchaV2State({ changes, type }) { lazy.console.debug("updateGRecaptchaV2State", changes); const topId = this.#topInnerWindowId; const isPBM = this.browsingContext.usePrivateBrowsing; if (changes === "ImagesShown") { docState.update(topId, state => { state.set(type + changes, true); }); // We don't call maybeSubmitPing here because we might end up // submitting the ping without the "GotCheckmark" event. // maybeSubmitPing will be called when "GotCheckmark" event is // received, or when the daily maybeSubmitPing is called. const shownMetric = "googleRecaptchaV2Ps" + (isPBM ? "Pbm" : ""); Glean.captchaDetection[shownMetric].add(1); } else if (changes === "GotCheckmark") { const autoCompleted = !docState.get(topId)?.has(type + "ImagesShown"); const resultMetric = "googleRecaptchaV2" + (autoCompleted ? "Ac" : "Pc") + (isPBM ? "Pbm" : ""); Glean.captchaDetection[resultMetric].add(1); lazy.console.debug("Incremented metric", resultMetric); docState.clear(topId); this.#onMetricSet(); } } /** @type {CaptchaStateUpdateFunction} */ #recordCFTurnstileResult({ result }) { lazy.console.debug("recordCFTurnstileResult", result); const isPBM = this.browsingContext.usePrivateBrowsing; const resultMetric = "cloudflareTurnstile" + (result === "Succeeded" ? "Cc" : "Cf") + (isPBM ? "Pbm" : ""); Glean.captchaDetection[resultMetric].add(1); lazy.console.debug("Incremented metric", resultMetric); this.#onMetricSet(); } async #datadomeInit() { const parent = this.browsingContext.parentWindowContext; if (!parent) { lazy.console.error("Datadome captcha loaded in a top-level window?"); return; } let actor = null; try { actor = parent.getActor("CaptchaDetectionCommunication"); if (!actor) { lazy.console.error("CaptchaDetection actor not found in parent window"); return; } } catch (e) { lazy.console.error("Error getting actor", e); return; } await actor.sendQuery("Datadome:AddMessageListener"); } /** @type {CaptchaStateUpdateFunction} */ #recordDatadomeEvent({ event, ...payload }) { lazy.console.debug("recordDatadomeEvent", { event, payload }); const suffix = this.browsingContext.usePrivateBrowsing ? "Pbm" : ""; let metricName = "datadome"; if (event === "load") { if (payload.captchaShown) { metricName += "Ps"; } else if (payload.blocked) { metricName += "Bl"; } } else if (event === "passed") { metricName += "Pc"; } else { lazy.console.error("Unknown Datadome event", event); return; } metricName += suffix; Glean.captchaDetection[metricName].add(1); lazy.console.debug("Incremented metric", metricName); this.#onMetricSet(0); } /** @type {CaptchaStateUpdateFunction} */ #recordHCaptchaState({ changes, type }) { lazy.console.debug("recordHCaptchaEvent", changes); const topId = this.#topInnerWindowId; const isPBM = this.browsingContext.usePrivateBrowsing; if (changes === "shown") { // I don't think HCaptcha supports auto-completion, but we act // as if it does just in case. docState.update(topId, state => { state.set(type + changes, true); }); // We don't call maybeSubmitPing here because we might end up // submitting the ping without the "passed" event. // maybeSubmitPing will be called when "passed" event is // received, or when the daily maybeSubmitPing is called. const shownMetric = "hcaptchaPs" + (isPBM ? "Pbm" : ""); Glean.captchaDetection[shownMetric].add(1); lazy.console.debug("Incremented metric", shownMetric); } else if (changes === "passed") { const autoCompleted = !docState.get(topId)?.has(type + "shown"); const resultMetric = "hcaptcha" + (autoCompleted ? "Ac" : "Pc") + (isPBM ? "Pbm" : ""); Glean.captchaDetection[resultMetric].add(1); lazy.console.debug("Incremented metric", resultMetric); docState.clear(topId); this.#onMetricSet(); } } /** @type {CaptchaStateUpdateFunction} */ #recordArkoseLabsEvent({ event, solved, solutionsSubmitted }) { lazy.console.debug("recordArkoseLabsEvent", { event, solved, solutionsSubmitted, }); const isPBM = this.browsingContext.usePrivateBrowsing; const suffix = isPBM ? "Pbm" : ""; const resultMetric = "arkoselabs" + (solved ? "Pc" : "Pf") + suffix; Glean.captchaDetection[resultMetric].add(1); lazy.console.debug("Incremented metric", resultMetric); const metricName = "arkoselabsSolutionsRequired" + suffix; Glean.captchaDetection[metricName].accumulateSingleSample( solutionsSubmitted ); lazy.console.debug("Sampled", metricName, "with", solutionsSubmitted); this.#onMetricSet(); } async #arkoseLabsInit() { let solutionsSubmitted = 0; this.#responseObserver = new lazy.CaptchaResponseObserver( channel => channel.loadInfo?.browsingContextID === this.browsingContext.id && channel.URI && (Cu.isInAutomation ? channel.URI.filePath.endsWith("arkose_labs_api.sjs") : channel.URI.spec === "https://client-api.arkoselabs.com/fc/ca/"), (_channel, statusCode, responseBody) => { if (statusCode !== Cr.NS_OK) { return; } let body; try { body = JSON.parse(responseBody); if (!body) { lazy.console.debug( "ResponseObserver:ResponseBody", "Failed to parse JSON" ); return; } } catch (e) { lazy.console.debug( "ResponseObserver:ResponseBody", "Failed to parse JSON", e, responseBody ); return; } // Check for the presence of the expected keys if (["response", "solved"].some(key => !body.hasOwnProperty(key))) { lazy.console.debug( "ResponseObserver:ResponseBody", "Missing keys", body ); return; } solutionsSubmitted++; if (typeof body.solved !== "boolean") { return; } this.#recordArkoseLabsEvent({ event: "completed", solved: body.solved, solutionsSubmitted, }); solutionsSubmitted = 0; } ); this.#responseObserver.register(); } get #topInnerWindowId() { return this.browsingContext.topWindowContext.innerWindowId; } #onPageHidden() { docState.clear(this.#topInnerWindowId); if (this.#responseObserver) { this.#responseObserver.unregister(); } } async #onMetricSet(parentDepth = 1) { lazy.CaptchaDetectionPingUtils.maybeSubmitPing(); if (Cu.isInAutomation) { await this.#notifyTestMetricIsSet(parentDepth); } } /** * Notify the `parentDepth`'nth parent browsing context that the test metric is set. * * @param {number} parentDepth - The depth of the parent window context. * The reason we need this param is because Datadome calls this method * not from the captcha iframe, but its parent browsing context. So * it overrides the depth to 0. */ async #notifyTestMetricIsSet(parentDepth = 1) { if (!Cu.isInAutomation) { throw new Error("This method should only be called in automation"); } let parent = this.browsingContext.currentWindowContext; for (let i = 0; i < parentDepth; i++) { parent = parent.parentWindowContext; if (!parent) { lazy.console.error("No parent window context"); return; } } let actor = null; try { actor = parent.getActor("CaptchaDetectionCommunication"); if (!actor) { lazy.console.error("CaptchaDetection actor not found in parent window"); return; } } catch (e) { lazy.console.error("Error getting actor", e); return; } await actor.sendQuery("Testing:MetricIsSet"); } recordCaptchaHandlerConstructed({ type }) { lazy.console.debug("recordCaptchaHandlerConstructed", type); let metric = ""; switch (type) { case "g-recaptcha-v2": metric = "googleRecaptchaV2Oc"; break; case "cf-turnstile": metric = "cloudflareTurnstileOc"; break; case "datadome": metric = "datadomeOc"; break; case "hCaptcha": metric = "hcaptchaOc"; break; case "arkoseLabs": metric = "arkoselabsOc"; break; } metric += this.browsingContext.usePrivateBrowsing ? "Pbm" : ""; Glean.captchaDetection[metric].add(1); lazy.console.debug("Incremented metric", metric); } async receiveMessage(message) { lazy.console.debug("receiveMessage", message); switch (message.name) { case "CaptchaState:Update": switch (message.data.type) { case "g-recaptcha-v2": this.#updateGRecaptchaV2State(message.data); break; case "cf-turnstile": this.#recordCFTurnstileResult(message.data); break; case "datadome": this.#recordDatadomeEvent(message.data); break; case "hCaptcha": this.#recordHCaptchaState(message.data); break; } break; case "CaptchaHandler:Constructed": // message.name === "CaptchaHandler:Constructed" // => message.data = { // type: string, // } this.recordCaptchaHandlerConstructed(message.data); break; case "Page:Hide": // message.name === "TabState:Closed" // => message.data = undefined this.#onPageHidden(); break; case "CaptchaDetection:Init": // message.name === "CaptchaDetection:Init" // => message.data = { // type: string, // } switch (message.data.type) { case "datadome": return this.#datadomeInit(); case "arkoseLabs": return this.#arkoseLabsInit(); } break; default: lazy.console.error("Unknown message", message); } return null; } } export { CaptchaDetectionParent, CaptchaDetectionParent as CaptchaDetectionCommunicationParent, }; /** * @typedef lazy * @type {object} * @property {ConsoleInstance} console - console instance. * @property {typeof import("./CaptchaDetectionPingUtils.sys.mjs").CaptchaDetectionPingUtils} CaptchaDetectionPingUtils - CaptchaDetectionPingUtils module. * @property {typeof import("./CaptchaResponseObserver.sys.mjs").CaptchaResponseObserver} CaptchaResponseObserver - CaptchaResponseObserver module. */ /** * @typedef CaptchaStateUpdateMessageData * @type {object} * @property {string} type - The type of the captcha. * * @typedef {(message: CaptchaStateUpdateMessageData) => void} CaptchaStateUpdateFunction */