@prosopo/provider 3.3.0 → 3.12.3

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 (52) hide show
  1. package/CHANGELOG.md +545 -0
  2. package/dist/api/admin/apiRemoveDetectorKeyEndpoint.js +7 -4
  3. package/dist/api/blacklistRequestInspector.js +11 -9
  4. package/dist/api/captcha.js +121 -33
  5. package/dist/api/domainMiddleware.js +8 -8
  6. package/dist/api/headerCheckMiddleware.js +4 -0
  7. package/dist/api/ignoreMiddleware.js +4 -1
  8. package/dist/api/ja4Middleware.js +5 -23
  9. package/dist/api/public.js +26 -3
  10. package/dist/api/verify.js +4 -2
  11. package/dist/cjs/api/admin/apiRemoveDetectorKeyEndpoint.cjs +6 -3
  12. package/dist/cjs/api/blacklistRequestInspector.cjs +11 -9
  13. package/dist/cjs/api/captcha.cjs +121 -33
  14. package/dist/cjs/api/domainMiddleware.cjs +8 -8
  15. package/dist/cjs/api/headerCheckMiddleware.cjs +4 -0
  16. package/dist/cjs/api/ignoreMiddleware.cjs +3 -0
  17. package/dist/cjs/api/ja4Middleware.cjs +4 -22
  18. package/dist/cjs/api/public.cjs +26 -3
  19. package/dist/cjs/api/verify.cjs +4 -2
  20. package/dist/cjs/compositeIpAddress.cjs +53 -0
  21. package/dist/cjs/index.cjs +7 -0
  22. package/dist/cjs/pairs.cjs +27 -0
  23. package/dist/cjs/services/ipComparison.cjs +123 -0
  24. package/dist/cjs/services/ipInfo.cjs +87 -0
  25. package/dist/cjs/tasks/captchaManager.cjs +49 -2
  26. package/dist/cjs/tasks/client/clientTasks.cjs +33 -12
  27. package/dist/cjs/tasks/detection/decodePayload.cjs +712 -278
  28. package/dist/cjs/tasks/detection/getBotScore.cjs +14 -3
  29. package/dist/cjs/tasks/frictionless/frictionlessTasks.cjs +128 -24
  30. package/dist/cjs/tasks/frictionless/frictionlessTasksUtils.cjs +17 -0
  31. package/dist/cjs/tasks/imgCaptcha/imgCaptchaTasks.cjs +62 -20
  32. package/dist/cjs/tasks/powCaptcha/powTasks.cjs +43 -15
  33. package/dist/cjs/util.cjs +248 -16
  34. package/dist/cjs/utils/hashUserAgent.cjs +10 -0
  35. package/dist/compositeIpAddress.js +53 -0
  36. package/dist/index.js +8 -1
  37. package/dist/pairs.js +27 -0
  38. package/dist/services/ipComparison.js +123 -0
  39. package/dist/services/ipInfo.js +87 -0
  40. package/dist/tasks/captchaManager.js +49 -2
  41. package/dist/tasks/client/clientTasks.js +33 -12
  42. package/dist/tasks/detection/decodePayload.js +712 -278
  43. package/dist/tasks/detection/getBotScore.js +15 -4
  44. package/dist/tasks/frictionless/frictionlessTasks.js +128 -24
  45. package/dist/tasks/frictionless/frictionlessTasksUtils.js +18 -1
  46. package/dist/tasks/imgCaptcha/imgCaptchaTasks.js +64 -22
  47. package/dist/tasks/powCaptcha/powTasks.js +44 -16
  48. package/dist/util.js +249 -17
  49. package/dist/utils/hashUserAgent.js +10 -0
  50. package/package.json +26 -23
  51. package/vite.test.config.ts +3 -2
  52. package/vite.threads.test.config.ts +33 -0
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const geolib = require("geolib");
4
+ const ipInfo = require("./ipInfo.cjs");
5
+ async function compareIPs(ip1, ip2, apiKey, apiUrl) {
6
+ try {
7
+ if (!ip1 || !ip2 || typeof ip1 !== "string" || typeof ip2 !== "string") {
8
+ return {
9
+ error: "Invalid IP addresses provided",
10
+ ip1: ip1 || "undefined",
11
+ ip2: ip2 || "undefined"
12
+ };
13
+ }
14
+ if (ip1 === ip2) {
15
+ return {
16
+ ipsMatch: true,
17
+ ip1,
18
+ ip2
19
+ };
20
+ }
21
+ const [ip1Info, ip2Info] = await Promise.all([
22
+ ipInfo.getIPInfo(ip1, apiUrl, apiKey),
23
+ ipInfo.getIPInfo(ip2, apiUrl, apiKey)
24
+ ]);
25
+ if (!ip1Info.isValid && !ip2Info.isValid) {
26
+ return {
27
+ error: "Failed to lookup both IP addresses",
28
+ ip1,
29
+ ip2,
30
+ ip1Error: ip1Info.error,
31
+ ip2Error: ip2Info.error
32
+ };
33
+ }
34
+ if (!ip1Info.isValid) {
35
+ return {
36
+ error: "Failed to lookup first IP address",
37
+ ip1,
38
+ ip2,
39
+ ip1Error: ip1Info.error
40
+ };
41
+ }
42
+ if (!ip2Info.isValid) {
43
+ return {
44
+ error: "Failed to lookup second IP address",
45
+ ip1,
46
+ ip2,
47
+ ip2Error: ip2Info.error
48
+ };
49
+ }
50
+ const determineConnectionType = (ipInfo2) => {
51
+ if (ipInfo2.isMobile) return "mobile";
52
+ if (ipInfo2.isDatacenter) return "datacenter";
53
+ if (ipInfo2.isSatellite) return "satellite";
54
+ if (ipInfo2.providerType === "isp") return "residential";
55
+ switch (ipInfo2.providerType) {
56
+ case "hosting":
57
+ return "datacenter";
58
+ case "business":
59
+ case "education":
60
+ case "government":
61
+ case "banking":
62
+ return "residential";
63
+ default:
64
+ return "unknown";
65
+ }
66
+ };
67
+ const ip1ConnectionType = determineConnectionType(ip1Info);
68
+ const ip2ConnectionType = determineConnectionType(ip2Info);
69
+ const differentConnectionTypes = ip1ConnectionType !== ip2ConnectionType;
70
+ const ip1Provider = ip1Info.providerName || ip1Info.asnOrganization || "Unknown";
71
+ const ip2Provider = ip2Info.providerName || ip2Info.asnOrganization || "Unknown";
72
+ const differentProviders = ip1Provider !== ip2Provider;
73
+ let distanceKm;
74
+ if (ip1Info.latitude !== void 0 && ip1Info.longitude !== void 0 && ip2Info.latitude !== void 0 && ip2Info.longitude !== void 0) {
75
+ const distanceMeters = geolib.getDistance(
76
+ { latitude: ip1Info.latitude, longitude: ip1Info.longitude },
77
+ { latitude: ip2Info.latitude, longitude: ip2Info.longitude }
78
+ );
79
+ distanceKm = distanceMeters / 1e3;
80
+ }
81
+ const ip1IsVpnOrProxy = ip1Info.isVPN || ip1Info.isProxy || ip1Info.isTor;
82
+ const ip2IsVpnOrProxy = ip2Info.isVPN || ip2Info.isProxy || ip2Info.isTor;
83
+ const anyVpnOrProxy = ip1IsVpnOrProxy || ip2IsVpnOrProxy;
84
+ const ip1Coordinates = ip1Info.latitude !== void 0 && ip1Info.longitude !== void 0 ? { latitude: ip1Info.latitude, longitude: ip1Info.longitude } : void 0;
85
+ const ip2Coordinates = ip2Info.latitude !== void 0 && ip2Info.longitude !== void 0 ? { latitude: ip2Info.latitude, longitude: ip2Info.longitude } : void 0;
86
+ return {
87
+ ipsMatch: false,
88
+ ip1,
89
+ ip2,
90
+ comparison: {
91
+ differentProviders,
92
+ differentConnectionTypes,
93
+ distanceKm,
94
+ anyVpnOrProxy,
95
+ ip1Details: {
96
+ provider: ip1Provider,
97
+ connectionType: ip1ConnectionType,
98
+ isVpnOrProxy: ip1IsVpnOrProxy,
99
+ country: ip1Info.country,
100
+ countryCode: ip1Info.countryCode,
101
+ city: ip1Info.city,
102
+ coordinates: ip1Coordinates
103
+ },
104
+ ip2Details: {
105
+ provider: ip2Provider,
106
+ connectionType: ip2ConnectionType,
107
+ isVpnOrProxy: ip2IsVpnOrProxy,
108
+ country: ip2Info.country,
109
+ countryCode: ip2Info.countryCode,
110
+ city: ip2Info.city,
111
+ coordinates: ip2Coordinates
112
+ }
113
+ }
114
+ };
115
+ } catch (error) {
116
+ return {
117
+ error: `Comparison failed: ${error instanceof Error ? error.message : String(error)}`,
118
+ ip1,
119
+ ip2
120
+ };
121
+ }
122
+ }
123
+ exports.compareIPs = compareIPs;
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ async function getIPInfo(ip, apiUrl, apiKey, includeRawResponse = false) {
4
+ try {
5
+ if (!ip || typeof ip !== "string") {
6
+ return {
7
+ isValid: false,
8
+ error: "Invalid IP address provided",
9
+ ip: ip || "undefined"
10
+ };
11
+ }
12
+ const url = apiUrl;
13
+ const body = { q: ip };
14
+ if (apiKey) {
15
+ body.key = apiKey;
16
+ }
17
+ const response = await fetch(url, {
18
+ method: "POST",
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ Accept: "application/json"
22
+ },
23
+ body: JSON.stringify(body)
24
+ });
25
+ if (!response.ok) {
26
+ return {
27
+ isValid: false,
28
+ error: `API request failed with status ${response.status}: ${response.statusText}`,
29
+ ip
30
+ };
31
+ }
32
+ const data = await response.json();
33
+ if (data.is_bogon) {
34
+ return {
35
+ isValid: false,
36
+ error: "IP address is bogon (non-routable)",
37
+ ip
38
+ };
39
+ }
40
+ const result = {
41
+ ip: data.ip,
42
+ isValid: true,
43
+ // Threat indicators
44
+ isVPN: data.is_vpn,
45
+ isTor: data.is_tor,
46
+ isProxy: data.is_proxy,
47
+ isDatacenter: data.is_datacenter,
48
+ isAbuser: data.is_abuser,
49
+ isMobile: data.is_mobile,
50
+ isSatellite: data.is_satellite,
51
+ // Provider information
52
+ providerName: data.company?.name || data.datacenter?.datacenter,
53
+ providerType: data.company?.type || data.asn?.type,
54
+ asnNumber: data.asn?.asn,
55
+ asnOrganization: data.asn?.org,
56
+ // Geolocation
57
+ country: data.location?.country,
58
+ countryCode: data.location?.country_code,
59
+ region: data.location?.state,
60
+ city: data.location?.city,
61
+ latitude: data.location?.latitude,
62
+ longitude: data.location?.longitude,
63
+ timezone: data.location?.timezone,
64
+ // VPN specific details
65
+ vpnService: data.vpn?.service,
66
+ vpnType: data.vpn?.type,
67
+ // Risk scoring
68
+ abuserScore: Number.parseFloat(
69
+ data.asn?.abuser_score.split(" ")[0] || "0"
70
+ ),
71
+ companyAbuserScore: Number.parseFloat(
72
+ data.company?.abuser_score.split(" ")[0] || "0"
73
+ )
74
+ };
75
+ if (includeRawResponse) {
76
+ result.rawResponse = data;
77
+ }
78
+ return result;
79
+ } catch (error) {
80
+ return {
81
+ isValid: false,
82
+ error: `Network or parsing error: ${error instanceof Error ? error.message : String(error)}`,
83
+ ip
84
+ };
85
+ }
86
+ }
87
+ exports.getIPInfo = getIPInfo;
@@ -15,7 +15,20 @@ class CaptchaManager {
15
15
  );
