@webdecoy/fcaptcha 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +73 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
package/server.js CHANGED
@@ -18,6 +18,43 @@ const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-producti
18
18
  const PORT = process.env.PORT || 3000;
19
19
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
20
20
 
21
+ // Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
22
+ // logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
23
+ // When on, each /api/verify and /api/score logs one privacy-safe JSON line
24
+ // (score, recommendation, category scores, and per-hit category/score/confidence)
25
+ // for observability and tuning. It deliberately omits IP, user agent, raw
26
+ // signals, and the free-text detection `reason`, which can interpolate
27
+ // visitor-derived data (reverse-DNS hostname, UA/header fragments, field ids).
28
+ //
29
+ // FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW additionally includes that free-text reason.
30
+ // Separate and off by default because reasons can carry visitor-derived data —
31
+ // only enable in trusted debugging contexts with no privacy obligations.
32
+ const envFlag = (name) => ['1', 'true', 'yes', 'on']
33
+ .includes(String(process.env[name] || '').trim().toLowerCase());
34
+ const VERDICT_LOGGING_ENABLED = envFlag('FCAPTCHA_LOG_VERDICTS');
35
+ const VERDICT_LOG_INCLUDE_RAW = envFlag('FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW');
36
+ if (VERDICT_LOGGING_ENABLED && VERDICT_LOG_INCLUDE_RAW) {
37
+ console.warn('WARNING: FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW enabled — verdict logs include free-text detection reasons that may contain visitor-derived data (hostnames, UA/header fragments, field ids). Do not enable where you have privacy obligations.');
38
+ }
39
+
40
+ function logVerdict(endpoint, siteKey, result) {
41
+ if (!VERDICT_LOGGING_ENABLED || !result) return;
42
+ console.log(JSON.stringify({
43
+ event: 'verdict',
44
+ endpoint,
45
+ siteKey,
46
+ success: result.success,
47
+ score: result.score,
48
+ recommendation: result.recommendation,
49
+ categoryScores: result.categoryScores,
50
+ detections: (result.detections || []).map((d) => {
51
+ const det = { category: d.category, score: d.score, confidence: d.confidence };
52
+ if (VERDICT_LOG_INCLUDE_RAW) det.reason = d.reason;
53
+ return det;
54
+ })
55
+ }));
56
+ }
57
+
21
58
  // Serve the browser widget alongside the API so deployments expose a single
22
59
  // origin to integrators (the implicit contract behind <serverUrl>/fcaptcha.js).
23
60
  // Set FCAPTCHA_SERVE_CLIENT=false to opt out — useful when hosting the widget
@@ -458,6 +495,39 @@ function detectHeadless(signals, userAgent) {
458
495
  return detections;
459
496
  }
460
497
 
498
+ // Flags anti-detection patch traces collected by the client. FALSE-POSITIVE-
499
+ // SAFE: a genuine browser never produces these (internal contradictions /
500
+ // native-function tampering, not environment-shape heuristics that would
501
+ // misfire on real Linux/VPN users). Targets stealth agents (e.g. Manus AI)
502
+ // driving real-but-patched Chromium. Only the two FP-safe signals are scored;
503
+ // the client also collects privacy-extension-ambiguous artifacts (patched_*)
504
+ // for observability that are intentionally NOT scored. Keep in sync with Go/Py.
505
+ function detectStealthArtifacts(signals) {
506
+ const detections = [];
507
+ const env = signals.environmental || {};
508
+
509
+ // Function.prototype.toString proxied — the signature move of stealth
510
+ // frameworks (used to make their other native overrides look untouched).
511
+ const artifactSignals = (env.stealthArtifacts && env.stealthArtifacts.signals) || [];
512
+ if (artifactSignals.includes('tostring_proxied')) {
513
+ detections.push({
514
+ category: 'headless', score: 0.9, confidence: 0.85,
515
+ reason: 'Function.prototype.toString is proxied (stealth automation patch)'
516
+ });
517
+ }
518
+
519
+ // Notification.permission === 'denied' while the Permissions API reports
520
+ // 'prompt': a state a real browser cannot reach (classic headless tell).
521
+ if (env.permissionProbe && env.permissionProbe.contradiction === true) {
522
+ detections.push({
523
+ category: 'headless', score: 0.85, confidence: 0.85,
524
+ reason: 'Notification permission contradicts Permissions API (headless/stealth tell)'
525
+ });
526
+ }
527
+
528
+ return detections;
529
+ }
530
+
461
531
  function detectAutomation(signals) {
462
532
  const detections = [];
463
533
  const env = signals.environmental || {};
@@ -1061,6 +1131,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1061
1131
  detections.push(
1062
1132
  ...detectVisionAI(signals),
1063
1133
  ...detectHeadless(signals, userAgent),
1134
+ ...detectStealthArtifacts(signals),
1064
1135
  ...detectAutomation(signals),
1065
1136
  ...detectCDP(signals),
1066
1137
  ...detectBehavioral(signals),
@@ -1168,6 +1239,7 @@ app.post('/api/verify', (req, res) => {
1168
1239
  }
1169
1240
 
1170
1241
  const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1242
+ logVerdict('verify', siteKey, result);
1171
1243
  res.json(result);
1172
1244
  });
1173
1245
 
@@ -1191,6 +1263,7 @@ app.post('/api/score', (req, res) => {
1191
1263
  }
1192
1264
 
1193
1265
  const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1266
+ logVerdict('score', siteKey, result);
1194
1267
  res.json({
1195
1268
  success: result.success,
1196
1269
  score: result.score,