Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions injected/src/features/broker-protection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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(),
});
}
});
}

/**
Expand Down
106 changes: 106 additions & 0 deletions injected/src/utils/web-interference-detection/detector-service.js
Original file line number Diff line number Diff line change
@@ -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()
*/
Original file line number Diff line number Diff line change
@@ -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();

Check failure on line 22 in injected/src/utils/web-interference-detection/interference-monitor.js

View workflow job for this annotation

GitHub Actions / snapshots

Expected Symbol to have a description

Check failure on line 22 in injected/src/utils/web-interference-detection/interference-monitor.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

Expected Symbol to have a description
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) {

Check failure on line 44 in injected/src/utils/web-interference-detection/interference-monitor.js

View workflow job for this annotation

GitHub Actions / snapshots

'id' is assigned a value but never used

Check failure on line 44 in injected/src/utils/web-interference-detection/interference-monitor.js

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

'id' is assigned a value but never used
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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(),
};
}
Original file line number Diff line number Diff line change
@@ -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',
};
}
Original file line number Diff line number Diff line change
@@ -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'
};
}
Loading