diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 05294dc88e..5a38d441a9 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -2,6 +2,7 @@ import ContentFeature from '../content-feature.js'; import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; +import { detectWebInterference } from '../utils/web-interference-detection/detector-service.js'; export class ActionExecutorBase extends ContentFeature { /** @@ -91,6 +92,20 @@ export default class BrokerProtection extends ActionExecutorBase { const { action, data } = params.state; return await this.processActionAndNotify(action, data); }); + + this.messaging.subscribe('detectInterference', (/** @type {any} */ params) => { + console.log('[BrokerProtection] detectInterference request received from native', { params }); + try { + const result = detectWebInterference(params); + console.log('[BrokerProtection] Sending detection result to native:', result); + return this.messaging.notify('interferenceDetected', result); + } catch (error) { + console.error('[BrokerProtection] Error detecting interference:', error); + return this.messaging.notify('interferenceDetectionError', { + error: error.toString(), + }); + } + }); } /** diff --git a/injected/src/utils/web-interference-detection/detector-service.js b/injected/src/utils/web-interference-detection/detector-service.js new file mode 100644 index 0000000000..1a8be635b9 --- /dev/null +++ b/injected/src/utils/web-interference-detection/detector-service.js @@ -0,0 +1,106 @@ +import { detectBotInterference } from './interference-types/bot-detection.js'; +import { InterferenceMonitor } from './interference-monitor.js'; + +export function createEmptyResult(type) { + return { + detected: false, + interferenceType: type, + timestamp: Date.now(), + }; +} + +export const typeDetectorsMap = { + bot_detection: detectBotInterference, + youtube_ads: () => createEmptyResult('youtube_ads'), + video_buffering: () => createEmptyResult('video_buffering'), + fraud_detection: () => createEmptyResult('fraud_detection'), +}; + +export function detectTypes(types) { + return Object.fromEntries( + types.map(type => [type, typeDetectorsMap[type]()]) + ); +} + +/** + * Detect web interference (CAPTCHAs, ads, buffering, fraud detection) + * + * @param {Object} config - Detection configuration + * @param {string[]} config.types - Array of interference types to detect + * ['bot_detection', 'youtube_ads', 'video_buffering', 'fraud_detection'] + * @param {boolean} [config.observeDOMChanges] - Enable continuous monitoring via MutationObserver + * Default: false (one-time detection) + * @param {Function} [config.onDetectionChange] - Callback when detection changes (required if observeDOMChanges=true) + * + * @returns {Object|Function} Results object (one-time) or unsubscribe function (observeDOMChanges=true) + * + * Current return structure: + * { + * bot_detection: { + * detected: boolean, + * interferenceType: 'bot_detection', + * results: [ + * { vendor: 'cloudflare', detected: true, challengeType?: 'turnstile' }, + * { vendor: 'hcaptcha', detected: true, challengeType?: 'widget' } + * ], + * timestamp: number + * } + * } + * + * Future return structure (enhanced detection): + * { + * bot_detection: { + * detected: boolean, + * interferenceType: 'bot_detection', + * results: [ + * { + * vendor: 'cloudflare', + * detected: true, + * challengeType: 'turnstile', + * challengeState: 'unsolved', + * confidence: 'high', + * signals: { scripts: true, windowObjects: true, domElements: true } + * }, + * { + * vendor: 'cloudflare', + * detected: true, + * challengeType: 'challenge_page', + * challengeState: null, + * confidence: 'medium', + * signals: { scripts: true, windowObjects: false, domElements: true } + * } + * ], + * timestamp: number + * } + * } + * + * Multiple results from same vendor represent different challenge types + * (e.g., Cloudflare Turnstile + Cloudflare Challenge Page), not multiple instances + * of the same type. + */ +export function detectWebInterference(config) { + if (config.observeDOMChanges) { + const monitor = InterferenceMonitor.getInstance({ detectFn: detectTypes }); + return monitor.addListener(config, config.onDetectionChange); + } + + return detectTypes(config.types); +} + +/** + * Future: Continuous monitoring with MutationObserver + * + * InterferenceMonitor class available in interference-monitor.js. + * Uses singleton pattern so all features share one MutationObserver. + * + * Usage example: + * const unsubscribe = detectWebInterference({ + * types: ['bot_detection'], + * observeDOMChanges: true, + * onDetectionChange: (results) => { + * messaging.notify('interferenceChanged', results) + * } + * }) + * + * unsubscribe() + */ diff --git a/injected/src/utils/web-interference-detection/interference-monitor.js b/injected/src/utils/web-interference-detection/interference-monitor.js new file mode 100644 index 0000000000..0de715121e --- /dev/null +++ b/injected/src/utils/web-interference-detection/interference-monitor.js @@ -0,0 +1,64 @@ +/** + * Prototype for continuous monitoring with MutationObserver (later phase) + */ +export class InterferenceMonitor { + static instance; + + constructor({ detectFn }) { + this.detectFn = detectFn; + this.listeners = new Map(); + this.observer = null; + this.debounceTimer = null; + } + + static getInstance({ detectFn }) { + if (!InterferenceMonitor.instance) { + InterferenceMonitor.instance = new InterferenceMonitor({ detectFn }); + } + return InterferenceMonitor.instance; + } + + addListener(config, onDetectionChange) { + const id = Symbol(); + this.listeners.set(id, { types: config.types, callback: onDetectionChange }); + + if (!this.observer) { + this.start(); + } + + return () => { + this.listeners.delete(id); + if (this.listeners.size === 0) { + this.stop(); + } + }; + } + + start() { + this.observer = new MutationObserver(() => { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + for (const [id, { types, callback }] of this.listeners) { + const results = this.detectFn(types); + callback(results); + } + }, 500); + }); + + this.observer.observe(document.body, { childList: true, subtree: true }); + } + + stop() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } +} diff --git a/injected/src/utils/web-interference-detection/interference-types/bot-detection.js b/injected/src/utils/web-interference-detection/interference-types/bot-detection.js new file mode 100644 index 0000000000..86906d7a6f --- /dev/null +++ b/injected/src/utils/web-interference-detection/interference-types/bot-detection.js @@ -0,0 +1,30 @@ +import { detectCloudflareTurnstile, detectCloudflareChallengePage } from '../vendors/cloudflare.js'; +import { detectHCaptcha } from '../vendors/hcaptcha.js'; + +const challengeDetectorsMap = { + cloudflare_turnstile: detectCloudflareTurnstile, + cloudflare_challenge_page: detectCloudflareChallengePage, + hcaptcha: detectHCaptcha, +}; + +export function detectBotInterference() { + const results = []; + + for (const [challengeName, detector] of Object.entries(challengeDetectorsMap)) { + try { + const result = detector(); + if (result.detected) { + results.push(result); + } + } catch (error) { + console.warn(`[web-interference-detection] ${challengeName} detector failed:`, error); + } + } + + return { + detected: results.length > 0, + interferenceType: 'bot_detection', + results, + timestamp: Date.now(), + }; +} diff --git a/injected/src/utils/web-interference-detection/vendors/cloudflare.js b/injected/src/utils/web-interference-detection/vendors/cloudflare.js new file mode 100644 index 0000000000..d8b716836c --- /dev/null +++ b/injected/src/utils/web-interference-detection/vendors/cloudflare.js @@ -0,0 +1,29 @@ +const TURNSTILE_SELECTORS = ['.cf-turnstile']; +const TURNSTILE_WINDOW_OBJECTS = ['turnstile']; + +const CHALLENGE_PAGE_SELECTORS = ['#challenge-form', '.cf-browser-verification', '#cf-wrapper']; +const CHALLENGE_PAGE_WINDOW_OBJECTS = ['_cf_chl_opt', '__CF$cv$params', 'cfjsd']; + +export function detectCloudflareTurnstile() { + const hasDOM = TURNSTILE_SELECTORS.some((selector) => document.querySelector(selector)); + const hasWindowObjects = TURNSTILE_WINDOW_OBJECTS.some((object) => typeof window[object] !== 'undefined'); + const detected = hasDOM || hasWindowObjects; + + return { + detected, + vendor: 'cloudflare', + challengeType: 'turnstile', + }; +} + +export function detectCloudflareChallengePage() { + const hasDOM = CHALLENGE_PAGE_SELECTORS.some((selector) => document.querySelector(selector)); + const hasWindowObjects = CHALLENGE_PAGE_WINDOW_OBJECTS.some((object) => typeof window[object] !== 'undefined'); + const detected = hasDOM || hasWindowObjects; + + return { + detected, + vendor: 'cloudflare', + challengeType: 'challenge_page', + }; +} diff --git a/injected/src/utils/web-interference-detection/vendors/hcaptcha.js b/injected/src/utils/web-interference-detection/vendors/hcaptcha.js new file mode 100644 index 0000000000..4f3fb1bccb --- /dev/null +++ b/injected/src/utils/web-interference-detection/vendors/hcaptcha.js @@ -0,0 +1,25 @@ +const DOM_SELECTORS = ['.h-captcha', '[data-hcaptcha-widget-id]']; +const SCRIPT_DOMAINS = ['hcaptcha.com', 'assets.hcaptcha.com']; +const WINDOW_OBJECTS = ['hcaptcha']; + +function detectDOM() { + return DOM_SELECTORS.some((selector) => document.querySelector(selector)); +} + +function detectScripts() { + return SCRIPT_DOMAINS.some((domain) => document.querySelectorAll(`script[src*="${domain}"]`).length > 0); +} + +function detectWindowObjects() { + return WINDOW_OBJECTS.some((object) => typeof window[object] !== 'undefined'); +} + +export function detectHCaptcha() { + const detected = detectDOM() || detectScripts() || detectWindowObjects(); + + return { + detected, + vendor: 'hcaptcha', + challengeType: 'widget' + }; +}