@anonympins/fingerprint 0.5.1 → 0.5.4
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.
- package/CHANGELOG.md +27 -0
- package/README.md +118 -115
- package/package.json +1 -1
- package/src/js/dynamic-wasm.js +52 -13
- package/src/js/fingerprint.builder.js +169 -168
- package/src/js/fingerprint.client.js +1271 -1189
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +436 -162
- package/src/js/fingerprint.utils.js +30 -0
- package/src/js/gpu_pow.solver.js +96 -1
- package/src/js/library.js +40 -21
- package/src/js/mongodb-store.js +165 -79
- package/src/js/pow.solver.inline.js +627 -30
- package/src/js/pow.solver.js +453 -31
- package/src/js/problem-manager.js +1 -1
- package/src/js/redis-store.js +114 -42
- package/src/js/tests/dns-circuit-breaker.test.js +55 -0
- package/src/js/tests/fingerprint.client.init.test.js +143 -143
- package/src/js/tests/fingerprint.client.test.js +128 -128
- package/src/js/tests/fingerprint.engine.test.js +370 -370
- package/src/js/tests/fingerprint.test.js +119 -5
- package/src/js/tests/gpu_pow.test.js +81 -0
- package/src/js/tests/ip-reputation.test.js +1 -1
- package/src/js/tests/pow.solver.test.js +9 -6
- package/src/js/tests/usefulWorkSolver.test.js +162 -0
- package/src/php/Challenge/ChallengeUtils.php +137 -35
- package/src/php/FingerprintClient.php +22 -5
- package/src/php/FingerprintEngine.php +335 -58
- package/src/php/Optimization/Optimization.php +23 -16
- package/src/php/Optimization/OptimizationOperators.php +27 -0
- package/src/php/ProblemManager.php +1 -1
- package/src/php/Store/MongoDbStore.php +184 -104
- package/src/php/Store/RedisStore.php +91 -11
- package/src/php/Tests/ChallengeUtilsTest.php +285 -285
- package/src/php/Tests/DnsCircuitBreakerTest.php +106 -0
- package/src/php/Tests/IpReputationTest.php +1 -1
- package/src/php/Tests/PowTest.php +1 -1
- package/src/php/Tests/RequestUtilsTest.php +29 -0
- package/src/php/Tests/TLSClientHelloParserTest.php +177 -117
- package/src/php/Utils/BigInt.php +202 -202
- package/src/php/Utils/RequestUtils.php +74 -21
- package/src/php/Utils/TLSClientHelloParser.php +212 -116
|
@@ -27,6 +27,141 @@
|
|
|
27
27
|
private ?Logger $logger = null;
|
|
28
28
|
private bool $dryRun;
|
|
29
29
|
|
|
30
|
+
private static ?array $googlebotEntries = null;
|
|
31
|
+
private static ?array $yandexEntries = null;
|
|
32
|
+
private static ?array $bingbotEntries = null;
|
|
33
|
+
|
|
34
|
+
private static array $dnsCircuitBreaker = [
|
|
35
|
+
'state' => 'CLOSED',
|
|
36
|
+
'failureCount' => 0,
|
|
37
|
+
'lastStateChange' => 0,
|
|
38
|
+
'threshold' => 5,
|
|
39
|
+
'cooldown' => 30, // seconds
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
private static function recordDnsSuccess(): void
|
|
43
|
+
{
|
|
44
|
+
self::$dnsCircuitBreaker['failureCount'] = 0;
|
|
45
|
+
self::$dnsCircuitBreaker['state'] = 'CLOSED';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private static function recordDnsFailure(): void
|
|
49
|
+
{
|
|
50
|
+
self::$dnsCircuitBreaker['failureCount']++;
|
|
51
|
+
if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
|
|
52
|
+
self::$dnsCircuitBreaker['state'] = 'OPEN';
|
|
53
|
+
self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private static function canAttemptDns(): bool
|
|
58
|
+
{
|
|
59
|
+
if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
|
|
63
|
+
if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
|
|
64
|
+
self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true; // HALF-OPEN
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static function getReverseDnsName(string $ip): ?string
|
|
73
|
+
{
|
|
74
|
+
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
75
|
+
return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
|
|
76
|
+
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
77
|
+
$hex = bin2hex((string)inet_pton($ip));
|
|
78
|
+
return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static function dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
|
|
84
|
+
{
|
|
85
|
+
$dnsServer = '8.8.8.8';
|
|
86
|
+
$port = 53;
|
|
87
|
+
$fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
|
|
88
|
+
if (!$fp) return null;
|
|
89
|
+
stream_set_timeout($fp, 0, (int)($timeout * 1000000));
|
|
90
|
+
|
|
91
|
+
$id = rand(10000, 65000);
|
|
92
|
+
$header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
|
|
93
|
+
|
|
94
|
+
$qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
|
|
95
|
+
$parts = explode('.', $query);
|
|
96
|
+
$qName = '';
|
|
97
|
+
foreach ($parts as $part) {
|
|
98
|
+
$qName .= chr(strlen($part)) . $part;
|
|
99
|
+
}
|
|
100
|
+
$qName .= chr(0);
|
|
101
|
+
$question = $qName . pack('n2', $qType, 1);
|
|
102
|
+
|
|
103
|
+
if (!@fwrite($fp, $header . $question)) {
|
|
104
|
+
fclose($fp);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
$response = @fread($fp, 512);
|
|
109
|
+
fclose($fp);
|
|
110
|
+
if (!$response || strlen($response) < 12) return null;
|
|
111
|
+
|
|
112
|
+
$resHeader = unpack('n6', substr($response, 0, 12));
|
|
113
|
+
if ($resHeader[1] !== $id) return null;
|
|
114
|
+
|
|
115
|
+
$answersCount = $resHeader[4];
|
|
116
|
+
if ($answersCount <= 0) return [];
|
|
117
|
+
|
|
118
|
+
$offset = 12 + strlen($question);
|
|
119
|
+
$results = [];
|
|
120
|
+
for ($i = 0; $i < $answersCount; $i++) {
|
|
121
|
+
if ($offset + 12 > strlen($response)) break;
|
|
122
|
+
if ((ord($response[$offset]) & 0xc0) === 0xc0) {
|
|
123
|
+
$offset += 2;
|
|
124
|
+
} else {
|
|
125
|
+
while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
|
|
126
|
+
$offset += 1 + ord($response[$offset]);
|
|
127
|
+
}
|
|
128
|
+
$offset++;
|
|
129
|
+
}
|
|
130
|
+
if ($offset + 10 > strlen($response)) break;
|
|
131
|
+
$typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
|
|
132
|
+
$rType = $typeAndClass['type_class1'];
|
|
133
|
+
$rLen = $typeAndClass['len'];
|
|
134
|
+
$offset += 10;
|
|
135
|
+
if ($offset + $rLen > strlen($response)) break;
|
|
136
|
+
$rData = substr($response, $offset, $rLen);
|
|
137
|
+
$offset += $rLen;
|
|
138
|
+
|
|
139
|
+
if ($rType === 12) {
|
|
140
|
+
$ptrName = '';
|
|
141
|
+
$p = 0;
|
|
142
|
+
while ($p < strlen($rData)) {
|
|
143
|
+
$l = ord($rData[$p]);
|
|
144
|
+
if ($l === 0) break;
|
|
145
|
+
if (($l & 0xc0) === 0xc0) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
$ptrName .= substr($rData, $p + 1, $l) . '.';
|
|
149
|
+
$p += 1 + $l;
|
|
150
|
+
}
|
|
151
|
+
$results[] = rtrim($ptrName, '.');
|
|
152
|
+
} elseif ($rType === 1) {
|
|
153
|
+
if ($rLen === 4) {
|
|
154
|
+
$results[] = long2ip(unpack('N', $rData)[1]);
|
|
155
|
+
}
|
|
156
|
+
} elseif ($rType === 28) {
|
|
157
|
+
if ($rLen === 16) {
|
|
158
|
+
$results[] = inet_ntop($rData);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return $results;
|
|
163
|
+
}
|
|
164
|
+
|
|
30
165
|
public function __construct(array $securityConfig)
|
|
31
166
|
{
|
|
32
167
|
$this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
|
|
@@ -103,7 +238,103 @@
|
|
|
103
238
|
}
|
|
104
239
|
}
|
|
105
240
|
}
|
|
241
|
+
private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
|
|
242
|
+
{
|
|
243
|
+
$configDir = dirname(__DIR__, 2) . '/config';
|
|
244
|
+
$filePath = $configDir . '/' . $filename;
|
|
245
|
+
if (file_exists($filePath)) {
|
|
246
|
+
try {
|
|
247
|
+
$content = file_get_contents($filePath);
|
|
248
|
+
if ($content !== false) {
|
|
249
|
+
$decoded = json_decode($content, true);
|
|
250
|
+
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
251
|
+
return $decoded;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (\Throwable $e) {
|
|
255
|
+
error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return $fallbackEntries;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public static function googlebot_whitelist(): array
|
|
262
|
+
{
|
|
263
|
+
if (self::$googlebotEntries === null) {
|
|
264
|
+
self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
|
|
265
|
+
"2001:4860:4801:10::/64",
|
|
266
|
+
"2001:4860:4801:11::/64",
|
|
267
|
+
"2001:4860:4801:12::/64",
|
|
268
|
+
"66.249.79.64"
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
'type' => 'allowlist',
|
|
273
|
+
'entries' => self::$googlebotEntries
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public static function yandex_whitelist(): array
|
|
278
|
+
{
|
|
279
|
+
if (self::$yandexEntries === null) {
|
|
280
|
+
self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
|
|
281
|
+
"2a02:6b8::/29",
|
|
282
|
+
"5.45.192.0/18",
|
|
283
|
+
"213.180.192.0/19"
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
286
|
+
return [
|
|
287
|
+
'type' => 'allowlist',
|
|
288
|
+
'entries' => self::$yandexEntries
|
|
289
|
+
];
|
|
290
|
+
}
|
|
106
291
|
|
|
292
|
+
public static function bingbot_whitelist(): array
|
|
293
|
+
{
|
|
294
|
+
if (self::$bingbotEntries === null) {
|
|
295
|
+
self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
|
|
296
|
+
"157.55.39.0/24",
|
|
297
|
+
"207.46.13.0/24",
|
|
298
|
+
"40.77.178.0/23"
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
return [
|
|
302
|
+
'type' => 'allowlist',
|
|
303
|
+
'entries' => self::$bingbotEntries
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
public static function default_whitelist(): array
|
|
308
|
+
{
|
|
309
|
+
return [
|
|
310
|
+
self::googlebot_whitelist(),
|
|
311
|
+
self::bingbot_whitelist(),
|
|
312
|
+
self::yandex_whitelist(),
|
|
313
|
+
['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
|
|
314
|
+
['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
|
|
315
|
+
['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
|
|
316
|
+
['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
|
|
317
|
+
['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
|
|
318
|
+
['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
|
|
319
|
+
['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
|
|
320
|
+
['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
|
|
321
|
+
['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
|
|
322
|
+
['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
|
|
323
|
+
['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
|
|
324
|
+
['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
|
|
325
|
+
['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
|
|
326
|
+
['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
|
|
327
|
+
['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
|
|
328
|
+
['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
|
|
329
|
+
['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
|
|
330
|
+
['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
|
|
331
|
+
['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
|
|
332
|
+
['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
|
|
333
|
+
['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
|
|
334
|
+
['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
|
|
335
|
+
['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
|
|
336
|
+
];
|
|
337
|
+
}
|
|
107
338
|
private function log(string $message, array $data = [], string $level = 'info'): void
|
|
108
339
|
{
|
|
109
340
|
if ($this->verbose) {
|
|
@@ -287,13 +518,19 @@
|
|
|
287
518
|
|
|
288
519
|
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
289
520
|
|
|
521
|
+
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
522
|
+
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
523
|
+
($context->getHeader('x-forwarded-proto') === 'https');
|
|
524
|
+
$secureOption = $isHttps || $this->isProduction;
|
|
525
|
+
|
|
290
526
|
// Préparer le cookie à envoyer
|
|
291
527
|
$newCookie = [
|
|
292
528
|
'name' => 'device_id',
|
|
293
529
|
'value' => $deviceId,
|
|
294
530
|
'options' => [
|
|
295
531
|
'httponly' => true,
|
|
296
|
-
'secure' => $
|
|
532
|
+
'secure' => $secureOption,
|
|
533
|
+
'partitioned' => $secureOption,
|
|
297
534
|
'samesite' => 'Strict',
|
|
298
535
|
'path' => '/',
|
|
299
536
|
]
|
|
@@ -752,7 +989,7 @@
|
|
|
752
989
|
]);
|
|
753
990
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
754
991
|
// @phpstan-ignore-next-line - L'instance est gérée par le singleton
|
|
755
|
-
|
|
992
|
+
$defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
|
|
756
993
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
757
994
|
$problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
|
|
758
995
|
// FIX: La solution est directement le $workResult, pas une sous-propriété.
|
|
@@ -771,15 +1008,31 @@
|
|
|
771
1008
|
if ($isValid) {
|
|
772
1009
|
$store->delete("secret:{$powNonce}");
|
|
773
1010
|
$ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
|
|
774
|
-
|
|
1011
|
+
MetricsManager::incrementCounter('challenges_solved_total');
|
|
775
1012
|
$this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
|
|
776
|
-
|
|
1013
|
+
|
|
1014
|
+
$isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
|
|
1015
|
+
(isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
|
|
1016
|
+
($context->getHeader('x-forwarded-proto') === 'https');
|
|
1017
|
+
$secureOption = $isHttps || $this->isProduction;
|
|
1018
|
+
|
|
777
1019
|
return [
|
|
778
1020
|
'action' => 'redirect',
|
|
779
1021
|
'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
|
|
780
1022
|
'score' => 0.0,
|
|
781
1023
|
'vector' => ['challenge_solved' => 100],
|
|
782
|
-
'cookie' => [
|
|
1024
|
+
'cookie' => [
|
|
1025
|
+
'name' => 'pow_clearance',
|
|
1026
|
+
'value' => $ticket,
|
|
1027
|
+
'options' => [
|
|
1028
|
+
'httponly' => true,
|
|
1029
|
+
'secure' => $secureOption,
|
|
1030
|
+
'samesite' => 'Strict',
|
|
1031
|
+
'expires' => time() + ($ticketTtl / 1000),
|
|
1032
|
+
'path' => '/',
|
|
1033
|
+
'partitioned' => $secureOption,
|
|
1034
|
+
]
|
|
1035
|
+
]
|
|
783
1036
|
];
|
|
784
1037
|
}
|
|
785
1038
|
}
|
|
@@ -788,7 +1041,7 @@
|
|
|
788
1041
|
$this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
|
|
789
1042
|
$suspicionVector['honeypotScore'] = 100.0;
|
|
790
1043
|
}
|
|
791
|
-
|
|
1044
|
+
|
|
792
1045
|
// 3. Vérifier un ticket existant
|
|
793
1046
|
$hasValidTicket = false;
|
|
794
1047
|
$powCookie = $context->cookies['pow_clearance'] ?? null;
|
|
@@ -803,7 +1056,7 @@
|
|
|
803
1056
|
// $this->log('Valid clearance ticket found');
|
|
804
1057
|
// return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
|
|
805
1058
|
}
|
|
806
|
-
|
|
1059
|
+
|
|
807
1060
|
// 4. Calculer le vecteur et le score de suspicion
|
|
808
1061
|
// Résoudre l'identité et vérifier le statut "condamné"
|
|
809
1062
|
$store = StoreManager::getStore();
|
|
@@ -834,7 +1087,7 @@
|
|
|
834
1087
|
RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
|
|
835
1088
|
MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
|
|
836
1089
|
}
|
|
837
|
-
|
|
1090
|
+
|
|
838
1091
|
// Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
|
|
839
1092
|
$isNewDevice = $identity['newCookie'] !== null;
|
|
840
1093
|
if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
|
|
@@ -844,7 +1097,7 @@
|
|
|
844
1097
|
]);
|
|
845
1098
|
$finalScore = (float)$thresholds['low'];
|
|
846
1099
|
}
|
|
847
|
-
|
|
1100
|
+
|
|
848
1101
|
// Vérifier les URL pièges (après calcul du score)
|
|
849
1102
|
$lastNonce = $deviceData['lastChallengeNonce'] ?? null;
|
|
850
1103
|
if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
|
|
@@ -863,7 +1116,7 @@
|
|
|
863
1116
|
}
|
|
864
1117
|
return $decision;
|
|
865
1118
|
}
|
|
866
|
-
|
|
1119
|
+
|
|
867
1120
|
// 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
|
|
868
1121
|
$blockThreshold = $thresholds['block'] ?? 95;
|
|
869
1122
|
if ($finalScore >= $blockThreshold) {
|
|
@@ -901,13 +1154,13 @@
|
|
|
901
1154
|
|
|
902
1155
|
$highThreshold = $thresholds['high'] ?? 75;
|
|
903
1156
|
$mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
|
|
904
|
-
|
|
1157
|
+
|
|
905
1158
|
$lowThreshold = $thresholds['low'] ?? 20;
|
|
906
1159
|
if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
|
|
907
1160
|
if ($mustReChallenge) {
|
|
908
1161
|
$this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
|
|
909
1162
|
}
|
|
910
|
-
|
|
1163
|
+
|
|
911
1164
|
// --- AJOUT: Limiteur de débit (Token Bucket) ---
|
|
912
1165
|
$rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
|
|
913
1166
|
if (!$rateLimitPassed) {
|
|
@@ -927,9 +1180,9 @@
|
|
|
927
1180
|
}
|
|
928
1181
|
return $decision;
|
|
929
1182
|
}
|
|
930
|
-
|
|
1183
|
+
|
|
931
1184
|
$decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
|
|
932
|
-
|
|
1185
|
+
|
|
933
1186
|
if ($this->dryRun) {
|
|
934
1187
|
MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
|
|
935
1188
|
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
@@ -938,10 +1191,10 @@
|
|
|
938
1191
|
unset($decision['status']);
|
|
939
1192
|
return $decision;
|
|
940
1193
|
}
|
|
941
|
-
|
|
1194
|
+
|
|
942
1195
|
MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
|
|
943
1196
|
$this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
|
|
944
|
-
|
|
1197
|
+
|
|
945
1198
|
$nonce = bin2hex(random_bytes(16));
|
|
946
1199
|
$clientSecret = bin2hex(random_bytes(16));
|
|
947
1200
|
$highThreshold = $thresholds['high'] ?? 75;
|
|
@@ -962,7 +1215,7 @@
|
|
|
962
1215
|
|
|
963
1216
|
if ($shouldUseUsefulWork) {
|
|
964
1217
|
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
965
|
-
$defaultPath = dirname(__DIR__, 2) . '/problems.config.json';
|
|
1218
|
+
$defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
|
|
966
1219
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
967
1220
|
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
968
1221
|
$work = $problemManager->dispatchWork($finalScore);
|
|
@@ -1039,13 +1292,14 @@
|
|
|
1039
1292
|
'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
|
|
1040
1293
|
'path' => $context->path,
|
|
1041
1294
|
];
|
|
1042
|
-
|
|
1043
|
-
|
|
1295
|
+
|
|
1296
|
+
// Alignement linéaire parfait du ratio d'effort CPU/Mémoire
|
|
1297
|
+
$memActivationFactor = $suspicionFactor;
|
|
1044
1298
|
$memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
|
|
1045
|
-
|
|
1299
|
+
|
|
1046
1300
|
$originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
|
|
1047
1301
|
$baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
|
|
1048
|
-
|
|
1302
|
+
|
|
1049
1303
|
$challengeContext = [
|
|
1050
1304
|
'clientSecret' => $clientSecret,
|
|
1051
1305
|
'cpuTarget' => $cpuChallengeDetails['target'],
|
|
@@ -1055,22 +1309,22 @@
|
|
|
1055
1309
|
'baseBlock' => $baseBlock,
|
|
1056
1310
|
'originalPath' => $context->path,
|
|
1057
1311
|
];
|
|
1058
|
-
|
|
1312
|
+
|
|
1059
1313
|
$store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
|
|
1060
|
-
|
|
1314
|
+
|
|
1061
1315
|
// Associer le nonce au device pour la vérification des URL pièges
|
|
1062
1316
|
if ($deviceData) {
|
|
1063
1317
|
$deviceData['lastChallengeNonce'] = $nonce;
|
|
1064
1318
|
$store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
|
|
1065
1319
|
}
|
|
1066
|
-
|
|
1320
|
+
|
|
1067
1321
|
$trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
|
|
1068
1322
|
$this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
|
|
1069
|
-
|
|
1323
|
+
|
|
1070
1324
|
if ($this->logger) {
|
|
1071
1325
|
$this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1072
1326
|
}
|
|
1073
|
-
|
|
1327
|
+
|
|
1074
1328
|
// Pour les API, retourner un challenge JSON
|
|
1075
1329
|
if ($isApiRequest) {
|
|
1076
1330
|
$challengePayload = [
|
|
@@ -1087,7 +1341,7 @@
|
|
|
1087
1341
|
} else {
|
|
1088
1342
|
// Pour les navigateurs, retourner une page HTML
|
|
1089
1343
|
$pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
|
|
1090
|
-
$cpuChallengeDetails, $memDifficulty, $clientSecret,
|
|
1344
|
+
$cpuChallengeDetails, $memDifficulty, $clientSecret,
|
|
1091
1345
|
$this->securityConfig, $trapUrls, $originalFingerprint
|
|
1092
1346
|
);
|
|
1093
1347
|
$decision['body'] = $pageBody;
|
|
@@ -1109,12 +1363,12 @@
|
|
|
1109
1363
|
$response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
|
|
1110
1364
|
}
|
|
1111
1365
|
}
|
|
1112
|
-
|
|
1366
|
+
|
|
1113
1367
|
// Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
|
|
1114
1368
|
if (isset($context->newCookieForResponse)) {
|
|
1115
1369
|
$response['newCookieForResponse'] = $context->newCookieForResponse;
|
|
1116
1370
|
}
|
|
1117
|
-
|
|
1371
|
+
|
|
1118
1372
|
return $response;
|
|
1119
1373
|
}
|
|
1120
1374
|
|
|
@@ -1126,7 +1380,7 @@
|
|
|
1126
1380
|
if (empty($operationType) || empty($operationName)) {
|
|
1127
1381
|
return false;
|
|
1128
1382
|
}
|
|
1129
|
-
|
|
1383
|
+
|
|
1130
1384
|
$whitelistRules = $this->securityConfig['whitelist'] ?? [];
|
|
1131
1385
|
$graphqlRule = null;
|
|
1132
1386
|
foreach ($whitelistRules as $rule) {
|
|
@@ -1135,23 +1389,23 @@
|
|
|
1135
1389
|
break;
|
|
1136
1390
|
}
|
|
1137
1391
|
}
|
|
1138
|
-
|
|
1392
|
+
|
|
1139
1393
|
if (empty($graphqlRule['entries'])) {
|
|
1140
1394
|
return false;
|
|
1141
1395
|
}
|
|
1142
|
-
|
|
1396
|
+
|
|
1143
1397
|
foreach ($graphqlRule['entries'] as $entry) {
|
|
1144
1398
|
[$entryType, $entryName] = explode(':', $entry, 2);
|
|
1145
1399
|
if ($entryType !== $operationType) continue;
|
|
1146
|
-
|
|
1400
|
+
|
|
1147
1401
|
if ($entryName === $operationName || $entryName === '*') return true;
|
|
1148
|
-
|
|
1402
|
+
|
|
1149
1403
|
if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
|
|
1150
1404
|
}
|
|
1151
|
-
|
|
1405
|
+
|
|
1152
1406
|
return false;
|
|
1153
1407
|
}
|
|
1154
|
-
|
|
1408
|
+
|
|
1155
1409
|
/**
|
|
1156
1410
|
* Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
|
|
1157
1411
|
* en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
|
|
@@ -1183,44 +1437,67 @@
|
|
|
1183
1437
|
if ($cachedStatus === 'verified') return true;
|
|
1184
1438
|
if ($cachedStatus === 'failed') return false;
|
|
1185
1439
|
|
|
1440
|
+
if (!self::canAttemptDns()) {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1186
1444
|
try {
|
|
1187
|
-
// 1. Reverse DNS lookup
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1445
|
+
// 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
|
|
1446
|
+
$revName = self::getReverseDnsName($context->client_ip);
|
|
1447
|
+
if (!$revName) {
|
|
1448
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
$hostnames = self::dnsResolveUdp($revName, 'PTR', 0.5);
|
|
1452
|
+
if ($hostnames === null || empty($hostnames)) {
|
|
1453
|
+
self::recordDnsFailure();
|
|
1454
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1192
1455
|
return false;
|
|
1193
1456
|
}
|
|
1194
1457
|
|
|
1195
1458
|
$validHostname = null;
|
|
1196
|
-
|
|
1197
|
-
$
|
|
1459
|
+
foreach ($hostnames as $hostname) {
|
|
1460
|
+
if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
|
|
1461
|
+
$validHostname = $hostname;
|
|
1462
|
+
break;
|
|
1463
|
+
}
|
|
1198
1464
|
}
|
|
1199
1465
|
|
|
1200
1466
|
if ($validHostname === null) {
|
|
1201
|
-
$store->set($cacheKey, 'failed',
|
|
1467
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1202
1468
|
return false;
|
|
1203
1469
|
}
|
|
1204
|
-
|
|
1205
|
-
// 2. Forward DNS lookup
|
|
1206
|
-
$addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
|
|
1470
|
+
|
|
1471
|
+
// 2. Forward DNS lookup with strict 500ms timeout
|
|
1207
1472
|
$ips = [];
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
$ips[] = $address['ipv6'];
|
|
1214
|
-
}
|
|
1473
|
+
$resolvedA = self::dnsResolveUdp($validHostname, 'A', 0.5);
|
|
1474
|
+
if ($resolvedA === null) {
|
|
1475
|
+
self::recordDnsFailure();
|
|
1476
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1477
|
+
return false;
|
|
1215
1478
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1479
|
+
$ips = array_merge($ips, $resolvedA);
|
|
1480
|
+
|
|
1481
|
+
$resolvedAaaa = self::dnsResolveUdp($validHostname, 'AAAA', 0.5);
|
|
1482
|
+
if ($resolvedAaaa === null) {
|
|
1483
|
+
self::recordDnsFailure();
|
|
1484
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
$ips = array_merge($ips, $resolvedAaaa);
|
|
1488
|
+
|
|
1489
|
+
if (in_array($context->client_ip, $ips, true)) {
|
|
1490
|
+
self::recordDnsSuccess();
|
|
1218
1491
|
$store->set($cacheKey, 'verified', 86400);
|
|
1219
1492
|
return true;
|
|
1220
1493
|
}
|
|
1221
|
-
} catch (\Exception $e) {
|
|
1494
|
+
} catch (\Exception $e) {
|
|
1495
|
+
self::recordDnsFailure();
|
|
1496
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1222
1499
|
|
|
1223
|
-
$store->set($cacheKey, 'failed',
|
|
1500
|
+
$store->set($cacheKey, 'failed', 300); // 5 min negative caching
|
|
1224
1501
|
return false;
|
|
1225
1502
|
}
|
|
1226
1503
|
|
|
@@ -224,31 +224,38 @@ class Optimization
|
|
|
224
224
|
*/
|
|
225
225
|
public static function benfordTest(array $numbers): float // Rendre la méthode publique et statique
|
|
226
226
|
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
$leadingDigits = array_map(function ($n) {
|
|
232
|
-
$str = ltrim((string)$n, '0.');
|
|
233
|
-
return $str[0] ?? '';
|
|
234
|
-
}, $numbers);
|
|
235
|
-
|
|
236
|
-
$leadingDigits = array_filter($leadingDigits, fn ($d) => $d >= '1' && $d <= '9');
|
|
227
|
+
$counts = array_fill(1, 9, 0);
|
|
228
|
+
$validCount = 0;
|
|
237
229
|
|
|
238
|
-
|
|
239
|
-
|
|
230
|
+
foreach ($numbers as $n) {
|
|
231
|
+
if (is_string($n)) {
|
|
232
|
+
$n = (float)$n;
|
|
233
|
+
}
|
|
234
|
+
if (!is_numeric($n)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
$val = abs((float)$n);
|
|
238
|
+
if ($val == 0.0) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
$log = log10($val);
|
|
242
|
+
$factor = pow(10, (int)floor($log));
|
|
243
|
+
$digit = (int)floor($val / $factor);
|
|
244
|
+
if ($digit >= 1 && $digit <= 9) {
|
|
245
|
+
$counts[$digit]++;
|
|
246
|
+
$validCount++;
|
|
247
|
+
}
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
$counts[(int)$digit]++;
|
|
250
|
+
if ($validCount < 10) {
|
|
251
|
+
return 0.0;
|
|
245
252
|
}
|
|
246
253
|
|
|
247
254
|
$benfordDistribution = [1 => 30.1, 2 => 17.6, 3 => 12.5, 4 => 9.7, 5 => 7.9, 6 => 6.7, 7 => 5.8, 8 => 5.1, 9 => 4.6];
|
|
248
255
|
|
|
249
256
|
$totalDeviation = 0.0;
|
|
250
257
|
for ($i = 1; $i <= 9; $i++) {
|
|
251
|
-
$observedFrequency = ($counts[$i] /
|
|
258
|
+
$observedFrequency = ($counts[$i] / $validCount) * 100.0;
|
|
252
259
|
$expectedFrequency = $benfordDistribution[$i];
|
|
253
260
|
$totalDeviation += pow($observedFrequency - $expectedFrequency, 2);
|
|
254
261
|
}
|