@prosopo/user-access-policy 3.8.1 → 3.9.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.
Files changed (63) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -8
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +9 -9
  4. package/CHANGELOG.md +33 -0
  5. package/dist/api/read/fetchRules.d.ts +1 -30
  6. package/dist/api/read/fetchRules.d.ts.map +1 -1
  7. package/dist/api/read/fetchRules.js.map +1 -1
  8. package/dist/api/write/insertRules.d.ts +2 -2
  9. package/dist/api/write/insertRules.d.ts.map +1 -1
  10. package/dist/api/write/insertRules.js.map +1 -1
  11. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +2 -1
  12. package/dist/cjs/redis/reader/redisAggregate.cjs +22 -4
  13. package/dist/cjs/redis/reader/redisRulesReader.cjs +22 -27
  14. package/dist/cjs/ruleInput/policyInput.cjs +5 -1
  15. package/dist/cjs/ruleInput/userScopeInput.cjs +1 -1
  16. package/dist/cjs/transformRule.cjs +2 -1
  17. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  18. package/dist/mongoose/mongooseRuleSchema.js +2 -1
  19. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  20. package/dist/redis/reader/redisAggregate.d.ts +1 -1
  21. package/dist/redis/reader/redisAggregate.d.ts.map +1 -1
  22. package/dist/redis/reader/redisAggregate.js +22 -4
  23. package/dist/redis/reader/redisAggregate.js.map +1 -1
  24. package/dist/redis/reader/redisRulesReader.d.ts +1 -0
  25. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  26. package/dist/redis/reader/redisRulesReader.js +24 -29
  27. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  28. package/dist/redis/redisClient.d.ts +2 -2
  29. package/dist/redis/redisClient.d.ts.map +1 -1
  30. package/dist/redis/redisClient.js.map +1 -1
  31. package/dist/rule.d.ts +1 -0
  32. package/dist/rule.d.ts.map +1 -1
  33. package/dist/ruleInput/policyInput.d.ts +3 -0
  34. package/dist/ruleInput/policyInput.d.ts.map +1 -1
  35. package/dist/ruleInput/policyInput.js +5 -1
  36. package/dist/ruleInput/policyInput.js.map +1 -1
  37. package/dist/ruleInput/ruleInput.d.ts +3 -16
  38. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  39. package/dist/ruleInput/ruleInput.js.map +1 -1
  40. package/dist/ruleInput/userScopeInput.js +2 -2
  41. package/dist/ruleInput/userScopeInput.js.map +1 -1
  42. package/dist/tests/redis/redisRulesStorage.integration.test.js +31 -0
  43. package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
  44. package/dist/tests/transformRule.unit.test.js +45 -1
  45. package/dist/tests/transformRule.unit.test.js.map +1 -1
  46. package/dist/transformRule.d.ts.map +1 -1
  47. package/dist/transformRule.js +3 -2
  48. package/dist/transformRule.js.map +1 -1
  49. package/package.json +3 -3
  50. package/src/api/read/fetchRules.ts +10 -2
  51. package/src/api/write/insertRules.ts +4 -2
  52. package/src/mongoose/mongooseRuleSchema.ts +1 -0
  53. package/src/redis/reader/redisAggregate.ts +27 -1
  54. package/src/redis/reader/redisRulesReader.ts +42 -40
  55. package/src/redis/redisClient.ts +7 -2
  56. package/src/rule.ts +12 -0
  57. package/src/ruleInput/policyInput.ts +12 -1
  58. package/src/ruleInput/ruleInput.ts +11 -6
  59. package/src/ruleInput/userScopeInput.ts +7 -7
  60. package/src/tests/redis/redisRulesStorage.integration.test.ts +52 -0
  61. package/src/tests/transformRule.unit.test.ts +68 -1
  62. package/src/transformRule.ts +7 -2
  63. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/user-access-policy",
3
- "version": "3.8.1",
3
+ "version": "3.9.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^24",
@@ -43,12 +43,12 @@
43
43
  "test": "npm run test:unit && npm run test:integration"
