@anonympins/fingerprint 0.7.1 → 0.7.2
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 +12 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/public/fingerprint-wordpress.zip +0 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +38 -6
- package/src/js/tests/fingerprint.test.js +90 -3
- package/src/php/Challenge/ChallengeUtils.php +1 -1
- package/src/php/FingerprintEngine.php +37 -12
- package/src/php/Tests/FingerprintEngineTest.php +700 -662
- package/src/php/Utils/BlockList.php +99 -99
- package/src/php/WordPress/fingerprint-wordpress.php +347 -10
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +262 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +268 -0
- package/src/php/WordPress/package.php +96 -1
package/src/js/fingerprint.js
CHANGED
|
@@ -4472,6 +4472,17 @@ export class FingerprintEngine {
|
|
|
4472
4472
|
}
|
|
4473
4473
|
}
|
|
4474
4474
|
}
|
|
4475
|
+
|
|
4476
|
+
// If no security config is provided at all, create a base one.
|
|
4477
|
+
if (!finalConfig) {
|
|
4478
|
+
finalConfig = {};
|
|
4479
|
+
}
|
|
4480
|
+
// If the whitelist is not explicitly provided in the configuration,
|
|
4481
|
+
// apply the default whitelist which includes common search engine bots.
|
|
4482
|
+
if (!finalConfig.whitelist) {
|
|
4483
|
+
finalConfig.whitelist = default_whitelist();
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4475
4486
|
this.securityConfig = finalConfig;
|
|
4476
4487
|
this.isProduction = isProduction;
|
|
4477
4488
|
this._allowlist = this._buildAllowlist();
|
|
@@ -4670,7 +4681,8 @@ export class FingerprintEngine {
|
|
|
4670
4681
|
return false;
|
|
4671
4682
|
}
|
|
4672
4683
|
_isIpInAllowlist(clientIp) {
|
|
4673
|
-
|
|
4684
|
+
const family = isIPv6(clientIp) ? 'ipv6' : 'ipv4';
|
|
4685
|
+
return this._allowlist.check(clientIp, family);
|
|
4674
4686
|
}
|
|
4675
4687
|
/**
|
|
4676
4688
|
* Checks if the request's host and path match an entry in the host+path allowlist.
|
|
@@ -4809,7 +4821,10 @@ export class FingerprintEngine {
|
|
|
4809
4821
|
return false;
|
|
4810
4822
|
}
|
|
4811
4823
|
|
|
4824
|
+
const botName = matchedRule.userAgent; // e.g., 'Googlebot'
|
|
4825
|
+
|
|
4812
4826
|
if (!canAttemptDns()) {
|
|
4827
|
+
this._log(`[Bot Verification] DNS circuit breaker is open. Skipping check for ${botName}.`, { clientIp });
|
|
4813
4828
|
return false;
|
|
4814
4829
|
}
|
|
4815
4830
|
|
|
@@ -4817,9 +4832,11 @@ export class FingerprintEngine {
|
|
|
4817
4832
|
const cachedStatus = await store.get(cacheKey);
|
|
4818
4833
|
|
|
4819
4834
|
if (cachedStatus === 'verified') {
|
|
4835
|
+
this._log(`[Bot Verification] PASSED (cached): ${botName}`, { clientIp });
|
|
4820
4836
|
return true;
|
|
4821
4837
|
}
|
|
4822
4838
|
if (cachedStatus === 'failed') {
|
|
4839
|
+
this._log(`[Bot Verification] FAILED (cached): ${botName}`, { clientIp });
|
|
4823
4840
|
return false;
|
|
4824
4841
|
}
|
|
4825
4842
|
|
|
@@ -4829,25 +4846,35 @@ export class FingerprintEngine {
|
|
|
4829
4846
|
const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
|
|
4830
4847
|
|
|
4831
4848
|
if (!validHostname) {
|
|
4849
|
+
this._log(`[Bot Verification] FAILED: Reverse DNS lookup for ${clientIp} did not yield a valid hostname ending in '${matchedRule.hostnameSuffix}'.`, { clientIp, resolvedHostnames: hostnames });
|
|
4832
4850
|
await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
|
|
4833
4851
|
return false;
|
|
4834
4852
|
}
|
|
4835
4853
|
|
|
4854
|
+
this._log(`[Bot Verification] Reverse DNS OK for ${botName}.`, { clientIp, validHostname });
|
|
4855
|
+
|
|
4836
4856
|
// 2. Forward DNS lookup
|
|
4837
4857
|
let addresses = [];
|
|
4838
4858
|
try {
|
|
4839
4859
|
addresses = await withTimeout(dns.resolve(validHostname), 500);
|
|
4840
|
-
} catch (e) {
|
|
4860
|
+
} catch (e) {
|
|
4861
|
+
this._log(`[Bot Verification] Forward DNS (A) lookup FAILED for ${validHostname}.`, { error: e.message });
|
|
4862
|
+
}
|
|
4841
4863
|
try {
|
|
4842
4864
|
const ipv6 = await withTimeout(dns.resolve(validHostname, 'AAAA'), 500);
|
|
4843
4865
|
addresses = addresses.concat(ipv6);
|
|
4844
|
-
} catch (e) {
|
|
4866
|
+
} catch (e) {
|
|
4867
|
+
this._log(`[Bot Verification] Forward DNS (AAAA) lookup FAILED for ${validHostname}.`, { error: e.message });
|
|
4868
|
+
}
|
|
4845
4869
|
if (addresses.includes(clientIp)) {
|
|
4870
|
+
this._log(`[Bot Verification] PASSED: Forward DNS IP matches original IP for ${botName}.`, { clientIp, validHostname, resolvedIps: addresses });
|
|
4846
4871
|
recordDnsSuccess();
|
|
4847
4872
|
await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
|
|
4848
4873
|
return true;
|
|
4849
4874
|
}
|
|
4875
|
+
this._log(`[Bot Verification] FAILED: IP mismatch for ${botName}. Original IP not in resolved addresses.`, { clientIp, validHostname, resolvedIps: addresses });
|
|
4850
4876
|
} catch (error) {
|
|
4877
|
+
this._log(`[Bot Verification] FAILED: DNS lookup error for ${botName}.`, { clientIp, error: error.message });
|
|
4851
4878
|
recordDnsFailure();
|
|
4852
4879
|
await store.set(cacheKey, 'failed', 300); // Temporary negative caching (5 minutes)
|
|
4853
4880
|
return false;
|
|
@@ -4965,15 +4992,15 @@ export class FingerprintEngine {
|
|
|
4965
4992
|
}
|
|
4966
4993
|
}
|
|
4967
4994
|
|
|
4968
|
-
|
|
4969
|
-
if (
|
|
4995
|
+
const botVerificationResult = await this._verifyWhitelistedBot(requestContext);
|
|
4996
|
+
if (botVerificationResult === true) {
|
|
4970
4997
|
const filterWhitelist = this.securityConfig.filterWhitelist || false;
|
|
4971
4998
|
let bypassWhitelist = false;
|
|
4972
4999
|
if (filterWhitelist === true) {
|
|
4973
5000
|
bypassWhitelist = this._hasCertainAttack(requestContext);
|
|
4974
5001
|
} else if (typeof filterWhitelist === 'number') {
|
|
4975
5002
|
const res = await getScoreAndVector();
|
|
4976
|
-
if (res.score > filterWhitelist) {
|
|
5003
|
+
if (res.score > filterWhitelist) { // NOSONAR
|
|
4977
5004
|
bypassWhitelist = true;
|
|
4978
5005
|
}
|
|
4979
5006
|
}
|
|
@@ -5013,6 +5040,11 @@ export class FingerprintEngine {
|
|
|
5013
5040
|
|
|
5014
5041
|
// The engine now works with the context directly, no more rawReq dependency here.
|
|
5015
5042
|
const suspicionVector = preCalculatedVector || await __internal.getSuspicionVector(requestContext, this.securityConfig);
|
|
5043
|
+
if (botVerificationResult === false) {
|
|
5044
|
+
// This means it claimed to be a bot but failed verification.
|
|
5045
|
+
// This is a strong signal of spoofing.
|
|
5046
|
+
suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
|
|
5047
|
+
}
|
|
5016
5048
|
let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
|
|
5017
5049
|
|
|
5018
5050
|
this._log('Suspicion vector calculated', {
|
|
@@ -10,6 +10,8 @@ import * as fingerprint from '../fingerprint.js';
|
|
|
10
10
|
vi.mock('import-meta-env', () => ({
|
|
11
11
|
env: { NODE_ENV: 'test', POW_SECRET: 'fallback-dev-secret-32-chars-minimum' },
|
|
12
12
|
}));
|
|
13
|
+
// Mock the entire dns module
|
|
14
|
+
vi.mock('node:dns/promises');
|
|
13
15
|
// Mock readFileSync for custom challenge page tests
|
|
14
16
|
vi.mock('node:fs', async () => {
|
|
15
17
|
const actualFs = await vi.importActual('node:fs');
|
|
@@ -35,9 +37,8 @@ const {
|
|
|
35
37
|
} = fingerprint;
|
|
36
38
|
const { store, getRequestPatternScore, getDeviceHash } = __internal;
|
|
37
39
|
const { getRenderingAnomalyScore } = __internal;
|
|
38
|
-
let { getBehaviorScore, getClickVarianceScore } = __internal;
|
|
39
|
-
|
|
40
|
-
vi.mock('node:dns/promises');
|
|
40
|
+
let { getBehaviorScore, getClickVarianceScore } = __internal; // eslint-disable-line prefer-const
|
|
41
|
+
|
|
41
42
|
|
|
42
43
|
describe('Fingerprint & PoW Security Suite', () => {
|
|
43
44
|
test('cyrb53 should be deterministic', () => {
|
|
@@ -1764,6 +1765,92 @@ describe('Fingerprint & PoW Security Suite', () => {
|
|
|
1764
1765
|
});
|
|
1765
1766
|
});
|
|
1766
1767
|
});
|
|
1768
|
+
describe('Googlebot Whitelisting (Node.js)', () => {
|
|
1769
|
+
const inMemoryStore = {
|
|
1770
|
+
_map: new Map(),
|
|
1771
|
+
async get(key) { return this._map.get(key); },
|
|
1772
|
+
async set(key, value) { this._map.set(key, value); },
|
|
1773
|
+
async has(key) { return this._map.has(key); },
|
|
1774
|
+
async delete(key) { this._map.delete(key); },
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
const securityConfig = {
|
|
1778
|
+
weights: { headerAnomalyScore: 1.0, tlsSpoofingScore: 1.0 }, // Simple config for testing
|
|
1779
|
+
thresholds: { low: 20, medium: 45, high: 75, block: 95 },
|
|
1780
|
+
whitelist: [
|
|
1781
|
+
{ userAgent: 'Googlebot', hostnameSuffix: '.googlebot.com' }
|
|
1782
|
+
]
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
let engine;
|
|
1786
|
+
|
|
1787
|
+
beforeEach(() => {
|
|
1788
|
+
inMemoryStore._map.clear();
|
|
1789
|
+
configureStore(inMemoryStore);
|
|
1790
|
+
vi.clearAllMocks(); // Clear mocks before each test
|
|
1791
|
+
engine = new FingerprintEngine(securityConfig);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it('should correctly identify and allow a legitimate Googlebot request', async () => {
|
|
1795
|
+
const googleIp = '::ffff:66.249.66.39'; // An IP from the official list
|
|
1796
|
+
const googleHostname = 'crawl-66-249-66-39.googlebot.com';
|
|
1797
|
+
const googleUa = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
|
|
1798
|
+
|
|
1799
|
+
// Mock the DNS verification process
|
|
1800
|
+
vi.mocked(dns.reverse).mockResolvedValue([googleHostname]);
|
|
1801
|
+
vi.mocked(dns.resolve).mockResolvedValue([googleIp]);
|
|
1802
|
+
|
|
1803
|
+
const requestContext = {
|
|
1804
|
+
clientIp: googleIp,
|
|
1805
|
+
path: '/',
|
|
1806
|
+
headers: { 'user-agent': googleUa },
|
|
1807
|
+
isStatic: false,
|
|
1808
|
+
cookies: {},
|
|
1809
|
+
query: {},
|
|
1810
|
+
body: null,
|
|
1811
|
+
httpVersion: '1.1',
|
|
1812
|
+
rawHeaders: ['user-agent', googleUa]
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
const decision = await engine.processRequest(requestContext);
|
|
1816
|
+
|
|
1817
|
+
expect(decision.action).toBe('next');
|
|
1818
|
+
expect(decision.score).toBe(0);
|
|
1819
|
+
expect(decision.vector.whitelisted).toBe(100);
|
|
1820
|
+
expect(decision.vector.type).toBe('bot');
|
|
1821
|
+
|
|
1822
|
+
expect(dns.reverse).toHaveBeenCalledWith(googleIp);
|
|
1823
|
+
expect(dns.resolve).toHaveBeenCalledWith(googleHostname);
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
it('should challenge or block a request spoofing a Googlebot User-Agent from a non-Google IP', async () => {
|
|
1827
|
+
const fakeGoogleIp = '1.2.3.4';
|
|
1828
|
+
const googleUa = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
|
|
1829
|
+
|
|
1830
|
+
vi.mocked(dns.reverse).mockRejectedValue(new Error('Host not found'));
|
|
1831
|
+
|
|
1832
|
+
const requestContext = {
|
|
1833
|
+
clientIp: fakeGoogleIp,
|
|
1834
|
+
path: '/',
|
|
1835
|
+
headers: { 'user-agent': googleUa, 'accept-language': 'en-US' },
|
|
1836
|
+
isStatic: false,
|
|
1837
|
+
cookies: {},
|
|
1838
|
+
query: {},
|
|
1839
|
+
body: null,
|
|
1840
|
+
httpVersion: '1.1',
|
|
1841
|
+
rawHeaders: ['user-agent', googleUa, 'accept-language', 'en-US']
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
const decision = await engine.processRequest(requestContext);
|
|
1845
|
+
|
|
1846
|
+
expect(decision.action).toBe('block');
|
|
1847
|
+
expect(decision.score).toBeGreaterThan(0);
|
|
1848
|
+
expect(decision.vector.whitelisted).toBeUndefined();
|
|
1849
|
+
|
|
1850
|
+
expect(dns.reverse).toHaveBeenCalledWith(fakeGoogleIp);
|
|
1851
|
+
expect(dns.resolve).not.toHaveBeenCalled();
|
|
1852
|
+
});
|
|
1853
|
+
});
|
|
1767
1854
|
|
|
1768
1855
|
describe('TLS Session Resumption (Cookieless Identity)', () => {
|
|
1769
1856
|
const inMemoryStore = {
|
|
@@ -179,6 +179,12 @@
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
// Si aucune liste blanche n'est explicitement fournie, appliquer la liste par défaut.
|
|
184
|
+
if (!isset($securityConfig['whitelist'])) {
|
|
185
|
+
$securityConfig['whitelist'] = self::default_whitelist();
|
|
186
|
+
}
|
|
187
|
+
|
|
182
188
|
$this->securityConfig = $securityConfig;
|
|
183
189
|
$this->verbose = $securityConfig['verbose'] ?? false;
|
|
184
190
|
$this->allowlist = $this->buildAllowlist();
|
|
@@ -551,10 +557,8 @@
|
|
|
551
557
|
|
|
552
558
|
$deviceId = bin2hex(random_bytes(16)); // UUID-like
|
|
553
559
|
|
|
554
|
-
$isHttps = (
|
|
555
|
-
|
|
556
|
-
($context->getHeader('x-forwarded-proto') === 'https');
|
|
557
|
-
$secureOption = $isHttps || $this->isProduction;
|
|
560
|
+
$isHttps = !empty($context->isHttps);
|
|
561
|
+
$secureOption = $isHttps;
|
|
558
562
|
|
|
559
563
|
// Préparer le cookie à envoyer
|
|
560
564
|
$newCookie = [
|
|
@@ -985,13 +989,36 @@
|
|
|
985
989
|
// Le fingerprint est cohérent, on peut valider la solution
|
|
986
990
|
if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
|
|
987
991
|
$cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
|
|
988
|
-
if ($cpuSolution) {
|
|
989
|
-
$
|
|
992
|
+
if ($cpuSolution !== null && $cpuSolution !== '') {
|
|
993
|
+
$identity = $this->resolveRequestIdentity($context, $suspicionVector);
|
|
994
|
+
$deviceId = (string)($identity['deviceId'] ?? '');
|
|
995
|
+
$currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
|
|
996
|
+
|
|
997
|
+
$ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
|
|
998
|
+
$context->clientIp,
|
|
999
|
+
3600000,
|
|
1000
|
+
$powNonce,
|
|
1001
|
+
(string)$cpuSolution,
|
|
1002
|
+
$challengeContext,
|
|
1003
|
+
$deviceId,
|
|
1004
|
+
$currentDeviceHash
|
|
1005
|
+
);
|
|
990
1006
|
$isValid = $ticket !== null;
|
|
991
1007
|
|
|
992
1008
|
if ($powType === 'cpu_mem') {
|
|
993
|
-
|
|
994
|
-
|
|
1009
|
+
if (!empty($challengeContext['isHttp'])) {
|
|
1010
|
+
$isMemValid = true;
|
|
1011
|
+
} else {
|
|
1012
|
+
$memSolution = $context->query['pow_solution_mem'] ?? null;
|
|
1013
|
+
$isMemValid = ($memSolution !== null && $memSolution !== '')
|
|
1014
|
+
? ChallengeUtils::verifyMemoryPoW(
|
|
1015
|
+
$powNonce,
|
|
1016
|
+
(string)$memSolution,
|
|
1017
|
+
$challengeContext['memDifficulty'] ?? 0,
|
|
1018
|
+
$challengeContext['clientSecret'] ?? ''
|
|
1019
|
+
)
|
|
1020
|
+
: false;
|
|
1021
|
+
}
|
|
995
1022
|
$isValid = $isValid && $isMemValid;
|
|
996
1023
|
}
|
|
997
1024
|
}
|
|
@@ -1073,10 +1100,8 @@
|
|
|
1073
1100
|
MetricsManager::incrementCounter('challenges_solved_total');
|
|
1074
1101
|
$this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
|
|
1075
1102
|
|
|
1076
|
-
$isHttps = (
|
|
1077
|
-
|
|
1078
|
-
($context->getHeader('x-forwarded-proto') === 'https');
|
|
1079
|
-
$secureOption = $isHttps || $this->isProduction;
|
|
1103
|
+
$isHttps = !empty($context->isHttps);
|
|
1104
|
+
$secureOption = $isHttps;
|
|
1080
1105
|
|
|
1081
1106
|
return [
|
|
1082
1107
|
'action' => 'redirect',
|