diff --git a/injected/integration-test/breakage-reporting.spec.js b/injected/integration-test/breakage-reporting.spec.js index 2935f0c575..a94d2dc2d9 100644 --- a/injected/integration-test/breakage-reporting.spec.js +++ b/injected/integration-test/breakage-reporting.spec.js @@ -3,22 +3,106 @@ import { ResultsCollector } from './page-objects/results-collector.js'; const HTML = '/breakage-reporting/index.html'; const CONFIG = './integration-test/test-pages/breakage-reporting/config/config.json'; -test('Breakage Reporting Feature', async ({ page }, testInfo) => { - const collector = ResultsCollector.create(page, testInfo.project.use); - await collector.load(HTML, CONFIG); - - const breakageFeature = new BreakageReportingSpec(page); - await breakageFeature.navigate(); - - await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); - await collector.waitForMessage('breakageReportResult'); - const calls = await collector.outgoingMessages(); - - expect(calls.length).toBe(1); - const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); - expect(result.params?.jsPerformance.length).toBe(1); - expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); - expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + +test.describe('Breakage Reporting Feature', () => { + test('collects basic metrics without detectors', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigate(); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + expect(calls.length).toBe(1); + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.jsPerformance.length).toBe(1); + expect(result.params?.jsPerformance[0]).toBeGreaterThan(0); + expect(result.params?.referrer).toBe('http://localhost:3220/breakage-reporting/index.html'); + }); + + test('detects no challenges on clean page', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/no-challenge.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(false); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(false); + }); + + test('detects Cloudflare challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-cloudflare.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + expect(result.params?.detectorData?.botDetection.results.length).toBeGreaterThan(0); + + const cloudflareResult = result.params?.detectorData?.botDetection.results[0]; + expect(cloudflareResult.vendor).toBe('Cloudflare'); + expect(cloudflareResult.challengeType).toBe('cloudflare'); + expect(cloudflareResult.detected).toBe(true); + }); + + test('detects reCAPTCHA challenge', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/captcha-recaptcha.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.botDetection.detected).toBe(true); + + const recaptchaResult = result.params?.detectorData?.botDetection.results.find( + r => r.challengeType === 'recaptcha' + ); + expect(recaptchaResult).toBeDefined(); + expect(recaptchaResult.vendor).toBe('reCAPTCHA'); + expect(recaptchaResult.detected).toBe(true); + }); + + test('detects Fraud challenge (PerimeterX)', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(HTML, CONFIG); + + const breakageFeature = new BreakageReportingSpec(page); + await breakageFeature.navigateToPage('/breakage-reporting/pages/fraud-px.html'); + + await collector.simulateSubscriptionMessage('breakageReporting', 'getBreakageReportValues', {}); + await collector.waitForMessage('breakageReportResult'); + const calls = await collector.outgoingMessages(); + + const result = /** @type {import("@duckduckgo/messaging").NotificationMessage} */ (calls[0].payload); + expect(result.params?.detectorData).toBeDefined(); + expect(result.params?.detectorData?.fraudDetection.detected).toBe(true); + + const fraudResult = result.params?.detectorData?.fraudDetection.results[0]; + expect(fraudResult.alertId).toBe('px'); + }); }); export class BreakageReportingSpec { @@ -30,10 +114,14 @@ export class BreakageReportingSpec { } async navigate() { - await this.page.evaluate(() => { - window.location.href = '/breakage-reporting/pages/ref.html'; - }); - await this.page.waitForURL('**/ref.html'); + await this.navigateToPage('/breakage-reporting/pages/ref.html'); + } + + async navigateToPage(url) { + await this.page.evaluate((targetUrl) => { + window.location.href = targetUrl; + }, url); + await this.page.waitForURL(`**${url}`); // Wait for first paint event to ensure we can get the performance metrics await this.page.evaluate(() => { diff --git a/injected/integration-test/test-pages/breakage-reporting/config/config.json b/injected/integration-test/test-pages/breakage-reporting/config/config.json index 11e2165348..39ce43c6f7 100644 --- a/injected/integration-test/test-pages/breakage-reporting/config/config.json +++ b/injected/integration-test/test-pages/breakage-reporting/config/config.json @@ -6,6 +6,33 @@ "breakageReporting": { "state": "enabled", "exceptions": [] + }, + "webInterferenceDetection": { + "state": "enabled", + "exceptions": [], + "settings": { + "interferenceTypes": { + "botDetection": { + "cloudflare": { + "state": "enabled", + "vendor": "Cloudflare", + "selectors": ["#challenge-running", ".cf-browser-verification"] + }, + "recaptcha": { + "state": "enabled", + "vendor": "reCAPTCHA", + "selectors": [".g-recaptcha", "iframe[src*='google.com/recaptcha']"] + } + }, + "fraudDetection": { + "px": { + "state": "enabled", + "vendor": "PerimeterX", + "selectors": ["#px-captcha", "[id^='px-']"] + } + } + } + } } } } diff --git a/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html new file mode 100644 index 0000000000..3423d36dad --- /dev/null +++ b/injected/integration-test/test-pages/breakage-reporting/pages/captcha-cloudflare.html @@ -0,0 +1,23 @@ + + +
+ + +Checking your browser before accessing example.com
+This process is automatic. Your browser will redirect shortly.
+This page has no bot detection or fraud detection challenges.
+ + + diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..7482f20bb6 --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,169 @@ +# Web Interference Detection + +This directory contains web interference detection functionality for content-scope-scripts. Detectors identify CAPTCHAs, fraud warnings, and other interference patterns to support breakage reporting and PIR automation. + +## Architecture + +The system provides simple detection utilities that can be called on-demand: + +- **Detection utilities** - Pure functions (`runBotDetection`, `runFraudDetection`) that scan DOM when called +- **Direct imports** - Features (breakage reporting, PIR) import detection functions directly +- **`WebInterferenceDetection`** - Optional ContentFeature wrapper for messaging (PIR use, not currently bundled) + + +## Directory Layout + +``` +detectors/ +├── detections/ +│ ├── bot-detection.js # CAPTCHA/bot detection utility +│ └── fraud-detection.js # fraud/phishing warning utility +├── utils/ +│ └── detection-utils.js # DOM helpers (selectors, text matching, visibility) +└── default-config.js # fallback detector settings +``` + +## How It Works + +### 1. On-Demand Detection + +Detectors are simple functions that scan the DOM when called: + +1. Feature imports detector function (e.g., `runBotDetection`) +2. Feature calls detector with config when needed (e.g., user submits breakage report) +3. Detector scans DOM and returns results immediately (~few ms) +4. No caching - each call is fresh + +### 2. Configuration + +Detectors are configured via `privacy-configuration/features/web-interference-detection.json`: + +```json +{ + "state": "enabled", + "settings": { + "autoRunDelayMs": 100, + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + }, + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } + } + } + } +} +``` + +**Domain-specific configuration** using `conditionalChanges`: + +```json +{ + "settings": { + "conditionalChanges": [ + { + "condition": { + "urlPattern": "https://*.example.com/*" + }, + "patchSettings": [ + { + "op": "add", + "path": "/interferenceTypes/customDetector", + "value": { "state": "enabled", "selectors": [".custom"] } + } + ] + } + ] + } +} +``` + +The framework automatically applies conditional changes based on the current URL before passing settings to the feature. + +### 3. Using Detection Results + +**Breakage reporting** (internal feature): + +```javascript +import { runBotDetection, runFraudDetection } from '../detectors/detections/bot-detection.js'; + +// Get detector config from privacy-configuration +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); + +if (detectorSettings) { + const result = { + botDetection: runBotDetection(detectorSettings.botDetection), + fraudDetection: runFraudDetection(detectorSettings.fraudDetection), + }; +} +``` + +**PIR/native** (via messaging, when `WebInterferenceDetection` is bundled): + +```javascript +// Via messaging +this.messaging.request('detectInterference', { + types: ['botDetection', 'fraudDetection'] +}); +``` + +## Adding New Detectors + +1. **Create detection utility** in `detections/`: + +```javascript +// detections/my-detector.js +export function runMyDetection(config = {}) { + // Run detection logic + const detected = checkSelectors(config.selectors); + + return { + detected, + type: 'myDetector', + results: [...], + }; +} +``` + +2. **Use in breakage reporting or other feature**: + +```javascript +// features/breakage-reporting.js +import { runMyDetection } from '../detectors/detections/my-detector.js'; + +const detectorSettings = this.getFeatureSetting('webInterferenceDetection', 'interferenceTypes'); +if (detectorSettings?.myDetector) { + result.myDetectorData = runMyDetection(detectorSettings.myDetector); +} +``` + +3. **Add config** to `web-interference-detection.json`: + +```json +{ + "settings": { + "interferenceTypes": { + "myDetector": { + "state": "enabled", + "selectors": [".my-selector"] + } + } + } +} +``` + +## Performance + +- Detectors are simple DOM queries - typically < 5ms +- No caching overhead or stale results +- Only run when explicitly needed (e.g., breakage report submitted) +- Future: If frequent polling is needed, add a shared caching wrapper diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..e569ce19e7 --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,53 @@ +export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..7c87d92853 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,45 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * Run bot detection. + * @param {Record