Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 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
109 changes: 89 additions & 20 deletions injected/integration-test/breakage-reporting.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,87 @@ 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);
});
});

export class BreakageReportingSpec {
Expand All @@ -30,10 +95,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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-']"]
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock Cloudflare Challenge</title>
</head>
<body>
<h1>Mock Cloudflare Challenge Page</h1>

<!-- Mock Cloudflare challenge elements -->
<div id="challenge-running" class="cf-browser-verification">
<p>Checking your browser before accessing example.com</p>
<p>This process is automatic. Your browser will redirect shortly.</p>
</div>

<script>
// Mock some common bot detection properties
window.__CF$cv$params = { r: 'mock-ray-id' };
</script>
</body>
</html>

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock reCAPTCHA Challenge</title>
</head>
<body>
<h1>Mock reCAPTCHA Challenge Page</h1>

<!-- Mock reCAPTCHA elements -->
<div class="g-recaptcha" data-sitekey="mock-site-key"></div>
<iframe src="https://www.google.com/recaptcha/api2/anchor" style="width: 304px; height: 78px;"></iframe>

<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</body>
</html>

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>No Challenge Page</title>
</head>
<body>
<h1>Clean Page - No Challenges</h1>
<p>This page has no bot detection or fraud detection challenges.</p>
</body>
</html>

169 changes: 169 additions & 0 deletions injected/src/detectors/README.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading