@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.
- package/.turbo/turbo-build$colon$cjs.log +8 -8
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +33 -0
- package/dist/api/read/fetchRules.d.ts +1 -30
- package/dist/api/read/fetchRules.d.ts.map +1 -1
- package/dist/api/read/fetchRules.js.map +1 -1
- package/dist/api/write/insertRules.d.ts +2 -2
- package/dist/api/write/insertRules.d.ts.map +1 -1
- package/dist/api/write/insertRules.js.map +1 -1
- package/dist/cjs/mongoose/mongooseRuleSchema.cjs +2 -1
- package/dist/cjs/redis/reader/redisAggregate.cjs +22 -4
- package/dist/cjs/redis/reader/redisRulesReader.cjs +22 -27
- package/dist/cjs/ruleInput/policyInput.cjs +5 -1
- package/dist/cjs/ruleInput/userScopeInput.cjs +1 -1
- package/dist/cjs/transformRule.cjs +2 -1
- package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
- package/dist/mongoose/mongooseRuleSchema.js +2 -1
- package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
- package/dist/redis/reader/redisAggregate.d.ts +1 -1
- package/dist/redis/reader/redisAggregate.d.ts.map +1 -1
- package/dist/redis/reader/redisAggregate.js +22 -4
- package/dist/redis/reader/redisAggregate.js.map +1 -1
- package/dist/redis/reader/redisRulesReader.d.ts +1 -0
- package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesReader.js +24 -29
- package/dist/redis/reader/redisRulesReader.js.map +1 -1
- package/dist/redis/redisClient.d.ts +2 -2
- package/dist/redis/redisClient.d.ts.map +1 -1
- package/dist/redis/redisClient.js.map +1 -1
- package/dist/rule.d.ts +1 -0
- package/dist/rule.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.d.ts +3 -0
- package/dist/ruleInput/policyInput.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.js +5 -1
- package/dist/ruleInput/policyInput.js.map +1 -1
- package/dist/ruleInput/ruleInput.d.ts +3 -16
- package/dist/ruleInput/ruleInput.d.ts.map +1 -1
- package/dist/ruleInput/ruleInput.js.map +1 -1
- package/dist/ruleInput/userScopeInput.js +2 -2
- package/dist/ruleInput/userScopeInput.js.map +1 -1
- package/dist/tests/redis/redisRulesStorage.integration.test.js +31 -0
- package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
- package/dist/tests/transformRule.unit.test.js +45 -1
- package/dist/tests/transformRule.unit.test.js.map +1 -1
- package/dist/transformRule.d.ts.map +1 -1
- package/dist/transformRule.js +3 -2
- package/dist/transformRule.js.map +1 -1
- package/package.json +3 -3
- package/src/api/read/fetchRules.ts +10 -2
- package/src/api/write/insertRules.ts +4 -2
- package/src/mongoose/mongooseRuleSchema.ts +1 -0
- package/src/redis/reader/redisAggregate.ts +27 -1
- package/src/redis/reader/redisRulesReader.ts +42 -40
- package/src/redis/redisClient.ts +7 -2
- package/src/rule.ts +12 -0
- package/src/ruleInput/policyInput.ts +12 -1
- package/src/ruleInput/ruleInput.ts +11 -6
- package/src/ruleInput/userScopeInput.ts +7 -7
- package/src/tests/redis/redisRulesStorage.integration.test.ts +52 -0
- package/src/tests/transformRule.unit.test.ts +68 -1
- package/src/transformRule.ts +7 -2
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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>)
|
|
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
|
-
|
|
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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 (
|
|
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
|
-
|
|
129
|
+
ruleKeys,
|
|
128
130
|
this.logger,
|
|
129
131
|
);
|
|
130
132
|
|
package/src/redis/redisClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
});
|
package/src/transformRule.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|