16
16
  return tokenRecord ? tokenRecord._id : void 0;
17
17
  }
18
- async isValidRequest(clientSettings, requestedCaptchaType, sessionId, userAccessPolicy) {
18
+ async validateFrictionlessTokenIP(sessionRecord, currentIP, env) {
19
+ const tokenRecord = await this.db.getFrictionlessTokenRecordByTokenId(
20
+ sessionRecord.tokenId
21
+ );
22
+ if (!tokenRecord) {
23
+ this.logger.info(() => ({
24
+ msg: "No frictionless token found for session",
25
+ data: { sessionId: sessionRecord.sessionId }
26
+ }));
27
+ return { valid: false, reason: "CAPTCHA.NO_SESSION_FOUND" };
28
+ }
29
+ return { valid: true };
30
+ }
31
+ async isValidRequest(clientSettings, requestedCaptchaType, env, sessionId, userAccessPolicy, currentIP) {
19
32
  this.logger.debug(() => ({
20
33
  msg: "Validating request",
21
34
  data: {
@@ -54,11 +67,45 @@ class CaptchaManager {
54
67
  type: requestedCaptchaType
55
68
  };
56
69
  }
70
+ if (currentIP) {
71
+ const ipValidation = await this.validateFrictionlessTokenIP(
72
+ sessionRecord,
73
+ currentIP,
74
+ env
75
+ );
76
+ if (!ipValidation.valid) {
77
+ return {
78
+ valid: false,
79
+ reason: ipValidation.reason,
80
+ type: requestedCaptchaType
81
+ };
82
+ }
83
+ }
57
84
  const frictionlessTokenId = await this.getFrictionlessTokenIdFromSession(sessionRecord);
85
+ if (sessionRecord.captchaType !== requestedCaptchaType) {
86
+ this.logger.warn(() => ({
87
+ msg: "Invalid frictionless request",
88
+ data: {
89
+ account: clientSettings.account,
90
+ sessionId
91
+ }
92
+ }));
93
+ return {
94
+ valid: false,
95
+ reason: "CAPTCHA.NO_SESSION_FOUND",
96
+ type: requestedCaptchaType
97
+ };
98
+ }
58
99
  return {
59
100
  valid: true,
60
101
  frictionlessTokenId,
61
- type: requestedCaptchaType
102
+ type: requestedCaptchaType,
103
+ ...sessionRecord.powDifficulty && {
104
+ powDifficulty: sessionRecord.powDifficulty
105
+ },
106
+ ...sessionRecord.solvedImagesCount && {
107
+ solvedImagesCount: sessionRecord.solvedImagesCount
108
+ }
62
109
  };
63
110
  }
64
111
  this.logger.warn(() => ({
@@ -126,11 +126,10 @@ class ClientTaskManager {
126
126
  threshold: 0
127
127
  };
128
128
  }
129
+ const { _id, token, ...tokenRecordWithoutId } = tokenRecord;
129
130
  return {
130
131
  ...record,
131
- score: tokenRecord?.score || 0,
132
- scoreComponents: tokenRecord?.scoreComponents,
133
- threshold: tokenRecord?.threshold || 0
132
+ ...tokenRecordWithoutId
134
133
  };
135
134
  });
136
135
  if (filteredBatch.length > 0) {
@@ -138,6 +137,9 @@ class ClientTaskManager {
138
137
  await this.providerDB.markSessionRecordsStored(
139
138
  filteredBatch.map((record) => record.sessionId)
140
139
  );
140
+ await this.providerDB.markFrictionlessTokenRecordsStored(
141
+ filteredBatch.map((record) => record.tokenId).filter((id) => !!id)
142
+ );
141
143
  }
142
144
  processedSessionRecords += filteredBatch.length;
143
145
  }
@@ -248,25 +250,44 @@ class ClientTaskManager {
248
250
  const activeDetectorKeys = await this.providerDB.getDetectorKeys();
249
251
  return activeDetectorKeys;
250
252
  }
251
- async removeDetectorKey(detectorKey) {
253
+ async removeDetectorKey(detectorKey, expirationInSeconds) {
252
254
  if (!isValidPrivateKey(detectorKey)) {
253
255
  throw new common.ProsopoApiError("INVALID_DETECTOR_KEY", {
254
256
  context: { detectorKey },
255
257
  logger: this.logger
256
258
  });
257
259
  }
258
- await this.providerDB.removeDetectorKey(detectorKey);
260
+ await this.providerDB.removeDetectorKey(detectorKey, expirationInSeconds);
259
261
  }
260
- isSubdomainOrExactMatch(referrer, clientDomain) {
262
+ /**
263
+ * Matches a request referrer against an allowed domain pattern.
264
+ * Supports global '*', subdomain '*.example.com', glob '*example*',
265
+ * plain domains (exact or subdomain), and 'localhost'.
266
+ */
267
+ domainPatternMatcher(referrer, clientDomain) {
261
268
  if (!referrer || !clientDomain) return false;
262
- if (clientDomain === "*") return true;
263
269
  try {
264
- const referrerDomain = util.parseUrl(referrer).hostname.replace(/\.$/, "");
265
- const allowedDomain = util.parseUrl(clientDomain).hostname.replace(/\.$/, "");
266
- return referrerDomain === allowedDomain || referrerDomain.endsWith(`.${allowedDomain}`);
267
- } catch {
270
+ const referrerHost = util.parseUrl(referrer).hostname.replace(/\.$/, "");
271
+ const pattern = clientDomain.trim().toLowerCase();
272
+ if (pattern === "*") return true;
273
+ if (pattern === "localhost") {
274
+ return referrerHost === "localhost" || referrerHost.startsWith("localhost:");
275
+ }
276
+ if (pattern.startsWith("*.")) {
277
+ const suffix = pattern.slice(2);
278
+ const allowed = util.parseUrl(suffix).hostname.replace(/\.$/, "");
279
+ return referrerHost.endsWith(`.${allowed}`) || referrerHost === allowed;
280
+ }
281
+ if (pattern.includes("*")) {
282
+ const escaped = pattern.replace(/[.+?^${}()|\[\]\\]/g, "\\$&").replace(/\*/g, ".*");
283
+ const regex = new RegExp(`^${escaped}$`, "i");
284
+ return regex.test(referrerHost);
285
+ }
286
+ const allowedHost = util.parseUrl(pattern).hostname.replace(/\.$/, "");
287
+ return referrerHost === allowedHost || referrerHost.endsWith(`.${allowedHost}`);
288
+ } catch (e) {
268
289
  this.logger.error(() => ({
269
- msg: "Error in isSubdomainOrExactMatch",
290
+ msg: "Error in domainPatternMatcher",
270
291
  data: { referrer, clientDomain }
271
292
  }));
272
293
  return false;