Backed out changeset f594e6f00208 (bug 1940883) for causing crashes in bug 1941164.
[gecko.git] / toolkit / components / captchadetection / CaptchaDetectionParent.sys.mjs
blobd7632db44f99c48e41c7ec2c935f87312b03ae5b
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
5 /** @type {lazy} */
6 const lazy = {};
8 ChromeUtils.defineLazyGetter(lazy, "console", () => {
9   return console.createInstance({
10     prefix: "CaptchaDetectionParent",
11     maxLogLevelPref: "captchadetection.loglevel",
12   });
13 });
15 ChromeUtils.defineESModuleGetters(lazy, {
16   CaptchaDetectionPingUtils:
17     "resource://gre/modules/CaptchaDetectionPingUtils.sys.mjs",
18   CaptchaResponseObserver:
19     "resource://gre/modules/CaptchaResponseObserver.sys.mjs",
20 });
22 /**
23  * Holds the state of each tab.
24  * The state is an object with the following structure:
25  * [key: tabId]: typeof ReturnType<TabState.#defaultValue()>
26  */
27 class TabState {
28   #state;
30   constructor() {
31     this.#state = new Map();
32   }
34   /**
35    * @param {number} tabId - The tab id.
36    * @returns {Map<any, any>} - The state of the tab.
37    */
38   get(tabId) {
39     return this.#state.get(tabId);
40   }
42   static #defaultValue() {
43     return new Map();
44   }
46   /**
47    * @param {number} tabId - The tab id.
48    * @param {(state: ReturnType<TabState['get']>) => void} updateFunction - The function to update the state.
49    */
50   update(tabId, updateFunction) {
51     if (!this.#state.has(tabId)) {
52       this.#state.set(tabId, TabState.#defaultValue());
53     }
54     updateFunction(this.#state.get(tabId));
55   }
57   /**
58    * @param {number} tabId - The tab id.
59    */
60   clear(tabId) {
61     this.#state.delete(tabId);
62   }
65 const tabState = new TabState();
67 /**
68  * This actor parent is responsible for recording the state of captchas
69  * or communicating with parent browsing context.
70  */
71 class CaptchaDetectionParent extends JSWindowActorParent {
72   #responseObserver;
74   actorCreated() {
75     lazy.console.debug("actorCreated");
76   }
78   actorDestroy() {
79     lazy.console.debug("actorDestroy()");
81     if (this.#responseObserver) {
82       this.#responseObserver.unregister();
83     }
84   }
86   /** @type {CaptchaStateUpdateFunction} */
87   #updateGRecaptchaV2State({ tabId, isPBM, state: { type, changes } }) {
88     lazy.console.debug("updateGRecaptchaV2State", changes);
90     if (changes === "ImagesShown") {
91       tabState.update(tabId, state => {
92         state.set(type + changes, true);
93       });
95       // We don't call maybeSubmitPing here because we might end up
96       // submitting the ping without the "GotCheckmark" event.
97       // maybeSubmitPing will be called when "GotCheckmark" event is
98       // received, or when the daily maybeSubmitPing is called.
99       const shownMetric = "googleRecaptchaV2Ps" + (isPBM ? "Pbm" : "");
100       Glean.captchaDetection[shownMetric].add(1);
101     } else if (changes === "GotCheckmark") {
102       const autoCompleted = !tabState.get(tabId)?.has(type + "ImagesShown");
103       lazy.console.debug(
104         "GotCheckmark" +
105           (autoCompleted ? " (auto-completed)" : " (manually-completed)")
106       );
107       const resultMetric =
108         "googleRecaptchaV2" +
109         (autoCompleted ? "Ac" : "Pc") +
110         (isPBM ? "Pbm" : "");
111       Glean.captchaDetection[resultMetric].add(1);
112       this.#onMetricSet();
113     }
114   }
116   /** @type {CaptchaStateUpdateFunction} */
117   #recordCFTurnstileResult({ isPBM, state: { result } }) {
118     lazy.console.debug("recordCFTurnstileResult", result);
119     const resultMetric =
120       "cloudflareTurnstile" +
121       (result === "Succeeded" ? "Cc" : "Cf") +
122       (isPBM ? "Pbm" : "");
123     Glean.captchaDetection[resultMetric].add(1);
124     this.#onMetricSet();
125   }
127   async #datadomeInit() {
128     const parent = this.browsingContext.parentWindowContext;
129     if (!parent) {
130       lazy.console.error("Datadome captcha loaded in a top-level window?");
131       return;
132     }
134     let actor = null;
135     try {
136       actor = parent.getActor("CaptchaDetectionCommunication");
137       if (!actor) {
138         lazy.console.error("CaptchaDetection actor not found in parent window");
139         return;
140       }
141     } catch (e) {
142       lazy.console.error("Error getting actor", e);
143       return;
144     }
146     await actor.sendQuery("Datadome:AddMessageListener");
147   }
149   /** @type {CaptchaStateUpdateFunction} */
150   #recordDatadomeEvent({ isPBM, state: { event, ...payload } }) {
151     lazy.console.debug("recordDatadomeEvent", event, payload);
152     const suffix = isPBM ? "Pbm" : "";
153     if (event === "load") {
154       if (payload.captchaShown) {
155         Glean.captchaDetection["datadomePs" + suffix].add(1);
156       } else if (payload.blocked) {
157         Glean.captchaDetection["datadomeBl" + suffix].add(1);
158       }
159     } else if (event === "passed") {
160       Glean.captchaDetection["datadomePc" + suffix].add(1);
161     }
163     this.#onMetricSet(0);
164   }
166   /** @type {CaptchaStateUpdateFunction} */
167   #recordHCaptchaState({ isPBM, tabId, state: { type, changes } }) {
168     lazy.console.debug("recordHCaptchaEvent", changes);
170     if (changes === "shown") {
171       // I don't think HCaptcha supports auto-completion, but we act
172       // as if it does just in case.
173       tabState.update(tabId, state => {
174         state.set(type + changes, true);
175       });
177       // We don't call maybeSubmitPing here because we might end up
178       // submitting the ping without the "passed" event.
179       // maybeSubmitPing will be called when "passed" event is
180       // received, or when the daily maybeSubmitPing is called.
181       const shownMetric = "hcaptchaPs" + (isPBM ? "Pbm" : "");
182       Glean.captchaDetection[shownMetric].add(1);
183     } else if (changes === "passed") {
184       const autoCompleted = !tabState.get(tabId)?.has(type + "shown");
185       const resultMetric =
186         "hcaptcha" + (autoCompleted ? "Ac" : "Pc") + (isPBM ? "Pbm" : "");
187       Glean.captchaDetection[resultMetric].add(1);
188       this.#onMetricSet();
189     }
190   }
192   /** @type {CaptchaStateUpdateFunction} */
193   #recordAWSWafEvent({
194     isPBM,
195     state: { event, success, numSolutionsRequired },
196   }) {
197     if (event === "shown") {
198       // We don't call maybeSubmitPing here because we might end up
199       // submitting the ping without the "completed" event.
200       // maybeSubmitPing will be called when "completed" event is
201       // received, or when the daily maybeSubmitPing is called.
202       const shownMetric = "awswafPs" + (isPBM ? "Pbm" : "");
203       Glean.captchaDetection[shownMetric].add(1);
204     } else if (event === "completed") {
205       const suffix = isPBM ? "Pbm" : "";
206       const resultMetric = "awswaf" + (success ? "Pc" : "Pf") + suffix;
207       Glean.captchaDetection[resultMetric].add(1);
209       const solutionsRequiredMetric =
210         Glean.captchaDetection["awswafSolutionsRequired" + suffix];
211       solutionsRequiredMetric.accumulateSingleSample(numSolutionsRequired);
213       this.#onMetricSet();
214     }
215   }
217   /** @type {CaptchaStateUpdateFunction} */
218   #recordArkoseLabsEvent({
219     isPBM,
220     state: { event, solved, solutionsSubmitted },
221   }) {
222     if (event === "shown") {
223       // We don't call maybeSubmitPing here because we might end up
224       // submitting the ping without the "completed" event.
225       // maybeSubmitPing will be called when "completed" event is
226       // received, or when the daily maybeSubmitPing is called.
227       const shownMetric = "arkoselabsPs" + (isPBM ? "Pbm" : "");
228       Glean.captchaDetection[shownMetric].add(1);
229     } else if (event === "completed") {
230       const suffix = isPBM ? "Pbm" : "";
231       const resultMetric = "arkoselabs" + (solved ? "Pc" : "Pf") + suffix;
232       Glean.captchaDetection[resultMetric].add(1);
234       const solutionsRequiredMetric =
235         Glean.captchaDetection["arkoselabsSolutionsRequired" + suffix];
236       solutionsRequiredMetric.accumulateSingleSample(solutionsSubmitted);
238       this.#onMetricSet();
239     }
240   }
242   async #awsWafInit() {
243     this.#responseObserver = new lazy.CaptchaResponseObserver(
244       channel =>
245         channel.loadInfo?.browsingContextID === this.browsingContext.id &&
246         channel.URI &&
247         channel.URI.scheme === "https" &&
248         (Cu.isInAutomation ||
249           channel.URI.host.endsWith(".captcha.awswaf.com")) &&
250         channel.URI.filePath.endsWith(
251           Cu.isInAutomation ? "aws_waf_api.sjs" : "/verify"
252         ),
253       (_channel, statusCode, responseBody) => {
254         if (statusCode !== Cr.NS_OK) {
255           return;
256         }
258         let body;
259         try {
260           body = JSON.parse(responseBody);
261           if (!body) {
262             lazy.console.debug("handleResponseBody", "Failed to parse JSON");
263             return;
264           }
265         } catch (e) {
266           lazy.console.debug(
267             "handleResponseBody",
268             "Failed to parse JSON",
269             e,
270             responseBody
271           );
272           return;
273         }
275         // Check for the presence of the expected keys
276         if (
277           !["success", "num_solutions_required"].every(key =>
278             body.hasOwnProperty(key)
279           )
280         ) {
281           lazy.console.debug("handleResponseBody", "Missing keys", body);
282           return;
283         }
285         this.#recordAWSWafEvent({
286           isPBM: this.browsingContext.usePrivateBrowsing,
287           state: {
288             event: "completed",
289             success: body.success,
290             numSolutionsRequired: body.num_solutions_required,
291           },
292         });
293       }
294     );
295     this.#responseObserver.register();
296   }
298   async #arkoseLabsInit() {
299     let solutionsSubmitted = 0;
300     this.#responseObserver = new lazy.CaptchaResponseObserver(
301       channel =>
302         channel.loadInfo?.browsingContextID === this.browsingContext.id &&
303         channel.URI &&
304         (Cu.isInAutomation
305           ? channel.URI.filePath.endsWith("arkose_labs_api.sjs")
306           : channel.URI.spec === "https://client-api.arkoselabs.com/fc/ca/"),
307       (_channel, statusCode, responseBody) => {
308         if (statusCode !== Cr.NS_OK) {
309           return;
310         }
312         let body;
313         try {
314           body = JSON.parse(responseBody);
315           if (!body) {
316             lazy.console.debug(
317               "ResponseObserver:ResponseBody",
318               "Failed to parse JSON"
319             );
320             return;
321           }
322         } catch (e) {
323           lazy.console.debug(
324             "ResponseObserver:ResponseBody",
325             "Failed to parse JSON",
326             e,
327             responseBody
328           );
329           return;
330         }
332         // Check for the presence of the expected keys
333         if (["response", "solved"].some(key => !body.hasOwnProperty(key))) {
334           lazy.console.debug(
335             "ResponseObserver:ResponseBody",
336             "Missing keys",
337             body
338           );
339           return;
340         }
342         solutionsSubmitted++;
343         if (body.solved === null) {
344           return;
345         }
347         this.#recordArkoseLabsEvent({
348           isPBM: this.browsingContext.usePrivateBrowsing,
349           state: {
350             event: "completed",
351             solved: body.solved,
352             solutionsSubmitted,
353           },
354         });
356         solutionsSubmitted = 0;
357       }
358     );
359     this.#responseObserver.register();
360   }
362   #onTabClosed(tabId) {
363     tabState.clear(tabId);
365     if (this.#responseObserver) {
366       this.#responseObserver.unregister();
367     }
368   }
370   async #onMetricSet(parentDepth = 1) {
371     lazy.CaptchaDetectionPingUtils.maybeSubmitPing();
372     if (Cu.isInAutomation) {
373       await this.#notifyTestMetricIsSet(parentDepth);
374     }
375   }
377   /**
378    * Notify the `parentDepth`'nth parent browsing context that the test metric is set.
379    *
380    * @param {number} parentDepth - The depth of the parent window context.
381    * The reason we need this param is because Datadome calls this method
382    * not from the captcha iframe, but its parent browsing context. So
383    * it overrides the depth to 0.
384    */
385   async #notifyTestMetricIsSet(parentDepth = 1) {
386     if (!Cu.isInAutomation) {
387       throw new Error("This method should only be called in automation");
388     }
390     let parent = this.browsingContext.currentWindowContext;
391     for (let i = 0; i < parentDepth; i++) {
392       parent = parent.parentWindowContext;
393       if (!parent) {
394         lazy.console.error("No parent window context");
395         return;
396       }
397     }
399     let actor = null;
400     try {
401       actor = parent.getActor("CaptchaDetectionCommunication");
402       if (!actor) {
403         lazy.console.error("CaptchaDetection actor not found in parent window");
404         return;
405       }
406     } catch (e) {
407       lazy.console.error("Error getting actor", e);
408       return;
409     }
411     await actor.sendQuery("Testing:MetricIsSet");
412   }
414   async receiveMessage(message) {
415     lazy.console.debug("receiveMessage", message);
417     switch (message.name) {
418       case "CaptchaState:Update":
419         switch (message.data.state.type) {
420           case "g-recaptcha-v2":
421             this.#updateGRecaptchaV2State(message.data);
422             break;
423           case "cf-turnstile":
424             this.#recordCFTurnstileResult(message.data);
425             break;
426           case "datadome":
427             this.#recordDatadomeEvent(message.data);
428             break;
429           case "hCaptcha":
430             this.#recordHCaptchaState(message.data);
431             break;
432           case "awsWaf":
433             this.#recordAWSWafEvent(message.data);
434             break;
435         }
436         break;
437       case "TabState:Closed":
438         // message.name === "TabState:Closed"
439         // => message.data = {
440         //   tabId: number,
441         // }
442         this.#onTabClosed(message.data.tabId);
443         break;
444       case "CaptchaDetection:Init":
445         // message.name === "CaptchaDetection:Init"
446         // => message.data = {
447         //   type: string,
448         // }
449         switch (message.data.type) {
450           case "datadome":
451             return this.#datadomeInit();
452           case "awsWaf":
453             return this.#awsWafInit();
454           case "arkoseLabs":
455             return this.#arkoseLabsInit();
456         }
457         break;
458       default:
459         lazy.console.error("Unknown message", message);
460     }
461     return null;
462   }
465 export {
466   CaptchaDetectionParent,
467   CaptchaDetectionParent as CaptchaDetectionCommunicationParent,
471  * @typedef lazy
472  * @type {object}
473  * @property {ConsoleInstance} console - console instance.
474  * @property {typeof import("./CaptchaDetectionPingUtils.sys.mjs").CaptchaDetectionPingUtils} CaptchaDetectionPingUtils - CaptchaDetectionPingUtils module.
475  * @property {typeof import("./CaptchaResponseObserver.sys.mjs").CaptchaResponseObserver} CaptchaResponseObserver - CaptchaResponseObserver module.
476  */
479  * @typedef CaptchaStateUpdateMessageData
480  * @property {number} tabId - The tab id.
481  * @property {boolean} isPBM - Whether the tab is in PBM.
482  * @property {object} state - The state of the captcha.
483  * @property {string} state.type - The type of the captcha.
485  * @typedef {(message: CaptchaStateUpdateMessageData) => void} CaptchaStateUpdateFunction
486  */