@anomira/node-sdk 0.2.3 → 0.2.5

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/dist/index.d.cts CHANGED
@@ -148,11 +148,17 @@ declare const EventName: {
148
148
  readonly SQL_ERROR: "db.sql.error";
149
149
  readonly FIREWALL_BLOCK: "http.firewall.block";
150
150
  readonly FIREWALL_FLAG: "http.firewall.flag";
151
+ readonly FIREWALL_RATE_LIMIT: "http.firewall.rate_limit";
152
+ readonly FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot";
153
+ readonly FIREWALL_TAG: "http.firewall.tag";
154
+ readonly FIREWALL_MONITOR: "http.firewall.monitor";
155
+ readonly FIREWALL_CHALLENGE: "http.firewall.challenge";
156
+ readonly FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block";
151
157
  };
152
158
  type EventNameValue = typeof EventName[keyof typeof EventName];
153
159
  type FirewallField = "url" | "body" | "header" | "user_agent" | "ip";
154
160
  type FirewallOperator = "contains" | "equals" | "starts_with" | "ends_with" | "regex";
155
- type FirewallAction = "block" | "flag";
161
+ type FirewallAction = "block" | "flag" | "rate_limit" | "challenge" | "redirect_honeypot" | "tag" | "monitor" | "temporary_block";
156
162
  interface EndpointDeclaration {
157
163
  /** HTTP method, e.g. "GET". Use "*" to match any method. */
158
164
  method: string;
@@ -169,6 +175,8 @@ interface FirewallRule {
169
175
  value: string;
170
176
  action: FirewallAction;
171
177
  attackType: string;
178
+ redirectTarget?: string | null;
179
+ tempBlockMins?: number | null;
172
180
  }
173
181
 
174
182
  /**
@@ -228,13 +236,33 @@ declare class AnomiraClient {
228
236
  private readonly _origWarn;
229
237
  private readonly _origError;
230
238
  constructor(config: AnomiraConfig);
239
+ /**
240
+ * Extract the real client IP from a request object, reading forwarded headers
241
+ * in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
242
+ *
243
+ * Use this instead of `req.ip` when tracking events manually. `req.ip` in
244
+ * Express without `trust proxy` set returns the proxy's address (127.0.0.1
245
+ * behind Nginx), which breaks geo-detection and IP blocking.
246
+ *
247
+ * ```ts
248
+ * // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
249
+ * sentinel.track(EventName.OTP_FAILED, {
250
+ * ip: sentinel.getClientIp(req),
251
+ * userId: req.body.phone,
252
+ * meta: { endpoint: "/api/verify-otp" },
253
+ * });
254
+ *
255
+ * // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
256
+ * sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
257
+ * ```
258
+ */
259
+ getClientIp(req: unknown): string;
231
260
  /**
232
261
  * Track a custom security event.
233
262
  *
234
263
  * ```ts
235
- * // Track a failed OTP attempt
236
264
  * sentinel.track(EventName.OTP_FAILED, {
237
- * ip: req.ip,
265
+ * ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
238
266
  * userId: req.body.phone,
239
267
  * meta: { endpoint: "/api/verify-otp", attempts: 3 },
240
268
  * });
package/dist/index.d.ts CHANGED
@@ -148,11 +148,17 @@ declare const EventName: {
148
148
  readonly SQL_ERROR: "db.sql.error";
149
149
  readonly FIREWALL_BLOCK: "http.firewall.block";
150
150
  readonly FIREWALL_FLAG: "http.firewall.flag";
151
+ readonly FIREWALL_RATE_LIMIT: "http.firewall.rate_limit";
152
+ readonly FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot";
153
+ readonly FIREWALL_TAG: "http.firewall.tag";
154
+ readonly FIREWALL_MONITOR: "http.firewall.monitor";
155
+ readonly FIREWALL_CHALLENGE: "http.firewall.challenge";
156
+ readonly FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block";
151
157
  };
152
158
  type EventNameValue = typeof EventName[keyof typeof EventName];
153
159
  type FirewallField = "url" | "body" | "header" | "user_agent" | "ip";
154
160
  type FirewallOperator = "contains" | "equals" | "starts_with" | "ends_with" | "regex";
155
- type FirewallAction = "block" | "flag";
161
+ type FirewallAction = "block" | "flag" | "rate_limit" | "challenge" | "redirect_honeypot" | "tag" | "monitor" | "temporary_block";
156
162
  interface EndpointDeclaration {
157
163
  /** HTTP method, e.g. "GET". Use "*" to match any method. */
158
164
  method: string;
@@ -169,6 +175,8 @@ interface FirewallRule {
169
175
  value: string;
170
176
  action: FirewallAction;
171
177
  attackType: string;
178
+ redirectTarget?: string | null;
179
+ tempBlockMins?: number | null;
172
180
  }
173
181
 
174
182
  /**
@@ -228,13 +236,33 @@ declare class AnomiraClient {
228
236
  private readonly _origWarn;
229
237
  private readonly _origError;
230
238
  constructor(config: AnomiraConfig);
239
+ /**
240
+ * Extract the real client IP from a request object, reading forwarded headers
241
+ * in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
242
+ *
243
+ * Use this instead of `req.ip` when tracking events manually. `req.ip` in
244
+ * Express without `trust proxy` set returns the proxy's address (127.0.0.1
245
+ * behind Nginx), which breaks geo-detection and IP blocking.
246
+ *
247
+ * ```ts
248
+ * // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
249
+ * sentinel.track(EventName.OTP_FAILED, {
250
+ * ip: sentinel.getClientIp(req),
251
+ * userId: req.body.phone,
252
+ * meta: { endpoint: "/api/verify-otp" },
253
+ * });
254
+ *
255
+ * // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
256
+ * sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
257
+ * ```
258
+ */
259
+ getClientIp(req: unknown): string;
231
260
  /**
232
261
  * Track a custom security event.
233
262
  *
234
263
  * ```ts
235
- * // Track a failed OTP attempt
236
264
  * sentinel.track(EventName.OTP_FAILED, {
237
- * ip: req.ip,
265
+ * ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
238
266
  * userId: req.body.phone,
239
267
  * meta: { endpoint: "/api/verify-otp", attempts: 3 },
240
268
  * });
package/dist/index.js CHANGED
@@ -400,7 +400,13 @@ var EventName = {
400
400
  SQL_ERROR: "db.sql.error",
401
401
  // Firewall (emitted when a custom request filtering rule fires)
402
402
  FIREWALL_BLOCK: "http.firewall.block",
403
- FIREWALL_FLAG: "http.firewall.flag"
403
+ FIREWALL_FLAG: "http.firewall.flag",
404
+ FIREWALL_RATE_LIMIT: "http.firewall.rate_limit",
405
+ FIREWALL_REDIRECT_HONEYPOT: "http.firewall.redirect_honeypot",
406
+ FIREWALL_TAG: "http.firewall.tag",
407
+ FIREWALL_MONITOR: "http.firewall.monitor",
408
+ FIREWALL_CHALLENGE: "http.firewall.challenge",
409
+ FIREWALL_TEMP_BLOCK: "http.firewall.temporary_block"
404
410
  };
405
411
 
406
412
  // src/agent-detection.ts
@@ -517,6 +523,9 @@ var KNOWN_BOT_UA = [
517
523
  [/^Ruby$/i, "Ruby"]
518
524
  ];
519
525
  var AXIOS_ACCEPT = "application/json, text/plain, */*";
526
+ function isBrowserUa(ua) {
527
+ return /Mozilla\/5\.0/.test(ua) && /(?:Chrome|Firefox|Safari|OPR|Edg(?:e|HTML)?|Trident)\/[\d.]+/.test(ua);
528
+ }
520
529
  function computeBrowserFingerprint(headers, ua) {
521
530
  const signals = [];
522
531
  let score = 0;
@@ -533,10 +542,15 @@ function computeBrowserFingerprint(headers, ua) {
533
542
  }
534
543
  const accept = str(headers["accept"]);
535
544
  if (!isDefiniteBot && accept === AXIOS_ACCEPT) {
536
- isDefiniteBot = true;
537
- knownClient = "axios";
538
- score = 100;
539
- signals.push("known_accept:axios");
545
+ if (!isBrowserUa(ua)) {
546
+ isDefiniteBot = true;
547
+ knownClient = "axios";
548
+ score = 100;
549
+ signals.push("known_accept:axios");
550
+ } else {
551
+ score += 15;
552
+ signals.push("browser_axios_accept");
553
+ }
540
554
  }
541
555
  if (isDefiniteBot) return { score, signals, isDefiniteBot, knownClient };
542
556
  const hasSecFetchSite = "sec-fetch-site" in headers;
@@ -1143,6 +1157,108 @@ var DEFAULT_INGEST_URL = "https://ingest.anomira.io/v1/events";
1143
1157
  var DEFAULT_BATCH_SIZE = 100;
1144
1158
  var DEFAULT_FLUSH_MS = 5e3;
1145
1159
  var DEFAULT_MAX_RETRIES = 3;
1160
+ var SENSITIVE_FIELDS = {
1161
+ identity: [
1162
+ "first_name",
1163
+ "last_name",
1164
+ "full_name",
1165
+ "name",
1166
+ "bvn",
1167
+ "nin",
1168
+ "ssn",
1169
+ "national_id",
1170
+ "tin",
1171
+ "passport",
1172
+ "passport_number",
1173
+ "drivers_license",
1174
+ "license_number",
1175
+ "voter_id",
1176
+ "dob",
1177
+ "date_of_birth",
1178
+ "birth_date",
1179
+ "birthdate",
1180
+ "gender",
1181
+ "marital_status",
1182
+ "nationality"
1183
+ ],
1184
+ contact: [
1185
+ "email",
1186
+ "phone",
1187
+ "phone_number",
1188
+ "mobile",
1189
+ "mobile_number",
1190
+ "address",
1191
+ "street",
1192
+ "city",
1193
+ "state",
1194
+ "postal_code",
1195
+ "zip"
1196
+ ],
1197
+ financial: [
1198
+ "card_number",
1199
+ "credit_card",
1200
+ "debit_card",
1201
+ "cvv",
1202
+ "account_number",
1203
+ "bank_account",
1204
+ "routing_number",
1205
+ "sort_code",
1206
+ "iban",
1207
+ "swift",
1208
+ "wallet_id"
1209
+ ],
1210
+ authentication: [
1211
+ "password",
1212
+ "pin",
1213
+ "otp",
1214
+ "secret",
1215
+ "private_key",
1216
+ "api_key",
1217
+ "access_token",
1218
+ "refresh_token",
1219
+ "jwt",
1220
+ "bearer_token",
1221
+ "security_answer",
1222
+ "mother_maiden_name"
1223
+ ],
1224
+ biometric: [
1225
+ "fingerprint",
1226
+ "face_id",
1227
+ "iris",
1228
+ "voiceprint",
1229
+ "biometric"
1230
+ ],
1231
+ health: [
1232
+ "medical_record",
1233
+ "diagnosis",
1234
+ "blood_group",
1235
+ "insurance_id"
1236
+ ],
1237
+ fintech: [
1238
+ "kyc_id",
1239
+ "customer_id",
1240
+ "beneficiary_account",
1241
+ "transaction_pin",
1242
+ "wallet_balance",
1243
+ "bank_verification_number",
1244
+ "virtual_account",
1245
+ "monnify_account",
1246
+ "paystack_customer",
1247
+ "flutterwave_customer"
1248
+ ]
1249
+ };
1250
+ var FIELD_CATEGORY_MAP = /* @__PURE__ */ new Map();
1251
+ for (const [cat, fields] of Object.entries(SENSITIVE_FIELDS)) {
1252
+ for (const f of fields) FIELD_CATEGORY_MAP.set(f, cat);
1253
+ }
1254
+ var VALUE_PATTERNS = [
1255
+ { name: "email", category: "contact", re: /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ },
1256
+ { name: "bvn_nin", category: "identity", re: /^\d{11}$/ },
1257
+ { name: "card_number", category: "financial", re: /^\d{13,19}$/ },
1258
+ { name: "phone_ng", category: "contact", re: /^(?:\+234|0)[789][01]\d{8}$/ },
1259
+ { name: "jwt", category: "authentication", re: /^eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\./ },
1260
+ { name: "iban", category: "financial", re: /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/ }
1261
+ ];
1146
1262
  var AnomiraClient = class {
1147
1263
  constructor(config) {
1148
1264
  this.logBuffer = [];
@@ -1475,13 +1591,35 @@ var AnomiraClient = class {
1475
1591
  }
1476
1592
  }
1477
1593
  // ─── Public API ────────────────────────────────────────────────────────────
1594
+ /**
1595
+ * Extract the real client IP from a request object, reading forwarded headers
1596
+ * in the correct priority order (Cloudflare → XFF → X-Real-IP → socket).
1597
+ *
1598
+ * Use this instead of `req.ip` when tracking events manually. `req.ip` in
1599
+ * Express without `trust proxy` set returns the proxy's address (127.0.0.1
1600
+ * behind Nginx), which breaks geo-detection and IP blocking.
1601
+ *
1602
+ * ```ts
1603
+ * // ✅ Correct — reads X-Forwarded-For / CF-Connecting-IP automatically
1604
+ * sentinel.track(EventName.OTP_FAILED, {
1605
+ * ip: sentinel.getClientIp(req),
1606
+ * userId: req.body.phone,
1607
+ * meta: { endpoint: "/api/verify-otp" },
1608
+ * });
1609
+ *
1610
+ * // ❌ Wrong behind a proxy — req.ip is 127.0.0.1 without trust proxy configured
1611
+ * sentinel.track(EventName.OTP_FAILED, { ip: req.ip, ... });
1612
+ * ```
1613
+ */
1614
+ getClientIp(req) {
1615
+ return this.config.getIp(req);
1616
+ }
1478
1617
  /**
1479
1618
  * Track a custom security event.
1480
1619
  *
1481
1620
  * ```ts
1482
- * // Track a failed OTP attempt
1483
1621
  * sentinel.track(EventName.OTP_FAILED, {
1484
- * ip: req.ip,
1622
+ * ip: sentinel.getClientIp(req), // use getClientIp, not req.ip
1485
1623
  * userId: req.body.phone,
1486
1624
  * meta: { endpoint: "/api/verify-otp", attempts: 3 },
1487
1625
  * });
@@ -1554,6 +1692,11 @@ var AnomiraClient = class {
1554
1692
  if (this.disabled) return;
1555
1693
  const ctx = requestContext.getStore();
1556
1694
  const resolvedIp = data.ip && data.ip !== "0.0.0.0" && data.ip !== "" ? data.ip : ctx?.ip ?? "0.0.0.0";
1695
+ if (this.config.debug && data.ip && isPrivateIp2(data.ip)) {
1696
+ this._origWarn(
1697
+ `[Anomira] Warning: track() received a private/loopback IP "${data.ip}" for event "${eventName}". Behind a reverse proxy, req.ip is the proxy's address \u2014 use sentinel.getClientIp(req) instead.`
1698
+ );
1699
+ }
1557
1700
  const resolvedMeta = ctx?.endpoint && !data.meta?.["endpoint"] ? { endpoint: ctx.endpoint, method: ctx.method, ...data.meta } : data.meta;
1558
1701
  const event = {
1559
1702
  name: eventName,
@@ -1794,6 +1937,9 @@ function normalizeIp(raw) {
1794
1937
  if (raw.startsWith("::ffff:")) return raw.slice(7);
1795
1938
  return raw;
1796
1939
  }
1940
+ function isPrivateIp2(ip) {
1941
+ return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("10.") || ip.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
1942
+ }
1797
1943
  function defaultGetIp(req) {
1798
1944
  const r = req;
1799
1945
  const fwd = r["headers"];
@@ -1894,6 +2040,27 @@ function createExpressMiddleware(client) {
1894
2040
  const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
1895
2041
  const h2settings = extractHttp2Settings(req);
1896
2042
  const upstreamTls = extractUpstreamTls(headers ?? {});
2043
+ const cookieHeader = headers?.["cookie"];
2044
+ const cookieToken = cookieHeader ? cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith("anomira_fp="))?.slice("anomira_fp=".length) ?? "" : "";
2045
+ const browserFpRaw = cookieToken || headers?.["x-anomira-fp"] || "";
2046
+ let browserFp = null;
2047
+ if (browserFpRaw) {
2048
+ try {
2049
+ const raw = JSON.parse(Buffer.from(browserFpRaw, "base64").toString("utf8"));
2050
+ if (typeof raw["v"] === "number" && typeof raw["fp"] === "string") {
2051
+ browserFp = {
2052
+ fp: raw["fp"],
2053
+ bot: raw["bot"] ?? 0,
2054
+ sigs: (raw["sigs"] ?? []).join(","),
2055
+ uid: typeof raw["uid"] === "string" && raw["uid"] ? raw["uid"] : void 0,
2056
+ pst: raw["frm"]?.pst,
2057
+ ttf: raw["frm"]?.ttf,
2058
+ tts: raw["frm"]?.tts
2059
+ };
2060
+ }
2061
+ } catch {
2062
+ }
2063
+ }
1897
2064
  if (client["canaryTokenCache"] && client["canaryTokenCache"].size > 0) {
1898
2065
  const authHeader = (typeof headers?.["authorization"] === "string" ? headers["authorization"] : "") ?? "";
1899
2066
  if (authHeader.startsWith("Bearer ")) {
@@ -1988,11 +2155,39 @@ function createExpressMiddleware(client) {
1988
2155
  userId,
1989
2156
  meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
1990
2157
  });
1991
- if (fwMatch.rule.action === "block") {
1992
- const res_ = res;
1993
- if (typeof res_["status"] === "function") res_["status"](403);
1994
- else res_["statusCode"] = 403;
1995
- if (typeof res_["end"] === "function") res_["end"]('{"error":"Blocked by firewall rule"}');
2158
+ const res_ = res;
2159
+ const setStatus = (code) => {
2160
+ if (typeof res_["status"] === "function") res_["status"](code);
2161
+ else res_["statusCode"] = code;
2162
+ };
2163
+ const sendBody = (body) => {
2164
+ if (typeof res_["end"] === "function") res_["end"](body);
2165
+ };
2166
+ const setHeader = (k, v) => {
2167
+ if (typeof res_["setHeader"] === "function") res_["setHeader"](k, v);
2168
+ };
2169
+ const action = fwMatch.rule.action;
2170
+ if (action === "block" || action === "temporary_block") {
2171
+ setStatus(403);
2172
+ sendBody('{"error":"Blocked by firewall rule"}');
2173
+ return;
2174
+ }
2175
+ if (action === "rate_limit") {
2176
+ setStatus(429);
2177
+ setHeader("Retry-After", "60");
2178
+ sendBody('{"error":"Rate limit exceeded","retryAfter":60}');
2179
+ return;
2180
+ }
2181
+ if (action === "redirect_honeypot") {
2182
+ const target = fwMatch.rule.redirectTarget ?? "/.env";
2183
+ setStatus(302);
2184
+ setHeader("Location", target);
2185
+ sendBody("");
2186
+ return;
2187
+ }
2188
+ if (action === "challenge") {
2189
+ setStatus(403);
2190
+ sendBody('{"error":"Request challenge required"}');
1996
2191
  return;
1997
2192
  }
1998
2193
  }
@@ -2006,7 +2201,7 @@ function createExpressMiddleware(client) {
2006
2201
  }
2007
2202
  }
2008
2203
  const onFinish = () => {
2009
- const lateUserId = client.config.getUserId(req);
2204
+ const lateUserId = client.config.getUserId(req) || browserFp?.uid || "";
2010
2205
  if (!userIdWarnFired && userIdCheckCount < 20) {
2011
2206
  userIdCheckCount++;
2012
2207
  if (!lateUserId) userIdMissCount++;
@@ -2021,6 +2216,30 @@ function createExpressMiddleware(client) {
2021
2216
  const latencyMs = Date.now() - startMs;
2022
2217
  const getHeader = res["getHeader"];
2023
2218
  const bytes = typeof getHeader === "function" ? parseInt(getHeader.call(res, "content-length") ?? "0", 10) || 0 : 0;
2219
+ const rawBody = req["body"];
2220
+ const piiFields = [];
2221
+ const piiPatterns = [];
2222
+ const piiCatSet = /* @__PURE__ */ new Set();
2223
+ if (rawBody != null && typeof rawBody === "object" && !Array.isArray(rawBody)) {
2224
+ const body = rawBody;
2225
+ for (const key of Object.keys(body)) {
2226
+ const cat = FIELD_CATEGORY_MAP.get(key.toLowerCase());
2227
+ if (cat) {
2228
+ piiFields.push(key.toLowerCase());
2229
+ piiCatSet.add(cat);
2230
+ }
2231
+ }
2232
+ for (const val of Object.values(body)) {
2233
+ if (typeof val !== "string" || val.length > 200) continue;
2234
+ for (const { name, category, re } of VALUE_PATTERNS) {
2235
+ if (!piiPatterns.includes(name) && re.test(val)) {
2236
+ piiPatterns.push(name);
2237
+ piiCatSet.add(category);
2238
+ }
2239
+ }
2240
+ }
2241
+ }
2242
+ const piiCategories = piiCatSet.size > 0 ? [...piiCatSet] : void 0;
2024
2243
  client.track(EventName.REQUEST, {
2025
2244
  ip,
2026
2245
  userId: lateUserId,
@@ -2031,9 +2250,20 @@ function createExpressMiddleware(client) {
2031
2250
  latencyMs,
2032
2251
  userAgent: ua,
2033
2252
  bytes,
2253
+ ...piiFields.length > 0 ? { piiFields } : {},
2254
+ ...piiPatterns.length > 0 ? { piiPatterns } : {},
2255
+ ...piiCategories ? { piiCategories } : {},
2034
2256
  _fp: fingerprint.score,
2035
2257
  _fpSig: fingerprint.signals.join(","),
2036
2258
  ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
2259
+ ...browserFp ? {
2260
+ _bfp: browserFp.fp,
2261
+ _bbot: browserFp.bot,
2262
+ _bsigs: browserFp.sigs,
2263
+ ...browserFp.pst !== void 0 ? { _bpaste: browserFp.pst } : {},
2264
+ ...browserFp.ttf !== void 0 && browserFp.ttf >= 0 ? { _bttf: browserFp.ttf } : {},
2265
+ ...browserFp.tts !== void 0 && browserFp.tts >= 0 ? { _btts: browserFp.tts } : {}
2266
+ } : {},
2037
2267
  ...h2settings ? {
2038
2268
  _h2Window: h2settings.initialWindowSize,
2039
2269
  _h2HdrTable: h2settings.headerTableSize,
@@ -2161,14 +2391,32 @@ function createFastifyPlugin(client) {
2161
2391
  userId,
2162
2392
  meta: { url, method, ruleId: fwMatch.rule.id, attackType: fwMatch.rule.attackType }
2163
2393
  });
2164
- if (fwMatch.rule.action === "block") {
2165
- const rep = reply;
2166
- if (typeof rep["code"] === "function") {
2167
- const chained = rep["code"](403);
2168
- if (chained && typeof chained["send"] === "function") {
2169
- chained["send"]({ error: "Blocked by firewall rule" });
2170
- }
2171
- }
2394
+ const rep = reply;
2395
+ const replyCode = (code) => typeof rep["code"] === "function" ? rep["code"](code) : rep;
2396
+ const replyHeader = (k, v) => {
2397
+ if (typeof rep["header"] === "function") rep["header"](k, v);
2398
+ };
2399
+ const replySend = (chained, body) => {
2400
+ if (chained && typeof chained["send"] === "function") chained["send"](body);
2401
+ };
2402
+ const action = fwMatch.rule.action;
2403
+ if (action === "block" || action === "temporary_block") {
2404
+ replySend(replyCode(403), { error: "Blocked by firewall rule" });
2405
+ return;
2406
+ }
2407
+ if (action === "rate_limit") {
2408
+ replyHeader("Retry-After", "60");
2409
+ replySend(replyCode(429), { error: "Rate limit exceeded", retryAfter: 60 });
2410
+ return;
2411
+ }
2412
+ if (action === "redirect_honeypot") {
2413
+ const target = fwMatch.rule.redirectTarget ?? "/.env";
2414
+ replyHeader("Location", target);
2415
+ replySend(replyCode(302), "");
2416
+ return;
2417
+ }
2418
+ if (action === "challenge") {
2419
+ replySend(replyCode(403), { error: "Request challenge required" });
2172
2420
  return;
2173
2421
  }
2174
2422
  }
@@ -2183,11 +2431,11 @@ function createFastifyPlugin(client) {
2183
2431
  const ip = client.config.getIp(req);
2184
2432
  const url = req["url"] ?? "/";
2185
2433
  const method = req["method"]?.toUpperCase() ?? "GET";
2186
- const userId = client.config.getUserId(req);
2434
+ const serverUserId = client.config.getUserId(req);
2187
2435
  const status = reply["statusCode"] ?? 0;
2188
2436
  if (!userIdWarnFired && userIdCheckCount < 20) {
2189
2437
  userIdCheckCount++;
2190
- if (!userId) userIdMissCount++;
2438
+ if (!serverUserId) userIdMissCount++;
2191
2439
  if (userIdCheckCount === 20 && userIdMissCount >= 18) {
2192
2440
  userIdWarnFired = true;
2193
2441
  console.warn(
@@ -2202,6 +2450,28 @@ function createFastifyPlugin(client) {
2202
2450
  const fingerprint = computeBrowserFingerprint(headers ?? {}, ua);
2203
2451
  const h2settings = extractHttp2Settings(req);
2204
2452
  const upstreamTls = extractUpstreamTls(headers ?? {});
2453
+ const cookieHdrF = headers?.["cookie"];
2454
+ const cookieTokF = cookieHdrF ? cookieHdrF.split(";").map((c) => c.trim()).find((c) => c.startsWith("anomira_fp="))?.slice("anomira_fp=".length) ?? "" : "";
2455
+ const bfpRawF = cookieTokF || headers?.["x-anomira-fp"] || "";
2456
+ let browserFpF = null;
2457
+ if (bfpRawF) {
2458
+ try {
2459
+ const raw = JSON.parse(Buffer.from(bfpRawF, "base64").toString("utf8"));
2460
+ if (typeof raw["v"] === "number" && typeof raw["fp"] === "string") {
2461
+ browserFpF = {
2462
+ fp: raw["fp"],
2463
+ bot: raw["bot"] ?? 0,
2464
+ sigs: (raw["sigs"] ?? []).join(","),
2465
+ uid: typeof raw["uid"] === "string" && raw["uid"] ? raw["uid"] : void 0,
2466
+ pst: raw["frm"]?.pst,
2467
+ ttf: raw["frm"]?.ttf,
2468
+ tts: raw["frm"]?.tts
2469
+ };
2470
+ }
2471
+ } catch {
2472
+ }
2473
+ }
2474
+ const userId = serverUserId || browserFpF?.uid || "";
2205
2475
  client.track(EventName.REQUEST, {
2206
2476
  ip,
2207
2477
  userId,
@@ -2215,6 +2485,14 @@ function createFastifyPlugin(client) {
2215
2485
  _fp: fingerprint.score,
2216
2486
  _fpSig: fingerprint.signals.join(","),
2217
2487
  ...fingerprint.knownClient ? { _fpClient: fingerprint.knownClient } : {},
2488
+ ...browserFpF ? {
2489
+ _bfp: browserFpF.fp,
2490
+ _bbot: browserFpF.bot,
2491
+ _bsigs: browserFpF.sigs,
2492
+ ...browserFpF.pst !== void 0 ? { _bpaste: browserFpF.pst } : {},
2493
+ ...browserFpF.ttf !== void 0 && browserFpF.ttf >= 0 ? { _bttf: browserFpF.ttf } : {},
2494
+ ...browserFpF.tts !== void 0 && browserFpF.tts >= 0 ? { _btts: browserFpF.tts } : {}
2495
+ } : {},
2218
2496
  ...h2settings ? {
2219
2497
  _h2Window: h2settings.initialWindowSize,
2220
2498
  _h2HdrTable: h2settings.headerTableSize,