@webdecoy/fcaptcha 1.22.0 → 1.24.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 +52 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.22.0",
3
+ "version": "1.24.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
@@ -1350,6 +1350,19 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1350
1350
 
1351
1351
  // Verify PoW if provided
1352
1352
  let powValid = false;
1353
+ // powSatisfied records whether the caller actually completed the challenge
1354
+ // this server issued. It gates token issuance below rather than only feeding
1355
+ // the score, because a proof of work is a precondition, not evidence: the
1356
+ // widget solves one on every path and aborts rather than submit without it,
1357
+ // so a request that arrives without a valid solution did not come from the
1358
+ // widget at all.
1359
+ //
1360
+ // Scoring it alone was not enough. The final score is a weighted sum, so the
1361
+ // bot category contributes at most its 0.13 weight — every PoW failure firing
1362
+ // at once reached 0.1298 against a 0.5 threshold, and a bare `curl` with no
1363
+ // solution and no signals was issued a valid token. The detections were all
1364
+ // correct; the aggregation discarded them.
1365
+ let powSatisfied = false;
1353
1366
  let powVerification = null;
1354
1367
  if (powSolution && powSolution.challengeId) {
1355
1368
  powVerification = powChallengeStore.verify(
@@ -1366,19 +1379,30 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1366
1379
  category: 'bot',
1367
1380
  score: 0.7,
1368
1381
  confidence: 0.8,
1369
- reason: `PoW verification failed: ${powVerification.reason}`
1382
+ reason: `PoW verification failed: ${powVerification.reason}`,
1383
+ // A solution that does not verify against a challenge this server
1384
+ // issued is not weak evidence of automation, it is proof the challenge
1385
+ // was not completed. See DISPOSITIVE_FLOOR.
1386
+ dispositive: true
1370
1387
  });
1388
+ } else {
1389
+ powSatisfied = true;
1371
1390
  }
1372
1391
 
1373
1392
  // Verify challenge nonce binding
1374
1393
  if (powValid && powVerification.nonce) {
1375
1394
  const clientNonce = signals.meta?.challengeNonce;
1376
1395
  if (!clientNonce || clientNonce !== powVerification.nonce) {
1396
+ // The solution verifies but the signals it commits to are not the ones
1397
+ // presented, so the work was done for a different payload. Revokes the
1398
+ // pass granted above.
1399
+ powSatisfied = false;
1377
1400
  detections.push({
1378
1401
  category: 'bot',
1379
1402
  score: 0.9,
1380
1403
  confidence: 0.9,
1381
- reason: 'Challenge nonce mismatch (signals not bound to challenge)'
1404
+ reason: 'Challenge nonce mismatch (signals not bound to challenge)',
1405
+ dispositive: true
1382
1406
  });
1383
1407
  }
1384
1408
  }
@@ -1410,7 +1434,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1410
1434
  category: 'bot',
1411
1435
  score: 0.9,
1412
1436
  confidence: 0.95,
1413
- reason: 'No PoW solution provided'
1437
+ reason: 'No PoW solution provided',
1438
+ dispositive: true
1414
1439
  });
1415
1440
  }
1416
1441
 
@@ -1500,7 +1525,24 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1500
1525
  // detection layer has nothing to say about it and the refusal is reported as
1501
1526
  // its own reason rather than smuggled in as a bot verdict.
1502
1527
  const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
1503
- const success = finalScore < 0.5 && hostnameAllowed;
1528
+
1529
+ // Three independent conditions, deliberately not folded into the score.
1530
+ //
1531
+ // powSatisfied is the one that matters most: a score threshold answers "how
1532
+ // suspicious is this visitor", which is the wrong question to ask of someone
1533
+ // who never completed the challenge. Gating here means no future reweighting
1534
+ // can reopen the bypass, and it holds even if the dispositive floor is
1535
+ // lowered or removed.
1536
+ const success = finalScore < 0.5 && hostnameAllowed && powSatisfied;
1537
+
1538
+ // Name the failed precondition whenever one fails, not only when the score
1539
+ // would otherwise have allowed. Gating it on the score made the PoW case
1540
+ // unreachable — a PoW failure is dispositive, so it floors the score at 0.9
1541
+ // and the branch never fired — which is exactly the case a caller most needs
1542
+ // explained.
1543
+ let withheldReason = '';
1544
+ if (!powSatisfied) withheldReason = 'pow_not_satisfied';
1545
+ else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
1504
1546
 
1505
1547
  const token = success
1506
1548
  ? generateToken(ip, siteKey, finalScore, {
@@ -1522,7 +1564,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1522
1564
  recommendation,
1523
1565
  categoryScores,
1524
1566
  detections,
1525
- ...(hostnameAllowed ? {} : { reason: 'hostname_not_allowed', hostname })
1567
+ ...(withheldReason ? { reason: withheldReason } : {}),
1568
+ ...(hostnameAllowed ? {} : { hostname })
1526
1569
  };
1527
1570
  }
1528
1571
 
@@ -1597,7 +1640,10 @@ app.post('/api/score', async (req, res) => {
1597
1640
  // the token, so a caller comparing the two sees the same value.
1598
1641
  action: sanitizeAction(action),
1599
1642
  cdata: sanitizeCdata(cdata),
1600
- recommendation: result.recommendation
1643
+ recommendation: result.recommendation,
1644
+ // Parity with /api/verify and with the Go server: a caller denied a token
1645
+ // needs to know which precondition failed.
1646
+ ...(result.reason ? { reason: result.reason } : {})
1601
1647
  });
1602
1648
  });
1603
1649