Skip to content
Merged
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: 9 additions & 6 deletions client/fcaptcha.js
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@
const accelerations = this.mouseAccelerations;

if (positions.length < 10) {
return this._getEmptyAnalysis();
return this._getEmptyAnalysis(positions.length);
}

// Velocity statistics
Expand Down Expand Up @@ -717,7 +717,7 @@
analyzeClick(clickX, clickY, targetRect) {
const positions = this.mousePositions;
if (positions.length < 5) {
return this._getEmptyClickAnalysis();
return this._getEmptyClickAnalysis(positions.length);
}

// Approach trajectory (last 20 points)
Expand Down Expand Up @@ -885,9 +885,12 @@
return arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length;
}

_getEmptyAnalysis() {
_getEmptyAnalysis(totalPoints = 0) {
return {
totalPoints: 0, trajectoryLength: 0, avgVelocity: 0, velocityVariance: 0,
// A short trace is insufficient for stable trajectory statistics, but
// its sample count is still an observed fact. Reporting zero here made
// 1-9 genuine mouse moves indistinguishable from no mouse use at all.
totalPoints, trajectoryLength: 0, avgVelocity: 0, velocityVariance: 0,
avgAcceleration: 0, accelerationChanges: 0, microTremorScore: 0.5,
straightLineRatio: 0, microMovements: 0, directionChanges: 0,
eventDeltas: [], eventDeltaVariance: 0, mouseEventRate: 0,
Expand All @@ -902,10 +905,10 @@
};
}

_getEmptyClickAnalysis() {
_getEmptyClickAnalysis(approachPoints = 0) {
return {
clickPrecision: 0, explorationRatio: 0, overshootCorrections: 0,
hoverTime: 0, approachDirectness: 1, approachPoints: 0
hoverTime: 0, approachDirectness: 1, approachPoints
};
}
}
Expand Down
54 changes: 54 additions & 0 deletions test/browser/tests/sparse-mouse.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test, expect, Page } from '@playwright/test';

test.setTimeout(60_000);

async function loadWidget(page: Page) {
await page.route('http://localhost:3000/__sparse_mouse__', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: `<!doctype html><html><body>
<div id="captcha"></div>
<script src="http://localhost:3000/fcaptcha.js"></script>
<script>
FCaptcha.configure({serverUrl: 'http://localhost:3000'});
FCaptcha.render('captcha', {siteKey: 'sparse-mouse-test'});
</script>
</body></html>`,
})
);

await page.goto('http://localhost:3000/__sparse_mouse__');
await page.waitForSelector('.fcaptcha-checkbox');
}

test('preserves observed counts for a sub-threshold mouse trace', async ({ page }) => {
await loadWidget(page);

const verifyRequest = page.waitForRequest((request) =>
request.method() === 'POST' && request.url().endsWith('/api/verify')
);

await page.evaluate(() => {
// These synthetic events test serialization only. Their verdict says
// nothing about human false positives: Playwright should be detected.
for (let i = 0; i < 9; i++) {
document.dispatchEvent(new MouseEvent('mousemove', {
bubbles: true,
clientX: 40 + i * 3,
clientY: 80 + i * 2,
}));
}

const checkbox = document.querySelector('.fcaptcha-checkbox');
checkbox?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
clientX: 64,
clientY: 96,
}));
});

const body = (await verifyRequest).postDataJSON();
expect(body.signals.behavioral.totalPoints).toBe(9);
expect(body.signals.behavioral.approachPoints).toBe(9);
});
Loading