44
44
  },
45
45
  "dependencies": {
46
- "@prosopo/api": "3.4.10",
46
+ "@prosopo/api": "3.4.11",
47
47
  "@prosopo/api-route": "2.6.46",
48
48
  "@prosopo/common": "3.1.38",
49
49
  "@prosopo/logger": "1.0.2",
50
50
  "@prosopo/redis-client": "1.0.23",
51
- "@prosopo/types": "4.4.0",
51
+ "@prosopo/types": "4.4.1",
52
52
  "@prosopo/util": "3.2.15",
53
53
  "@redis/search": "5.0.0",
54
54
  "cidr-calc": "1.0.4",
@@ -36,9 +36,17 @@ export type FetchRulesResponse = {
36
36
  ruleEntries: AccessRuleEntry[];
37
37
  };
38
38
 
39
- export const fetchRulesResponse = z.object({
39
+ // Explicit annotation with `unknown` input position rather than the
40
+ // strict identity form because `ruleEntryInput.rule` transitively uses
41
+ // `z.preprocess` on `deferToVerify`. Required for portable declaration
42
+ // emit; the `AllKeys<...>` constraint still catches missing fields.
43
+ export const fetchRulesResponse: ZodType<
44
+ FetchRulesResponse,
45
+ z.ZodTypeDef,
46
+ unknown
47
+ > = z.object({
40
48
  ruleEntries: ruleEntryInput.array(),
41
- } satisfies AllKeys<FetchRulesResponse>) satisfies ZodType<FetchRulesResponse>;
49
+ } satisfies AllKeys<FetchRulesResponse>);
42
50
 
43
51
  export type FetchRulesEndpointResponse = ApiEndpointResponse & {
44
52
  data?: FetchRulesResponse;
@@ -19,7 +19,7 @@ import {
19
19
  } from "@prosopo/api-route";
20
20
  import type { AllKeys } from "@prosopo/common";
21
21
  import { LogLevel, type Logger } from "@prosopo/logger";
22
- import { type ZodType, z } from "zod";
22
+ import { type ZodType, type ZodTypeDef, z } from "zod";
23
23
  import type {
24
24
  AccessPolicy,
25
25
  AccessRule,
@@ -56,7 +56,9 @@ type ParsedInsertRulesGroup = InsertRulesGroup & {
56
56
 
57
57
  type ParsedInsertRuleGroups = ParsedInsertRulesGroup[];
58
58
 
59
- type InsertRulesSchema = ZodType<InsertRulesGroup[]>;
59
+ // Input position widened to `unknown` because `accessPolicyInput` uses
60
+ // `z.preprocess` on `deferToVerify` for Redis string round-tripping.
61
+ type InsertRulesSchema = ZodType<InsertRulesGroup[], ZodTypeDef, unknown>;
60
62
 
61
63
  export class InsertRulesEndpoint implements ApiEndpoint<InsertRulesSchema> {
62
64
  public constructor(
@@ -56,6 +56,7 @@ const accessPolicySchema: SchemaDefinition<AccessPolicy> = {
56
56
  powDifficulty: { type: Number, required: false },
57
57
  unsolvedImagesCount: { type: Number, required: false },
58
58
  frictionlessScore: { type: Number, required: false },
59
+ deferToVerify: { type: Boolean, required: false },
59
60
  } satisfies AllKeys<AccessPolicy>;
60
61
 
61
62
  export const accessRuleMongooseSchema: SchemaDefinition<AccessRuleRecord> = {
@@ -29,6 +29,7 @@ export const aggregateRedisKeys = async (
29
29
  query: string,
30
30
  logger: Logger,
31
31
  batchHandler?: (keys: string[]) => Promise<void>,
32
+ maxKeys?: number,
32
33
  ): Promise<string[]> => {
33
34
  const keyField = "__key";
34
35
 
@@ -38,8 +39,13 @@ export const aggregateRedisKeys = async (
38
39
  });
39
40
 
40
41
  const foundKeys: string[] = [];
42
+ let stopRequested = false;
41
43
 
42
44
  const addRecordKeys = async (records: object[]) => {
45
+ if (stopRequested) {
46
+ return;
47
+ }
48
+
43
49
  const parsedRecords = parseRedisRecords(records, recordSchema, logger);
44
50
 
45
51
  const recordKeys = parsedRecords.map((record) => record[keyField]);
@@ -47,6 +53,24 @@ export const aggregateRedisKeys = async (
47
53
  if (batchHandler) {
48
54
  await batchHandler(recordKeys);
49
55
  } else {
56
+ if (
57
+ maxKeys !== undefined &&
58
+ foundKeys.length + recordKeys.length > maxKeys
59
+ ) {
60
+ const remaining = Math.max(0, maxKeys - foundKeys.length);
61
+ foundKeys.push(...recordKeys.slice(0, remaining));
62
+ stopRequested = true;
63
+
64
+ logger.warn(() => ({
65
+ msg: "Redis aggregation candidate cap hit; truncating result set. This can suppress less-frequent rules and should be investigated.",
66
+ data: {
67
+ maxKeys,
68
+ query,
69
+ },
70
+ }));
71
+ return;
72
+ }
73
+
50
74
  foundKeys.push(...recordKeys);
51
75
 
52
76
  logger.debug(() => ({
@@ -68,6 +92,7 @@ export const aggregateRedisKeys = async (
68
92
  LOAD: `@${keyField}`,
69
93
  },
70
94
  addRecordKeys,
95
+ () => stopRequested,
71
96
  );
72
97
 
73
98
  return foundKeys;
@@ -78,6 +103,7 @@ const executeAggregation = async (
78
103
  query: string,
79
104
  aggregateOptions: FtAggregateWithCursorOptions,
80
105
  handleBatch: (records: object[]) => Promise<void>,
106
+ shouldStop?: () => boolean,
81
107
  ): Promise<void> => {
82
108
  const initialReply = await client.ft.aggregateWithCursor(
83
109
  ACCESS_RULES_REDIS_INDEX_NAME,
@@ -89,7 +115,7 @@ const executeAggregation = async (
89
115
 
90
116
  let cursor = initialReply.cursor;
91
117
 
92
- while (0 !== cursor) {
118
+ while (0 !== cursor && !shouldStop?.()) {
93
119
  const batchReply = await client.ft.cursorRead(
94
120
  ACCESS_RULES_REDIS_INDEX_NAME,
95
121
  cursor,
@@ -16,20 +16,14 @@ import * as util from "node:util";
16
16
  import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
17
17
  import type { Logger } from "@prosopo/logger";
18
18
  import type { RedisClientType } from "redis";
19
- import {
20
- REDIS_QUERY_DIALECT,
21
- getRulesRedisQuery,
22
- } from "#policy/redis/reader/redisRulesQuery.js";
19
+ import { getRulesRedisQuery } from "#policy/redis/reader/redisRulesQuery.js";
23
20
  import {
24
21
  REDIS_BATCH_SIZE,
25
22
  fetchRedisHashRecords,
26
23
  getMissingRedisKeys,
27
24
  parseRedisRecords,
28
25
  } from "#policy/redis/redisClient.js";
29
- import {
30
- ACCESS_RULES_REDIS_INDEX_NAME,
31
- ACCESS_RULE_REDIS_KEY_PREFIX,
32
- } from "#policy/redis/redisRuleIndex.js";
26
+ import { ACCESS_RULE_REDIS_KEY_PREFIX } from "#policy/redis/redisRuleIndex.js";
33
27
  import type { AccessRule } from "#policy/rule.js";
34
28
  import { accessRuleInput } from "#policy/ruleInput/ruleInput.js";
35
29
  import type {
@@ -39,6 +33,15 @@ import type {
39
33
  } from "#policy/rulesStorage.js";
40
34
  import { aggregateRedisKeys } from "./redisAggregate.js";
41
35
 
36
+ // Verify-time lookup safety cap. The greedy `userScopeMatch` OR-query can match
37
+ // thousands of candidates on bot-attack-scale accounts; we still need to bring
38
+ // every candidate back so JS-side specificity ranking can pick the right rule,
39
+ // but a hard cap prevents a pathological match (e.g. an attacker crafting a
40
+ // userScope that hits the entire account's rule index) from spiralling the
41
+ // per-request cost. 10× the page size leaves comfortable headroom over the
42
+ // observed worst case (~2k candidates).
43
+ export const FIND_RULES_MAX_CANDIDATES = REDIS_BATCH_SIZE * 10;
44
+
42
45
  export class RedisRulesReader implements AccessRulesReader {
43
46
  constructor(
44
47
  private readonly client: RedisClientType,
@@ -85,46 +88,45 @@ export class RedisRulesReader implements AccessRulesReader {
85
88
  }
86
89
 
87
90
  try {
88
- // Use searchNoContent to get document keys only, then fetch data separately.
89
- // This avoids a bug in @redis/search where ft.search crashes with
90
- // "Cannot read properties of null (reading 'length')" when a document
91
- // is deleted/expired between the index scan and data retrieval.
92
- const searchReply = await this.client.ft.searchNoContent(
93
- ACCESS_RULES_REDIS_INDEX_NAME,
91
+ // FT.AGGREGATE WITHCURSOR (via aggregateRedisKeys) instead of
92
+ // FT.SEARCH. FT.SEARCH's LIMIT caps the candidate set at
93
+ // REDIS_BATCH_SIZE (1000), silently dropping anything past that.
94
+ // Under the greedy `userScopeMatch` mode (#2657), the OR-of-fields
95
+ // query for a popular ja4 hash returns thousands of candidates —
96
+ // matches sitting past offset 1000 (block rules emitted by
97
+ // less-frequent detectors like SUDDEN_VOLUME_INCREASE) were lost,
98
+ // so verify-time ranking only saw the high-volume rules and the
99
+ // most-specific block rule for the request never reached the
100
+ // JS-side specificity sort.
101
+ const ruleKeys = await aggregateRedisKeys(
102
+ this.client,
94
103
  query,
95
- {
96
- DIALECT: REDIS_QUERY_DIALECT,
97
- // FT.search doesn't support "unlimited" selects
98
- LIMIT: {
99
- from: 0,
100
- size: REDIS_BATCH_SIZE,
101
- },
102
- },
104
+ this.logger,
105
+ undefined,
106
+ FIND_RULES_MAX_CANDIDATES,
103
107
  );
104
108
 
105
- if (searchReply.total > 0) {
106
- this.logger.debug(() => ({
107
- msg: "Executed search query",
108
- data: {
109
- inspect: util.inspect(
110
- {
111
- filter: filter,
112
- searchReply: searchReply,
113
- query: query,
114
- },
115
- { depth: null },
116
- ),
117
- },
118
- }));
119
- }
120
-
121
- if (searchReply.documents.length === 0) {
109
+ if (ruleKeys.length === 0) {
122
110
  return [];
123
111
  }
124
112
 
113
+ this.logger.debug(() => ({
114
+ msg: "Executed search query",
115
+ data: {
116
+ inspect: util.inspect(
117
+ {
118
+ filter: filter,
119
+ foundCount: ruleKeys.length,
120
+ query: query,
121
+ },
122
+ { depth: null },
123
+ ),
124
+ },
125
+ }));
126
+
125
127
  const { records } = await fetchRedisHashRecords(
126
128
  this.client,
127
- searchReply.documents,
129
+ ruleKeys,
128
130
  this.logger,
129
131
  );
130
132
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { Logger } from "@prosopo/logger";
16
16
  import type { RedisClientType } from "redis";
17
- import { type ZodType, z } from "zod";
17
+ import { type ZodType, type ZodTypeDef, z } from "zod";
18
18
 
19
19
  export const REDIS_BATCH_SIZE = 1_000;
20
20
 
@@ -67,9 +67,14 @@ export const fetchRedisHashRecords = async (
67
67
  };
68
68
  };
69
69
 
70
+ // `recordSchema` is intentionally typed with `unknown` as the input
71
+ // position. Some schemas use `z.preprocess` (e.g. accessPolicyInput's
72
+ // deferToVerify boolean coercion from Redis strings), which widens the
73
+ // zod input type. The strict `ZodType<T, ZodTypeDef, T>` form rejects
74
+ // those at the call site even though they parse correctly at runtime.
70
75
  export const parseRedisRecords = <T>(
71
76
  records: unknown[],
72
- recordSchema: ZodType<T>,
77
+ recordSchema: ZodType<T, ZodTypeDef, unknown>,
73
78
  logger: Logger,
74
79
  ): T[] =>
75
80
  records.flatMap((record) => {
package/src/rule.ts CHANGED
@@ -27,6 +27,18 @@ export type AccessPolicy = {
27
27
  powDifficulty?: number;
28
28
  unsolvedImagesCount?: number;
29
29
  frictionlessScore?: number;
30
+ // When true, a Block policy does NOT fire at the request-time
31
+ // blockMiddleware (so the user does not see a 401 on the captcha
32
+ // challenge endpoint) — it fires at the verify step instead, marking
33
+ // the commitment ACCESS_POLICY_BLOCK / disapproved. The verify
34
+ // response returns `{verified:false}` to the dApp's server while the
35
+ // user-facing widget completes normally. Mirrors the existing
36
+ // coords-rule deferral pattern: the middleware blanks coords out of
37
+ // the userScope, so coords rules can only ever be matched in the
38
+ // verify path; `deferToVerify` is the explicit form for non-coords
39
+ // signals (ja4, headersHash, etc.) when the operator wants the
40
+ // attacker to pay the captcha-solving cost before being rejected.
41
+ deferToVerify?: boolean;
30
42
  };
31
43
 
32
44
  export type PolicyScope = {
@@ -21,6 +21,11 @@ import {
21
21
  type PolicyScope,
22
22
  } from "#policy/rule.js";
23
23
 
24
+ // `satisfies ZodType<AccessPolicy>` is intentionally omitted: the
25
+ // `deferToVerify` preprocess widens the schema's input type to `unknown`
26
+ // (preprocess accepts anything), which fails the
27
+ // `ZodType<T, ZodTypeDef, T>` identity check. The `AllKeys<AccessPolicy>`
28
+ // constraint still catches missing-field regressions.
24
29
  export const accessPolicyInput = z.object({
25
30
  type: z.nativeEnum(AccessPolicyType),
26
31
  captchaType: CaptchaTypeSchema.optional(),
@@ -35,7 +40,13 @@ export const accessPolicyInput = z.object({
35
40
  unsolvedImagesCount: z.coerce.number().optional(),
36
41
  // used to increase the user's score
37
42
  frictionlessScore: z.coerce.number().optional(),
38
- } satisfies AllKeys<AccessPolicy>) satisfies ZodType<AccessPolicy>;
43
+ // Skip the request-time block middleware and only fire at verify.
44
+ // Redis stores booleans as strings — preprocess so "true"/"false"
45
+ // round-trip to the JS boolean the matcher expects.
46
+ deferToVerify: z
47
+ .preprocess((v) => (typeof v === "string" ? v === "true" : v), z.boolean())
48
+ .optional(),
49
+ } satisfies AllKeys<AccessPolicy>);
39
50
 
40
51
  // Sanitize block policies by removing captchaType and solvedImagesCount
41
52
  export const sanitizeAccessPolicy = (policy: AccessPolicy): AccessPolicy => {
@@ -48,20 +48,25 @@ const ruleGroupInput = z
48
48
  return ruleGroup;
49
49
  });
50
50
 
51
- export const accessRuleInput: ZodType<AccessRule> = z
51
+ // Explicit `ZodType<…, ZodTypeDef, unknown>` annotation rather than the
52
+ // strict-identity form because `accessPolicyInput.shape.deferToVerify`
53
+ // uses `z.preprocess` which widens the input position to `unknown`. The
54
+ // relaxed annotation is portable for declaration emit; the `transform`
55
+ // pins the OUTPUT to AccessRule.
56
+ export const accessRuleInput: ZodType<AccessRule, z.ZodTypeDef, unknown> = z
52
57
  .object({
53
58
  ...accessPolicyInput.shape,
54
59
  ...policyScopeInput.shape,
55
60
  })
56
61
  .and(userScopeInput)
57
62
  .and(ruleGroupInput)
58
- // transform is used for type safety only - plain "satisfies ZodType<x>" doesn't work after ".and()"
59
63
  .transform((ruleInput: AccessRuleInput): AccessRule => ruleInput);
60
64
 
61
- export const ruleEntryInput = z.object({
62
- rule: accessRuleInput,
63
- expiresUnixTimestamp: z.coerce.number().optional(),
64
- } satisfies AllKeys<AccessRuleEntry>) satisfies ZodType<AccessRuleEntry>;
65
+ export const ruleEntryInput: ZodType<AccessRuleEntry, z.ZodTypeDef, unknown> =
66
+ z.object({
67
+ rule: accessRuleInput,
68
+ expiresUnixTimestamp: z.coerce.number().optional(),
69
+ } satisfies AllKeys<AccessRuleEntry>);
65
70
 
66
71
  export type AccessRulesFilterInput = AccessRulesFilter & {
67
72
  userScope?: UserScopeInput;
@@ -15,7 +15,7 @@
15
15
  import crypto from "node:crypto";
16
16
  import type { AllKeys } from "@prosopo/common";
17
17
  import { getIPAddress } from "@prosopo/util";
18
- import { Address4 } from "ip-address";
18
+ import { Address4, Address6 } from "ip-address";
19
19
  import { type ZodType, z } from "zod";
20
20
  import type { UserAttributes, UserIp, UserScope } from "#policy/rule.js";
21
21
  import type { UserAttributesRecord, UserIpRecord } from "#policy/ruleRecord.js";
@@ -77,14 +77,14 @@ const userIpInput = z
77
77
 
78
78
  // Assuming ipMask is already validated to be a string in CIDR format
79
79
  if ("string" === typeof ipMask) {
80
- // Create an Address4 object from the CIDR string.
81
- // Address4 automatically understands CIDR notation and represents the entire network range.
82
- const ipObject = new Address4(ipMask);
80
+ // Try IPv4 CIDR first (e.g., 192.168.1.0/24); fall back to IPv6
81
+ // CIDR (e.g., 2001:db8::/32). Both Address4 and Address6 understand
82
+ // CIDR notation and expose start/end addresses for the network.
83
+ const ipObject = Address4.isValid(ipMask)
84
+ ? new Address4(ipMask)
85
+ : new Address6(ipMask);
83
86
 
84
- // The minimum IP in the CIDR range is the start address of the network.
85
87
  numericUserIp.numericIpMaskMin = ipObject.startAddress().bigInt();
86
-
87
- // The maximum IP in the CIDR range is the end address of the network.
88
88
  numericUserIp.numericIpMaskMax = ipObject.endAddress().bigInt();
89
89
  }
90
90
 
@@ -1115,6 +1115,58 @@ describe("redisAccessRulesStorage", () => {
1115
1115
  expect(foundAccessRules).toEqual([]);
1116
1116
  });
1117
1117
 
1118
+ test("returns all matches when the candidate set exceeds the FT.SEARCH page size", async () => {
1119
+ // Regression: under the production traffic profile of a high-volume
1120
+ // bot attack, the greedy `@field:{X} | @field:{Y}` query returns
1121
+ // thousands of candidate rules sharing the dominant ja4 fingerprint.
1122
+ // FT.SEARCH's LIMIT (1000) silently truncated the candidate set,
1123
+ // dropping less-frequent block rules — they never reached the
1124
+ // JS-side specificity sort, so verify let the bot through.
1125
+ // FT.AGGREGATE WITHCURSOR paginates the result and returns all of
1126
+ // them. This test inserts > 1000 rules so the old code would
1127
+ // truncate; the target block rule must still come back.
1128
+ const clientId = getUniqueString();
1129
+ const popularJa4 = `t13d1516h2_${getUniqueString()}`;
1130
+
1131
+ const targetBlockRule: AccessRule = {
1132
+ type: AccessPolicyType.Block,
1133
+ clientId: clientId,
1134
+ ja4Hash: popularJa4,
1135
+ coords: "[[[867,60]]]",
1136
+ };
1137
+
1138
+ // 1500 noise rules sharing the popular ja4 but with distinct coords.
1139
+ // 1500 > REDIS_BATCH_SIZE (1000) — guarantees the targetBlockRule's
1140
+ // FT index position has at least a 1/3 chance of sitting past the
1141
+ // truncation boundary on any given run. With pagination, it must
1142
+ // always come back regardless of indexed order.
1143
+ const noiseRules: AccessRule[] = Array.from({ length: 1500 }, (_, i) => ({
1144
+ type: AccessPolicyType.Restrict,
1145
+ clientId: clientId,
1146
+ ja4Hash: popularJa4,
1147
+ coords: `[[[${i % 1024},${60 + (i % 32)}]]]`,
1148
+ description: `noise-${i}`,
1149
+ }));
1150
+
1151
+ await insertRules([targetBlockRule, ...noiseRules]);
1152
+
1153
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
1154
+ expect(indexRecordsCount).toBe(1501);
1155
+
1156
+ const found = await accessRulesReader.findRules({
1157
+ policyScope: { clientId: clientId },
1158
+ policyScopeMatch: FilterScopeMatch.Greedy,
1159
+ userScope: {
1160
+ ja4Hash: popularJa4,
1161
+ coords: "[[[867,60]]]",
1162
+ },
1163
+ userScopeMatch: FilterScopeMatch.Greedy,
1164
+ });
1165
+
1166
+ expect(found.length).toBe(1501);
1167
+ expect(found).toContainEqual(targetBlockRule);
1168
+ }, 60_000);
1169
+
1118
1170
  test("finds rules with matchingFieldsOnly when only userId is set and all IP fields are missing", async () => {
1119
1171
  // This is the exact scenario from the production error where the query
1120
1172
  // contained duplicate ismissing(@numericIpMaskMin) ismissing(@numericIpMaskMax)
@@ -13,7 +13,7 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import { CaptchaType } from "@prosopo/types";
16
- import { Address4 } from "ip-address";
16
+ import { Address4, Address6 } from "ip-address";
17
17
  import { describe, expect, it } from "vitest";
18
18
  import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
19
19
  import type { AccessRuleRecord } from "#policy/ruleRecord.js";
@@ -106,6 +106,7 @@ describe("transformRule", () => {
106
106
  powDifficulty: 1,
107
107
  unsolvedImagesCount: 1,
108
108
  frictionlessScore: 1,
109
+ deferToVerify: false,
109
110
  headersHash: "headersHash",
110
111
  ja4Hash: "js4Hash",
111
112
  clientId: "client",
@@ -186,6 +187,32 @@ describe("transformRule", () => {
186
187
  } as unknown as AccessRule),
187
188
  ).toThrow();
188
189
  });
190
+
191
+ it("should round-trip an IPv6 address and CIDR mask", () => {
192
+ const ipv6 = "2001:db8::1";
193
+ const ipv6Cidr = "2001:db8::/32";
194
+
195
+ const expectedNumericIp = new Address6(ipv6).bigInt();
196
+ const expectedMaskMin = new Address6(ipv6Cidr).startAddress().bigInt();
197
+ const expectedMaskMax = new Address6(ipv6Cidr).endAddress().bigInt();
198
+
199
+ const ruleRecord: AccessRuleRecord = {
200
+ type: AccessPolicyType.Restrict,
201
+ ip: ipv6,
202
+ ipMask: ipv6Cidr,
203
+ };
204
+
205
+ const accessRule = transformAccessRuleRecordIntoRule(ruleRecord);
206
+
207
+ expect(accessRule.numericIp).toEqual(expectedNumericIp);
208
+ expect(accessRule.numericIpMaskMin).toEqual(expectedMaskMin);
209
+ expect(accessRule.numericIpMaskMax).toEqual(expectedMaskMax);
210
+
211
+ const reconstructed = transformAccessRuleIntoRecord(accessRule);
212
+
213
+ expect(reconstructed.ip).toEqual(new Address6(ipv6).correctForm());
214
+ expect(reconstructed.ipMask).toEqual(ipv6Cidr);
215
+ });
189
216
  });
190
217
 
191
218
  describe("getCidrFromNumericIpRange", () => {
@@ -267,4 +294,44 @@ describe("getCidrFromNumericIpRange", () => {
267
294
 
268
295
  expect(cird).toEqual(cirdExample.cidr);
269
296
  });
297
+
298
+ type Ipv6CidrExample = {
299
+ cidr: string;
300
+ startIp: string;
301
+ endIp: string;
302
+ description: string;
303
+ };
304
+
305
+ const ipv6CirdsSet: Ipv6CidrExample[] = [
306
+ {
307
+ cidr: "2001:db8::/32",
308
+ startIp: "2001:db8::",
309
+ endIp: "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
310
+ description: "/32 IPv6 network",
311
+ },
312
+ {
313
+ cidr: "2001:db8::/64",
314
+ startIp: "2001:db8::",
315
+ endIp: "2001:db8::ffff:ffff:ffff:ffff",
316
+ description: "/64 IPv6 subnet",
317
+ },
318
+ {
319
+ cidr: "fe80::/10",
320
+ startIp: "fe80::",
321
+ endIp: "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
322
+ description: "/10 IPv6 link-local",
323
+ },
324
+ ];
325
+
326
+ it.each(ipv6CirdsSet)(
327
+ "should convert $description to $cidr",
328
+ (cirdExample) => {
329
+ const cidr = getCidrFromNumericIpRange(
330
+ new Address6(cirdExample.startIp).bigInt(),
331
+ new Address6(cirdExample.endIp).bigInt(),
332
+ );
333
+
334
+ expect(cidr).toEqual(cirdExample.cidr);
335
+ },
336
+ );
270
337
  });
@@ -14,7 +14,7 @@
14
14
 
15
15
  import crypto from "node:crypto";
16
16
  import { IpAddress, IpRange } from "cidr-calc";
17
- import { Address4 } from "ip-address";
17
+ import { Address4, Address6 } from "ip-address";
18
18
  import { z } from "zod";
19
19
  import type { AccessRule } from "./rule.js";
20
20
  import {
@@ -27,6 +27,9 @@ import type { AccessRuleRecord } from "./ruleRecord.js";
27
27
 
28
28
  const RULE_HASH_ALGORITHM = "md5";
29
29
 
30
+ // IPv4 numeric values occupy 0..2^32-1. Anything above is treated as IPv6.
31
+ const MAX_IPV4_NUMERIC = (1n << 32n) - 1n;
32
+
30
33
  export const makeAccessRuleHash = (rule: AccessRule): string => {
31
34
  // 1. exclude "undefined" values to ensure hash consistency
32
35
  const valueProperties = Object.entries(rule).filter(
@@ -111,7 +114,9 @@ const hashObject = (
111
114
  .digest("hex");
112
115
 
113
116
  const getStringIpFromNumeric = (numericIp: bigint): string =>
114
- Address4.fromInteger(Number(numericIp)).address;
117
+ numericIp > MAX_IPV4_NUMERIC
118
+ ? Address6.fromBigInt(numericIp).correctForm()
119
+ : Address4.fromInteger(Number(numericIp)).address;
115
120
 
116
121
  export const getCidrFromNumericIpRange = (
117
122
  startIp: bigint,