Files
tubestation/toolkit/components/captchadetection/CaptchaDetectionParent.sys.mjs

432 lines
13 KiB
JavaScript

/* 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 each tab.
* The state is an object with the following structure:
* [key: tabId]: typeof ReturnType<TabState.#defaultValue()>
*/
class TabState {
#state;
constructor() {
this.#state = new Map();
}
/**
* @param {number} tabId - The tab id.
* @returns {Map<any, any>} - The state of the tab.
*/
get(tabId) {
return this.#state.get(tabId);
}
static #defaultValue() {
return new Map();
}
/**
* @param {number} tabId - The tab id.
* @param {(state: ReturnType<TabState['get']>) => void} updateFunction - The function to update the state.
*/
update(tabId, updateFunction) {
if (!this.#state.has(tabId)) {
this.#state.set(tabId, TabState.#defaultValue());
}
updateFunction(this.#state.get(tabId));
}
/**
* @param {number} tabId - The tab id.
*/
clear(tabId) {
this.#state.delete(tabId);
}
}
const tabState = new TabState();
/**
* 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()");
if (this.#responseObserver) {
this.#responseObserver.unregister();
}
}
/** @type {CaptchaStateUpdateFunction} */
#updateGRecaptchaV2State({ tabId, isPBM, state: { type, changes } }) {
lazy.console.debug("updateGRecaptchaV2State", changes);
if (changes === "ImagesShown") {
tabState.update(tabId, 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 = !tabState.get(tabId)?.has(type + "ImagesShown");
lazy.console.debug(
"GotCheckmark" +
(autoCompleted ? " (auto-completed)" : " (manually-completed)")
);
const resultMetric =
"googleRecaptchaV2" +
(autoCompleted ? "Ac" : "Pc") +
(isPBM ? "Pbm" : "");
Glean.captchaDetection[resultMetric].add(1);
this.#onMetricSet();
}
}
/** @type {CaptchaStateUpdateFunction} */
#recordCFTurnstileResult({ isPBM, state: { result } }) {
lazy.console.debug("recordCFTurnstileResult", result);
const resultMetric =
"cloudflareTurnstile" +
(result === "Succeeded" ? "Cc" : "Cf") +
(isPBM ? "Pbm" : "");
Glean.captchaDetection[resultMetric].add(1);
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({ isPBM, state: { event, ...payload } }) {
lazy.console.debug("recordDatadomeEvent", event, payload);
const suffix = isPBM ? "Pbm" : "";
if (event === "load") {
if (payload.captchaShown) {
Glean.captchaDetection["datadomePs" + suffix].add(1);
} else if (payload.blocked) {
Glean.captchaDetection["datadomeBl" + suffix].add(1);
}
} else if (event === "passed") {
Glean.captchaDetection["datadomePc" + suffix].add(1);
}
this.#onMetricSet(0);
}
/** @type {CaptchaStateUpdateFunction} */
#recordHCaptchaState({ isPBM, tabId, state: { type, changes } }) {
lazy.console.debug("recordHCaptchaEvent", changes);
if (changes === "shown") {
// I don't think HCaptcha supports auto-completion, but we act
// as if it does just in case.
tabState.update(tabId, 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);
} else if (changes === "passed") {
const autoCompleted = !tabState.get(tabId)?.has(type + "shown");
const resultMetric =
"hcaptcha" + (autoCompleted ? "Ac" : "Pc") + (isPBM ? "Pbm" : "");
Glean.captchaDetection[resultMetric].add(1);
this.#onMetricSet();
}
}
/** @type {CaptchaStateUpdateFunction} */
#recordArkoseLabsEvent({
isPBM,
state: { event, solved, solutionsSubmitted },
}) {
if (event === "shown") {
// We don't call maybeSubmitPing here because we might end up
// submitting the ping without the "completed" event.
// maybeSubmitPing will be called when "completed" event is
// received, or when the daily maybeSubmitPing is called.
const shownMetric = "arkoselabsPs" + (isPBM ? "Pbm" : "");
Glean.captchaDetection[shownMetric].add(1);
} else if (event === "completed") {
const suffix = isPBM ? "Pbm" : "";
const resultMetric = "arkoselabs" + (solved ? "Pc" : "Pf") + suffix;
Glean.captchaDetection[resultMetric].add(1);
const solutionsRequiredMetric =
Glean.captchaDetection["arkoselabsSolutionsRequired" + suffix];
solutionsRequiredMetric.accumulateSingleSample(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 (body.solved === null) {
return;
}
this.#recordArkoseLabsEvent({
isPBM: this.browsingContext.usePrivateBrowsing,
state: {
event: "completed",
solved: body.solved,
solutionsSubmitted,
},
});
solutionsSubmitted = 0;
}
);
this.#responseObserver.register();
}
#onTabClosed(tabId) {
tabState.clear(tabId);
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({ isPBM, 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 += isPBM ? "Pbm" : "";
Glean.captchaDetection[metric].add(1);
}
async receiveMessage(message) {
lazy.console.debug("receiveMessage", message);
switch (message.name) {
case "CaptchaState:Update":
switch (message.data.state.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 = {
// isPBM: bool,
// type: string,
// }
this.recordCaptchaHandlerConstructed(message.data);
break;
case "TabState:Closed":
// message.name === "TabState:Closed"
// => message.data = {
// tabId: number,
// }
this.#onTabClosed(message.data.tabId);
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
* @property {number} tabId - The tab id.
* @property {boolean} isPBM - Whether the tab is in PBM.
* @property {object} state - The state of the captcha.
* @property {string} state.type - The type of the captcha.
*
* @typedef {(message: CaptchaStateUpdateMessageData) => void} CaptchaStateUpdateFunction
*/