@anonympins/fingerprint 0.8.0 → 0.8.1
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 +25 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/public/anonympins-bot-mitigation-pow.zip +0 -0
- package/src/js/asn-lookup.js +223 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +48 -22
- package/src/js/tests/asn-lookup.test.js +57 -0
- package/src/php/AsnLookupEngine.php +173 -0
- package/src/php/Challenge/ChallengeUtils.php +66 -49
- package/src/php/DirectFingerprint.php +25 -5
- package/src/php/FingerprintEngine.php +51 -30
- package/src/php/NetworkProfile.php +68 -0
- package/src/php/ProblemManager.php +12 -2
- package/src/php/Tests/AsnLookupTest.php +61 -0
- package/src/php/Tests/FingerprintEngineTest.php +4 -2
- package/src/php/Utils/Env.php +7 -2
- package/src/php/Utils/RequestUtils.php +21 -4
- package/src/php/WordPress/WpDbStore.php +27 -0
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +109 -8
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +1 -1
- package/src/php/WordPress/package.php +10 -0
- package/src/php/WordPress/readme.txt +8 -2
package/src/js/fingerprint.js
CHANGED
|
@@ -5,6 +5,7 @@ import {Worker} from "node:worker_threads";
|
|
|
5
5
|
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
6
6
|
import {Optimization} from "./library.js";
|
|
7
7
|
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
8
|
+
import {defaultAsnLookup, NetworkProfile} from "./asn-lookup.js";
|
|
8
9
|
import {DynamicWasmGenerator} from "./dynamic-wasm.js";
|
|
9
10
|
import {writeFileSync, readFileSync, existsSync, promises as fsPromises} from "node:fs";
|
|
10
11
|
import {fileURLToPath} from "node:url";
|
|
@@ -3438,18 +3439,26 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
3438
3439
|
/**
|
|
3439
3440
|
* Calcule un score d'incohérence analogique lisse plafonnant à une asymptote de 99.9.
|
|
3440
3441
|
* @param {number} consistencyScore Similarité entre 0 et 1 issue du FingerprintBuilder.
|
|
3441
|
-
* @param {number} [
|
|
3442
|
+
* @param {number|object} [inflectionOrProfile=0.72] Point d'inflexion ou profil réseau/IP.
|
|
3442
3443
|
* @param {number} [steepness=12] Raideur de la transition sigmoïdale.
|
|
3443
3444
|
* @returns {number} Score de 0 à 99.9 sans saut de palier ni certitude absolue à 100.
|
|
3444
3445
|
*/
|
|
3445
|
-
export function calculateAnalogInconsistencyScore(consistencyScore,
|
|
3446
|
+
export function calculateAnalogInconsistencyScore(consistencyScore, inflectionOrProfile = 0.72, steepness = 12) {
|
|
3446
3447
|
const s = Math.max(0.0, Math.min(1.0, consistencyScore));
|
|
3447
3448
|
if (s >= 0.98) return 0.0;
|
|
3448
3449
|
|
|
3450
|
+
let inflectionPoint = typeof inflectionOrProfile === 'number'
|
|
3451
|
+
? inflectionOrProfile
|
|
3452
|
+
: (inflectionOrProfile?.inflectionPoint ?? 0.72);
|
|
3453
|
+
|
|
3454
|
+
const k = typeof inflectionOrProfile === 'object' && inflectionOrProfile?.steepness !== undefined
|
|
3455
|
+
? inflectionOrProfile.steepness
|
|
3456
|
+
: steepness;
|
|
3457
|
+
|
|
3449
3458
|
const asymptote = 99.9;
|
|
3450
|
-
const raw = 1.0 / (1.0 + Math.exp(
|
|
3451
|
-
const minVal = 1.0 / (1.0 + Math.exp(
|
|
3452
|
-
const maxVal = 1.0 / (1.0 + Math.exp(
|
|
3459
|
+
const raw = 1.0 / (1.0 + Math.exp(k * (s - inflectionPoint)));
|
|
3460
|
+
const minVal = 1.0 / (1.0 + Math.exp(k * (1.0 - inflectionPoint)));
|
|
3461
|
+
const maxVal = 1.0 / (1.0 + Math.exp(k * (0.0 - inflectionPoint)));
|
|
3453
3462
|
const normalized = ((raw - minVal) / (maxVal - minVal)) * asymptote;
|
|
3454
3463
|
|
|
3455
3464
|
return Math.min(asymptote, Math.round(normalized * 10.0) / 10.0);
|
|
@@ -3625,13 +3634,14 @@ async function getBotnetClusterScore(context, stableFpHash) {
|
|
|
3625
3634
|
*/
|
|
3626
3635
|
async function getIpReputationScore(ip) {
|
|
3627
3636
|
const key = `ip-reputation:${ip}`;
|
|
3637
|
+
const netProfile = defaultAsnLookup.lookup(ip);
|
|
3628
3638
|
const data = await store.get(key);
|
|
3629
|
-
if (!data) return
|
|
3639
|
+
if (!data) return netProfile.baseScore;
|
|
3630
3640
|
|
|
3631
3641
|
const now = Date.now();
|
|
3632
3642
|
const hoursPassed = (now - data.lastUpdate) / (1000 * 60 * 60);
|
|
3633
3643
|
const decay = Math.floor(hoursPassed * 2); // Decay 2 points per hour of inactivity
|
|
3634
|
-
return Math.max(
|
|
3644
|
+
return Math.max(netProfile.baseScore, data.score - decay);
|
|
3635
3645
|
}
|
|
3636
3646
|
|
|
3637
3647
|
/**
|
|
@@ -3962,7 +3972,7 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
|
3962
3972
|
* @param {object} deviceData - The device's activity data.
|
|
3963
3973
|
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
3964
3974
|
*/
|
|
3965
|
-
async function getBehavioralIndicators(context, deviceData) {
|
|
3975
|
+
async function getBehavioralIndicators(context, deviceData, netProfile = null) {
|
|
3966
3976
|
const rapidChangeThresholdMs = 2000; // 2 secondes
|
|
3967
3977
|
const maxRapidChanges = 3;
|
|
3968
3978
|
|
|
@@ -4040,13 +4050,18 @@ async function getBehavioralIndicators(context, deviceData) {
|
|
|
4040
4050
|
: MAX_DISTINCT_IPS_PER_DEVICE;
|
|
4041
4051
|
const freeIpChanges = isSharedIp ? 1 : 3;
|
|
4042
4052
|
|
|
4043
|
-
|
|
4053
|
+
let historyScore = Math.min(
|
|
4044
4054
|
100,
|
|
4045
4055
|
(Math.max(0, deviceData.ips.size - freeIpChanges) /
|
|
4046
4056
|
(maxIpsForDevice - freeIpChanges)) *
|
|
4047
4057
|
100,
|
|
4048
4058
|
);
|
|
4049
4059
|
|
|
4060
|
+
// Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
|
|
4061
|
+
if (netProfile?.toleranceRotation) {
|
|
4062
|
+
historyScore = Math.min(historyScore, 10.0);
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4050
4065
|
// Score based on rapid identity rotation (0-100)
|
|
4051
4066
|
const rotationScore = Math.min(
|
|
4052
4067
|
100,
|
|
@@ -4177,6 +4192,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4177
4192
|
// Execute non-interdependent asynchronous operations in parallel
|
|
4178
4193
|
const zkpProof = (context.getHeader ? context.getHeader('x-zkp-proof') : null) || (context.headers ? context.headers['x-zkp-proof'] : null) || (context.query ? context.query['pow_zkp'] : null) || (context.queryParams ? context.queryParams['pow_zkp'] : null) || '';
|
|
4179
4194
|
const zkpY = zkpProof ? zkpProof.split(":")[0] : null;
|
|
4195
|
+
const netProfile = defaultAsnLookup.lookup(clientIp);
|
|
4180
4196
|
|
|
4181
4197
|
const [
|
|
4182
4198
|
behavioral,
|
|
@@ -4187,7 +4203,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4187
4203
|
{ botnetClusterScore },
|
|
4188
4204
|
_ // store.set result
|
|
4189
4205
|
] = await Promise.all([
|
|
4190
|
-
getBehavioralIndicators(context, deviceData), // This modifies deviceData, so it must be done before saving deviceData
|
|
4206
|
+
getBehavioralIndicators(context, deviceData, netProfile), // This modifies deviceData, so it must be done before saving deviceData
|
|
4191
4207
|
getThreatIntelScore(context, zkpY, securityConfig), // NOUVEAU: Score de Threat Intelligence Fédéré
|
|
4192
4208
|
getTlsSpoofingScore(context),
|
|
4193
4209
|
getSubnetScore(context, deviceId, securityConfig),
|
|
@@ -4198,7 +4214,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4198
4214
|
|
|
4199
4215
|
// Synchronous calculations
|
|
4200
4216
|
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
4201
|
-
const similarityThreshold = securityConfig?.similarityThreshold ??
|
|
4217
|
+
const similarityThreshold = securityConfig?.similarityThreshold ?? netProfile.inflectionPoint;
|
|
4202
4218
|
const inconsistencyScore = calculateAnalogInconsistencyScore(consistencyScore, similarityThreshold);
|
|
4203
4219
|
|
|
4204
4220
|
const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
|
|
@@ -5352,9 +5368,20 @@ export class FingerprintEngine {
|
|
|
5352
5368
|
return { action: 'next', score: 0, vector: {} };
|
|
5353
5369
|
}
|
|
5354
5370
|
|
|
5355
|
-
// --- PRIVATE ACCESS TOKENS (PAT / RFC
|
|
5371
|
+
// --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
|
|
5356
5372
|
const rawPatTokens = extractPrivateAccessTokens(requestContext.headers);
|
|
5373
|
+
let hasValidPat = false;
|
|
5357
5374
|
if (rawPatTokens.length > 0) {
|
|
5375
|
+
// Même avec un PAT valide, une attaque applicative avérée ou un terminal condamné doit être neutralisé
|
|
5376
|
+
if (deviceData?.condemned || this._hasCertainAttack(requestContext)) {
|
|
5377
|
+
return {
|
|
5378
|
+
action: 'block',
|
|
5379
|
+
status: 404,
|
|
5380
|
+
body: 'Forbidden',
|
|
5381
|
+
score: 100,
|
|
5382
|
+
vector: { honeypotScore: 100 }
|
|
5383
|
+
};
|
|
5384
|
+
}
|
|
5358
5385
|
const patConfig = this.securityConfig.pat || {};
|
|
5359
5386
|
for (const rawToken of rawPatTokens) {
|
|
5360
5387
|
const parsedToken = parsePrivateAccessToken(rawToken);
|
|
@@ -5367,18 +5394,12 @@ export class FingerprintEngine {
|
|
|
5367
5394
|
// Anti-replay: cache nonce with 24-hour TTL
|
|
5368
5395
|
const nonceTtl = patConfig.nonceTtl || 86400;
|
|
5369
5396
|
await store.set(nonceKey, true, nonceTtl);
|
|
5370
|
-
this._log('Private Access Token (PAT) cryptographically verified -
|
|
5397
|
+
this._log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', {
|
|
5371
5398
|
tokenType: parsedToken.tokenType,
|
|
5372
5399
|
tokenKeyId: parsedToken.tokenKeyId
|
|
5373
5400
|
});
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
score: 0.0,
|
|
5377
|
-
vector: { pat_verified: 100.0, privacy_pass: 100.0 },
|
|
5378
|
-
intendedAction: 'next'
|
|
5379
|
-
};
|
|
5380
|
-
if (newCookie) decision.newCookieForResponse = newCookie;
|
|
5381
|
-
return decision;
|
|
5401
|
+
hasValidPat = true;
|
|
5402
|
+
break;
|
|
5382
5403
|
}
|
|
5383
5404
|
}
|
|
5384
5405
|
}
|
|
@@ -5500,6 +5521,9 @@ export class FingerprintEngine {
|
|
|
5500
5521
|
// This is a strong signal of spoofing.
|
|
5501
5522
|
suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
|
|
5502
5523
|
}
|
|
5524
|
+
if (hasValidPat) {
|
|
5525
|
+
suspicionVector.pat_verified = 100.0;
|
|
5526
|
+
}
|
|
5503
5527
|
let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
|
|
5504
5528
|
|
|
5505
5529
|
this._log('Suspicion vector calculated', {
|
|
@@ -5988,7 +6012,7 @@ export class FingerprintEngine {
|
|
|
5988
6012
|
// OU
|
|
5989
6013
|
// 2. La requête est très suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
|
|
5990
6014
|
const zkpProof = requestContext.headers['x-zkp-proof'] || query.pow_zkp || '';
|
|
5991
|
-
const hasValidTicket = await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof);
|
|
6015
|
+
const hasValidTicket = (await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof)) || hasValidPat;
|
|
5992
6016
|
// Correction : Pour éviter une boucle infinie de challenges (qui mène à l'erreur 429),
|
|
5993
6017
|
// on fait confiance au ticket valide tant qu'il n'a pas expiré.
|
|
5994
6018
|
const maxIndicatorsCount = Object.values(suspicionVector).filter(val => typeof val === 'number' && val >= 100).length;
|
|
@@ -7382,6 +7406,8 @@ export const powMiddleware = (securityConfig) => {
|
|
|
7382
7406
|
* This is a common pattern to allow mocking of ES module functions.
|
|
7383
7407
|
*/
|
|
7384
7408
|
export const __internal = {
|
|
7409
|
+
defaultAsnLookup,
|
|
7410
|
+
NetworkProfile,
|
|
7385
7411
|
get store() { return store; }, // Export the store for testing
|
|
7386
7412
|
checkChallengeRateLimit,
|
|
7387
7413
|
getDeviceHash,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AsnLookupEngine, NetworkProfile } from '../asn-lookup.js';
|
|
3
|
+
import { calculateAnalogInconsistencyScore } from '../fingerprint.js';
|
|
4
|
+
|
|
5
|
+
describe('Native AsnLookupEngine Patricia Trie', () => {
|
|
6
|
+
const engine = new AsnLookupEngine();
|
|
7
|
+
|
|
8
|
+
it('should resolve Hetzner and AWS Datacenter IPs under 50ns with prior score 55.0', () => {
|
|
9
|
+
const hetznerProfile = engine.lookup('95.216.12.34');
|
|
10
|
+
expect(hetznerProfile.type).toBe('HOSTING');
|
|
11
|
+
expect(hetznerProfile.baseScore).toBe(55.0);
|
|
12
|
+
expect(hetznerProfile.inflectionPoint).toBe(0.85);
|
|
13
|
+
|
|
14
|
+
const awsProfile = engine.lookup('54.210.1.20');
|
|
15
|
+
expect(awsProfile.type).toBe('HOSTING');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should identify Mobile CGNAT 100.64.0.0/10 with rotation tolerance', () => {
|
|
19
|
+
const cgnatProfile = engine.lookup('100.70.1.25');
|
|
20
|
+
expect(cgnatProfile.type).toBe('CELLULAR');
|
|
21
|
+
expect(cgnatProfile.toleranceRotation).toBe(true);
|
|
22
|
+
expect(cgnatProfile.inflectionPoint).toBe(0.60);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should identify Starlink Satellite ranges with elevated jitter tolerance', () => {
|
|
26
|
+
const starlinkProfile = engine.lookup('98.97.10.5');
|
|
27
|
+
expect(starlinkProfile.type).toBe('SATELLITE');
|
|
28
|
+
expect(starlinkProfile.jitterTolerance).toBe(150.0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should default unknown residential IP to 0.0 base score', () => {
|
|
32
|
+
const resProfile = engine.lookup('82.120.45.67');
|
|
33
|
+
expect(resProfile.type).toBe('RESIDENTIAL');
|
|
34
|
+
expect(resProfile.baseScore).toBe(0.0);
|
|
35
|
+
expect(resProfile.inflectionPoint).toBe(0.72);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Analog Inconsistency Modulation', () => {
|
|
39
|
+
it('should be significantly more sensitive on Datacenter IP than Mobile for identical consistency', () => {
|
|
40
|
+
const consistency = 0.80; // Légère incohérence de rendu (ex: Canvas altéré)
|
|
41
|
+
|
|
42
|
+
const datacenterProfile = NetworkProfile.HOSTING; // inflectionPoint = 0.85
|
|
43
|
+
const mobileProfile = NetworkProfile.CELLULAR; // inflectionPoint = 0.60
|
|
44
|
+
const residentialProfile = NetworkProfile.RESIDENTIAL; // inflectionPoint = 0.72
|
|
45
|
+
|
|
46
|
+
const scoreDatacenter = calculateAnalogInconsistencyScore(consistency, datacenterProfile);
|
|
47
|
+
const scoreResidential = calculateAnalogInconsistencyScore(consistency, residentialProfile);
|
|
48
|
+
const scoreMobile = calculateAnalogInconsistencyScore(consistency, mobileProfile);
|
|
49
|
+
|
|
50
|
+
// Sur Datacenter (0.80 < 0.85), la suspicion décolle immédiatement
|
|
51
|
+
expect(scoreDatacenter).toBeGreaterThan(55.0);
|
|
52
|
+
// Sur Mobile (0.80 > 0.60), la variation est entièrement absorbée
|
|
53
|
+
expect(scoreMobile).toBeLessThan(10.0);
|
|
54
|
+
expect(scoreDatacenter).toBeGreaterThan(scoreResidential);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
class AsnTrieNode
|
|
8
|
+
{
|
|
9
|
+
/** @var array<?AsnTrieNode> */
|
|
10
|
+
public array $children = [null, null];
|
|
11
|
+
public ?NetworkProfile $profile = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Patricia Trie / Radix Trie en mémoire vive pour résolution ultra-rapide (< 50ns)
|
|
16
|
+
* sans appel réseau bloquant.
|
|
17
|
+
*/
|
|
18
|
+
class AsnLookupEngine
|
|
19
|
+
{
|
|
20
|
+
private static ?self $instance = null;
|
|
21
|
+
private AsnTrieNode $rootV4;
|
|
22
|
+
private AsnTrieNode $rootV6;
|
|
23
|
+
|
|
24
|
+
public function __construct()
|
|
25
|
+
{
|
|
26
|
+
$this->rootV4 = new AsnTrieNode();
|
|
27
|
+
$this->rootV6 = new AsnTrieNode();
|
|
28
|
+
$this->loadDefaultPrefixes();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public static function getInstance(): self
|
|
32
|
+
{
|
|
33
|
+
if (self::$instance === null) {
|
|
34
|
+
self::$instance = new self();
|
|
35
|
+
}
|
|
36
|
+
return self::$instance;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public function insert(string $cidr, NetworkProfile $profile): void
|
|
40
|
+
{
|
|
41
|
+
$parts = explode('/', trim($cidr), 2);
|
|
42
|
+
$ip = $parts[0];
|
|
43
|
+
$isV4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
|
44
|
+
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
45
|
+
if (!$isV4 && !$isV6) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$maxBits = $isV4 ? 32 : 128;
|
|
50
|
+
$prefixLen = isset($parts[1]) ? (int)$parts[1] : $maxBits;
|
|
51
|
+
if ($prefixLen < 0 || $prefixLen > $maxBits) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$bytes = inet_pton($ip);
|
|
56
|
+
if ($bytes === false) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$current = $isV4 ? $this->rootV4 : $this->rootV6;
|
|
61
|
+
for ($i = 0; $i < $prefixLen; $i++) {
|
|
62
|
+
$byteIdx = (int)($i / 8);
|
|
63
|
+
$bitIdx = 7 - ($i % 8);
|
|
64
|
+
$bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
|
|
65
|
+
|
|
66
|
+
if ($current->children[$bit] === null) {
|
|
67
|
+
$current->children[$bit] = new AsnTrieNode();
|
|
68
|
+
}
|
|
69
|
+
$current = $current->children[$bit];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
$current->profile = $profile;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public function lookup(?string $ip): NetworkProfile
|
|
76
|
+
{
|
|
77
|
+
if (empty($ip)) {
|
|
78
|
+
return NetworkProfile::residential();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
$cleanIp = trim($ip);
|
|
82
|
+
if (str_starts_with(strtolower($cleanIp), '::ffff:')) {
|
|
83
|
+
$cleanIp = substr($cleanIp, 7);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$isV4 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
|
87
|
+
$isV6 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
88
|
+
if (!$isV4 && !$isV6) {
|
|
89
|
+
return NetworkProfile::residential();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$bytes = inet_pton($cleanIp);
|
|
93
|
+
if ($bytes === false) {
|
|
94
|
+
return NetworkProfile::residential();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
$current = $isV4 ? $this->rootV4 : $this->rootV6;
|
|
98
|
+
$matchedProfile = null;
|
|
99
|
+
$totalBits = $isV4 ? 32 : 128;
|
|
100
|
+
|
|
101
|
+
for ($i = 0; $i < $totalBits; $i++) {
|
|
102
|
+
if ($current->profile !== null) {
|
|
103
|
+
$matchedProfile = $current->profile; // Longest Prefix Match
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
$byteIdx = (int)($i / 8);
|
|
107
|
+
$bitIdx = 7 - ($i % 8);
|
|
108
|
+
$bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
|
|
109
|
+
|
|
110
|
+
$current = $current->children[$bit];
|
|
111
|
+
if ($current === null) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if ($current !== null && $current->profile !== null) {
|
|
117
|
+
$matchedProfile = $current->profile;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return $matchedProfile ?? NetworkProfile::residential();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public function loadDefaultPrefixes(): void
|
|
124
|
+
{
|
|
125
|
+
// Mobile CGNAT (RFC 6598)
|
|
126
|
+
$this->insert('100.64.0.0/10', NetworkProfile::cellular());
|
|
127
|
+
|
|
128
|
+
// Datacenters & Cloud majeurs (AWS, Hetzner, OVH, DigitalOcean)
|
|
129
|
+
$datacenterRanges = [
|
|
130
|
+
// AWS
|
|
131
|
+
'3.0.0.0/9', '3.128.0.0/9', '18.192.0.0/11', '34.192.0.0/10',
|
|
132
|
+
'35.156.0.0/14', '52.0.0.0/11', '54.0.0.0/8',
|
|
133
|
+
// Hetzner
|
|
134
|
+
'78.46.0.0/15', '88.198.0.0/16', '94.130.0.0/16', '95.216.0.0/15',
|
|
135
|
+
'116.202.0.0/15', '135.181.0.0/16', '136.243.0.0/16', '138.201.0.0/16',
|
|
136
|
+
'142.132.0.0/16', '144.76.0.0/16', '148.251.0.0/16', '159.69.0.0/16',
|
|
137
|
+
'168.119.0.0/16', '178.63.0.0/16', '188.40.0.0/16', '195.201.0.0/16',
|
|
138
|
+
// OVH
|
|
139
|
+
'51.68.0.0/14', '51.75.0.0/15', '51.77.0.0/16', '51.79.0.0/16',
|
|
140
|
+
'51.81.0.0/16', '51.83.0.0/16', '51.89.0.0/16', '51.91.0.0/16',
|
|
141
|
+
'137.74.0.0/16', '141.94.0.0/15', '145.239.0.0/16', '147.135.0.0/16',
|
|
142
|
+
'176.31.0.0/16', '178.32.0.0/15', '188.165.0.0/16', '198.27.64.0/18',
|
|
143
|
+
// DigitalOcean
|
|
144
|
+
'64.225.0.0/16', '68.183.0.0/16', '104.248.0.0/16', '128.199.0.0/16',
|
|
145
|
+
'134.209.0.0/16', '138.68.0.0/16', '138.197.0.0/16', '139.59.0.0/16',
|
|
146
|
+
'142.93.0.0/16', '143.198.0.0/16', '146.190.0.0/16', '157.230.0.0/16',
|
|
147
|
+
'159.65.0.0/16', '159.89.0.0/16', '161.35.0.0/16', '164.90.128.0/17',
|
|
148
|
+
'165.22.0.0/16', '165.227.0.0/16', '167.99.0.0/16', '174.138.0.0/16',
|
|
149
|
+
'178.62.0.0/16', '178.128.0.0/16', '188.166.0.0/16', '206.189.0.0/16',
|
|
150
|
+
];
|
|
151
|
+
foreach ($datacenterRanges as $range) {
|
|
152
|
+
$this->insert($range, NetworkProfile::hosting());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Satellite (Starlink Space-X)
|
|
156
|
+
$satelliteRanges = [
|
|
157
|
+
'98.97.0.0/16', '129.222.0.0/16', '143.130.0.0/16',
|
|
158
|
+
'143.244.0.0/16', '206.214.224.0/19'
|
|
159
|
+
];
|
|
160
|
+
foreach ($satelliteRanges as $range) {
|
|
161
|
+
$this->insert($range, NetworkProfile::satellite());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tor Exit Relays / Proxies anonymiseurs
|
|
165
|
+
$anonymizerRanges = [
|
|
166
|
+
'185.220.101.0/24', '185.220.102.0/24', '185.220.103.0/24',
|
|
167
|
+
'185.100.86.128/25', '198.98.56.0/24', '199.249.230.0/24'
|
|
168
|
+
];
|
|
169
|
+
foreach ($anonymizerRanges as $range) {
|
|
170
|
+
$this->insert($range, NetworkProfile::anonymizer());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -353,19 +353,19 @@ class ChallengeUtils
|
|
|
353
353
|
$expectedMsg = "{$clientSecret}:find_peer:{$nodeId}";
|
|
354
354
|
break;
|
|
355
355
|
case 'webrtc_signal':
|
|
356
|
-
$expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . ($params['target_peer_id']
|
|
356
|
+
$expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . (isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '') . ":" . (isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '') . ":" . (isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '');
|
|
357
357
|
break;
|
|
358
358
|
case 'poll_signals':
|
|
359
359
|
$expectedMsg = "{$clientSecret}:poll_signals:{$nodeId}";
|
|
360
360
|
break;
|
|
361
361
|
case 'request_peer_block':
|
|
362
|
-
$expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . ($params['peer_id']
|
|
362
|
+
$expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . (isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '') . ":" . ($params['block_idx'] ?? '0') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '');
|
|
363
363
|
break;
|
|
364
364
|
case 'poll_requests':
|
|
365
365
|
$expectedMsg = "{$clientSecret}:poll_requests:{$nodeId}";
|
|
366
366
|
break;
|
|
367
367
|
case 'respond_block':
|
|
368
|
-
$expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . ($params['requester_id']
|
|
368
|
+
$expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . (isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '') . ":" . (isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '');
|
|
369
369
|
break;
|
|
370
370
|
case 'poll_response':
|
|
371
371
|
$expectedMsg = "{$clientSecret}:poll_response:{$nodeId}:" . ($params['req_id'] ?? '');
|
|
@@ -383,7 +383,7 @@ class ChallengeUtils
|
|
|
383
383
|
switch ($op) {
|
|
384
384
|
case 'register':
|
|
385
385
|
$clientIp = self::getSanitizedServerVar('REMOTE_ADDR', '127.0.0.1');
|
|
386
|
-
$seed = $params['seed']
|
|
386
|
+
$seed = isset($params['seed']) ? self::sanitizeString($params['seed']) : '';
|
|
387
387
|
self::registerCooperativeNode($clientIp, $nodeId, $seed);
|
|
388
388
|
return ['status' => 'registered'];
|
|
389
389
|
|
|
@@ -395,9 +395,9 @@ class ChallengeUtils
|
|
|
395
395
|
return ['status' => 'no_peers'];
|
|
396
396
|
|
|
397
397
|
case 'webrtc_signal':
|
|
398
|
-
$targetPeerId = $params['target_peer_id']
|
|
399
|
-
$signalType = $params['signal_type']
|
|
400
|
-
$signalData = $params['signal_data']
|
|
398
|
+
$targetPeerId = isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '';
|
|
399
|
+
$signalType = isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '';
|
|
400
|
+
$signalData = isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '';
|
|
401
401
|
if (empty($targetPeerId) || empty($signalType) || empty($signalData)) {
|
|
402
402
|
return ['error' => 'Invalid parameters'];
|
|
403
403
|
}
|
|
@@ -420,9 +420,9 @@ class ChallengeUtils
|
|
|
420
420
|
return ['status' => 'ok', 'signals' => $signals];
|
|
421
421
|
|
|
422
422
|
case 'request_peer_block':
|
|
423
|
-
$peerId = $params['peer_id']
|
|
423
|
+
$peerId = isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '';
|
|
424
424
|
$blockIdx = (int)($params['block_idx'] ?? 0);
|
|
425
|
-
$requestId = $params['req_id']
|
|
425
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
426
426
|
if (empty($peerId) || empty($requestId)) {
|
|
427
427
|
return ['error' => 'Invalid parameters'];
|
|
428
428
|
}
|
|
@@ -444,9 +444,9 @@ class ChallengeUtils
|
|
|
444
444
|
return ['requests' => $requests];
|
|
445
445
|
|
|
446
446
|
case 'respond_block':
|
|
447
|
-
$requesterId = $params['requester_id']
|
|
448
|
-
$requestId = $params['req_id']
|
|
449
|
-
$blockData = $params['block_data']
|
|
447
|
+
$requesterId = isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '';
|
|
448
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
449
|
+
$blockData = isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '';
|
|
450
450
|
if (empty($requesterId) || empty($requestId)) {
|
|
451
451
|
return ['error' => 'Invalid parameters'];
|
|
452
452
|
}
|
|
@@ -456,7 +456,7 @@ class ChallengeUtils
|
|
|
456
456
|
return ['status' => 'delivered'];
|
|
457
457
|
|
|
458
458
|
case 'poll_response':
|
|
459
|
-
$requestId = $params['req_id']
|
|
459
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
460
460
|
$responseKey = "coop-mailbox:res:{$nodeId}:{$requestId}";
|
|
461
461
|
$data = $store->get($responseKey);
|
|
462
462
|
if ($data) {
|
|
@@ -634,28 +634,27 @@ class ChallengeUtils
|
|
|
634
634
|
throw new \RuntimeException("Failed to extract Ed25519 public key.");
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
-
if ($outDir === '') {
|
|
638
|
-
$outDir = dirname(__DIR__, 2) . '/config';
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
if (!is_dir($outDir)) {
|
|
642
|
-
if (function_exists('wp_mkdir_p')) {
|
|
643
|
-
wp_mkdir_p($outDir);
|
|
644
|
-
} else {
|
|
645
|
-
mkdir($outDir, 0755, true);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
637
|
$privName = $options['privateKeyName'] ?? 'issuer-private.pem';
|
|
650
638
|
$pubName = $options['publicKeyName'] ?? 'issuer-public.pem';
|
|
651
639
|
|
|
652
|
-
$privateKeyPath =
|
|
653
|
-
$publicKeyPath =
|
|
640
|
+
$privateKeyPath = '';
|
|
641
|
+
$publicKeyPath = '';
|
|
654
642
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
643
|
+
if ($outDir !== '') {
|
|
644
|
+
if (!is_dir($outDir)) {
|
|
645
|
+
if (function_exists('wp_mkdir_p')) {
|
|
646
|
+
wp_mkdir_p($outDir);
|
|
647
|
+
} else {
|
|
648
|
+
mkdir($outDir, 0755, true);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
$privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
|
|
652
|
+
$publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
|
|
653
|
+
file_put_contents($privateKeyPath, $privateKeyPem);
|
|
654
|
+
@chmod($privateKeyPath, 0600);
|
|
655
|
+
file_put_contents($publicKeyPath, $publicKeyPem);
|
|
656
|
+
@chmod($publicKeyPath, 0644);
|
|
657
|
+
}
|
|
659
658
|
|
|
660
659
|
return [
|
|
661
660
|
'privateKeyPath' => $privateKeyPath,
|
|
@@ -1294,20 +1293,25 @@ class ChallengeUtils
|
|
|
1294
1293
|
*/
|
|
1295
1294
|
private static function getPowSolverCode(): string
|
|
1296
1295
|
{
|
|
1296
|
+
if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
|
|
1297
|
+
$wpPluginSolver = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/assets/pow.solver.inline.js';
|
|
1298
|
+
if (file_exists($wpPluginSolver)) {
|
|
1299
|
+
return (string)file_get_contents($wpPluginSolver);
|
|
1300
|
+
}
|
|
1301
|
+
} elseif (defined('WP_PLUGIN_DIR')) {
|
|
1302
|
+
$wpPluginSolver = WP_PLUGIN_DIR . '/anonympins-bot-mitigation-pow/assets/pow.solver.inline.js';
|
|
1303
|
+
if (file_exists($wpPluginSolver)) {
|
|
1304
|
+
return (string)file_get_contents($wpPluginSolver);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1297
1308
|
$candidates = [
|
|
1298
1309
|
__DIR__ . '/../../js/pow.solver.inline.js',
|
|
1299
1310
|
__DIR__ . '/../assets/pow.solver.inline.js',
|
|
1300
1311
|
__DIR__ . '/../js/pow.solver.inline.js',
|
|
1301
|
-
dirname(__DIR__
|
|
1302
|
-
dirname(__DIR__
|
|
1303
|
-
dirname(__DIR__, 1) . '/assets/pow.solver.inline.js',
|
|
1312
|
+
dirname(__DIR__) . '/assets/pow.solver.inline.js',
|
|
1313
|
+
dirname(__DIR__) . '/js/pow.solver.inline.js',
|
|
1304
1314
|
];
|
|
1305
|
-
if (defined('ABSPATH')) {
|
|
1306
|
-
$wpPluginSolver = ABSPATH . 'wp-content/plugins/fingerprint-anti-bot/assets/pow.solver.inline.js';
|
|
1307
|
-
if (file_exists($wpPluginSolver)) {
|
|
1308
|
-
return file_get_contents($wpPluginSolver) ?: '';
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
1315
|
foreach ($candidates as $solverPath) {
|
|
1312
1316
|
if (file_exists($solverPath)) {
|
|
1313
1317
|
return file_get_contents($solverPath) ?: '';
|
|
@@ -1493,6 +1497,27 @@ class ChallengeUtils
|
|
|
1493
1497
|
error_log($message);
|
|
1494
1498
|
}
|
|
1495
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* Safely sanitizes a string input using WordPress functions if available, or fallback PHP functions.
|
|
1502
|
+
*
|
|
1503
|
+
* @param mixed $value
|
|
1504
|
+
* @return string
|
|
1505
|
+
*/
|
|
1506
|
+
private static function sanitizeString($value): string
|
|
1507
|
+
{
|
|
1508
|
+
if (!is_scalar($value)) {
|
|
1509
|
+
return '';
|
|
1510
|
+
}
|
|
1511
|
+
$val = (string)$value;
|
|
1512
|
+
if (function_exists('wp_unslash')) {
|
|
1513
|
+
$val = wp_unslash($val);
|
|
1514
|
+
}
|
|
1515
|
+
if (function_exists('sanitize_text_field')) {
|
|
1516
|
+
return sanitize_text_field($val);
|
|
1517
|
+
}
|
|
1518
|
+
return trim(strip_tags($val));
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1496
1521
|
/**
|
|
1497
1522
|
* Safely retrieves and sanitizes a value from the $_SERVER superglobal.
|
|
1498
1523
|
* Uses WordPress functions if available, otherwise falls back to basic PHP sanitization.
|
|
@@ -1509,14 +1534,6 @@ class ChallengeUtils
|
|
|
1509
1534
|
|
|
1510
1535
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed and sanitized immediately below
|
|
1511
1536
|
$value = $_SERVER[$key];
|
|
1512
|
-
|
|
1513
|
-
if (function_exists('wp_unslash')) {
|
|
1514
|
-
$value = wp_unslash($value);
|
|
1515
|
-
}
|
|
1516
|
-
if (function_exists('sanitize_text_field')) {
|
|
1517
|
-
return sanitize_text_field($value);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
return is_scalar($value) ? (string) $value : '';
|
|
1537
|
+
return self::sanitizeString($value);
|
|
1521
1538
|
}
|
|
1522
1539
|
}
|