@prosopo/user-access-policy 3.12.32 → 3.13.0
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 +12 -10
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +13 -11
- package/CHANGELOG.md +91 -0
- package/dist/.export.d.ts +2 -0
- package/dist/.export.d.ts.map +1 -1
- package/dist/.export.js +3 -1
- package/dist/.export.js.map +1 -1
- package/dist/cjs/.export.cjs +11 -0
- package/dist/cjs/classifyBrowser.cjs +44 -0
- package/dist/cjs/headerMatch.cjs +122 -0
- package/dist/cjs/mongoose/mongooseRuleSchema.cjs +20 -0
- package/dist/cjs/redis/reader/redisRulesReader.cjs +8 -0
- package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +3 -1
- package/dist/cjs/redis/redisRuleIndex.cjs +20 -0
- package/dist/cjs/ruleInput/userScopeInput.cjs +6 -1
- package/dist/cjs/ruleRecord.cjs +6 -1
- package/dist/classifyBrowser.d.ts +4 -0
- package/dist/classifyBrowser.d.ts.map +1 -0
- package/dist/classifyBrowser.js +43 -0
- package/dist/classifyBrowser.js.map +1 -0
- package/dist/headerMatch.d.ts +13 -0
- package/dist/headerMatch.d.ts.map +1 -0
- package/dist/headerMatch.js +116 -0
- package/dist/headerMatch.js.map +1 -0
- package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
- package/dist/mongoose/mongooseRuleSchema.js +20 -0
- package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
- package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesReader.js +8 -0
- package/dist/redis/reader/redisRulesReader.js.map +1 -1
- package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesSplitQuery.js +3 -1
- package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
- package/dist/redis/redisRuleIndex.d.ts.map +1 -1
- package/dist/redis/redisRuleIndex.js +20 -0
- package/dist/redis/redisRuleIndex.js.map +1 -1
- package/dist/rule.d.ts +5 -0
- package/dist/rule.d.ts.map +1 -1
- package/dist/ruleInput/ruleInput.d.ts +30 -0
- package/dist/ruleInput/ruleInput.d.ts.map +1 -1
- package/dist/ruleInput/userScopeInput.d.ts +40 -0
- package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
- package/dist/ruleInput/userScopeInput.js +6 -1
- package/dist/ruleInput/userScopeInput.js.map +1 -1
- package/dist/ruleRecord.d.ts +2 -2
- package/dist/ruleRecord.d.ts.map +1 -1
- package/dist/ruleRecord.js +6 -1
- package/dist/ruleRecord.js.map +1 -1
- package/dist/tests/classifyBrowser.unit.test.d.ts +2 -0
- package/dist/tests/classifyBrowser.unit.test.d.ts.map +1 -0
- package/dist/tests/classifyBrowser.unit.test.js +99 -0
- package/dist/tests/classifyBrowser.unit.test.js.map +1 -0
- package/dist/tests/headerMatch.unit.test.d.ts +2 -0
- package/dist/tests/headerMatch.unit.test.d.ts.map +1 -0
- package/dist/tests/headerMatch.unit.test.js +202 -0
- package/dist/tests/headerMatch.unit.test.js.map +1 -0
- package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
- package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +9 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
- package/dist/tests/transformRule.unit.test.js +5 -0
- package/dist/tests/transformRule.unit.test.js.map +1 -1
- package/package.json +14 -14
- package/src/.export.ts +17 -0
- package/src/classifyBrowser.ts +77 -0
- package/src/headerMatch.ts +180 -0
- package/src/mongoose/mongooseRuleSchema.ts +5 -0
- package/src/redis/reader/redisRulesReader.ts +10 -0
- package/src/redis/reader/redisRulesSplitQuery.ts +2 -0
- package/src/redis/redisRuleIndex.ts +14 -0
- package/src/rule.ts +14 -0
- package/src/ruleInput/userScopeInput.ts +8 -0
- package/src/ruleRecord.ts +5 -0
- package/src/tests/classifyBrowser.unit.test.ts +138 -0
- package/src/tests/headerMatch.unit.test.ts +350 -0
- package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +17 -0
- package/src/tests/transformRule.unit.test.ts +5 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import {
|
|
17
|
+
type HeaderOperator,
|
|
18
|
+
accessRuleHeaderMatches,
|
|
19
|
+
decodeHeaderValueList,
|
|
20
|
+
encodeHeaderValueList,
|
|
21
|
+
evaluateHeaderCondition,
|
|
22
|
+
isHeaderOperator,
|
|
23
|
+
} from "#policy/headerMatch.js";
|
|
24
|
+
|
|
25
|
+
describe("isHeaderOperator", () => {
|
|
26
|
+
it("accepts the six known operators", () => {
|
|
27
|
+
for (const op of [
|
|
28
|
+
"equals",
|
|
29
|
+
"contains",
|
|
30
|
+
"notEquals",
|
|
31
|
+
"notContains",
|
|
32
|
+
"notEqualsAny",
|
|
33
|
+
"notContainsAny",
|
|
34
|
+
]) {
|
|
35
|
+
expect(isHeaderOperator(op)).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects unknown / undefined values", () => {
|
|
40
|
+
expect(isHeaderOperator("startsWith")).toBe(false);
|
|
41
|
+
expect(isHeaderOperator("")).toBe(false);
|
|
42
|
+
expect(isHeaderOperator(undefined)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("evaluateHeaderCondition", () => {
|
|
47
|
+
const headers: Record<string, string> = {
|
|
48
|
+
"x-app-version": "1.2.3-beta",
|
|
49
|
+
"cf-worker": "example.com",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cases: ReadonlyArray<{
|
|
53
|
+
name: string;
|
|
54
|
+
op: HeaderOperator;
|
|
55
|
+
value: string;
|
|
56
|
+
header: string;
|
|
57
|
+
expected: boolean;
|
|
58
|
+
}> = [
|
|
59
|
+
// equals: present + exact
|
|
60
|
+
{
|
|
61
|
+
name: "equals hit",
|
|
62
|
+
op: "equals",
|
|
63
|
+
value: "example.com",
|
|
64
|
+
header: "cf-worker",
|
|
65
|
+
expected: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "equals miss (different value)",
|
|
69
|
+
op: "equals",
|
|
70
|
+
value: "evil.com",
|
|
71
|
+
header: "cf-worker",
|
|
72
|
+
expected: false,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "equals miss (header absent)",
|
|
76
|
+
op: "equals",
|
|
77
|
+
value: "anything",
|
|
78
|
+
header: "x-missing",
|
|
79
|
+
expected: false,
|
|
80
|
+
},
|
|
81
|
+
// contains: substring
|
|
82
|
+
{
|
|
83
|
+
name: "contains hit",
|
|
84
|
+
op: "contains",
|
|
85
|
+
value: "beta",
|
|
86
|
+
header: "x-app-version",
|
|
87
|
+
expected: true,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "contains miss (no substring)",
|
|
91
|
+
op: "contains",
|
|
92
|
+
value: "alpha",
|
|
93
|
+
header: "x-app-version",
|
|
94
|
+
expected: false,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "contains miss (header absent)",
|
|
98
|
+
op: "contains",
|
|
99
|
+
value: "beta",
|
|
100
|
+
header: "x-missing",
|
|
101
|
+
expected: false,
|
|
102
|
+
},
|
|
103
|
+
// notEquals: allow-list "equals" — blocks when NOT exactly equal
|
|
104
|
+
{
|
|
105
|
+
name: "notEquals blocks a different value",
|
|
106
|
+
op: "notEquals",
|
|
107
|
+
value: "example.com",
|
|
108
|
+
header: "cf-worker",
|
|
109
|
+
expected: false,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "notEquals lets an exact match through",
|
|
113
|
+
op: "notEquals",
|
|
114
|
+
value: "evil.com",
|
|
115
|
+
header: "cf-worker",
|
|
116
|
+
expected: true,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "notEquals blocks an absent header",
|
|
120
|
+
op: "notEquals",
|
|
121
|
+
value: "secret",
|
|
122
|
+
header: "x-secret",
|
|
123
|
+
expected: true,
|
|
124
|
+
},
|
|
125
|
+
// notContains: allow-list "contains" — blocks when substring absent
|
|
126
|
+
{
|
|
127
|
+
name: "notContains blocks when substring present",
|
|
128
|
+
op: "notContains",
|
|
129
|
+
value: "beta",
|
|
130
|
+
header: "x-app-version",
|
|
131
|
+
expected: false,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "notContains lets a substring hit through",
|
|
135
|
+
op: "notContains",
|
|
136
|
+
value: "alpha",
|
|
137
|
+
header: "x-app-version",
|
|
138
|
+
expected: true,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "notContains blocks an absent header",
|
|
142
|
+
op: "notContains",
|
|
143
|
+
value: "beta",
|
|
144
|
+
header: "x-missing",
|
|
145
|
+
expected: true,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
for (const c of cases) {
|
|
150
|
+
it(c.name, () => {
|
|
151
|
+
expect(evaluateHeaderCondition(c.header, c.op, c.value, headers)).toBe(
|
|
152
|
+
c.expected,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it("matches header names case-insensitively", () => {
|
|
158
|
+
expect(
|
|
159
|
+
evaluateHeaderCondition("CF-Worker", "equals", "example.com", headers),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("accessRuleHeaderMatches", () => {
|
|
165
|
+
const headers: Record<string, string> = { "x-debug": "1" };
|
|
166
|
+
|
|
167
|
+
it("passes trivially when the rule has no header condition", () => {
|
|
168
|
+
expect(accessRuleHeaderMatches({}, headers)).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("evaluates a well-formed header condition", () => {
|
|
172
|
+
expect(
|
|
173
|
+
accessRuleHeaderMatches(
|
|
174
|
+
{ headerName: "x-debug", headerOperator: "equals", headerValue: "1" },
|
|
175
|
+
headers,
|
|
176
|
+
),
|
|
177
|
+
).toBe(true);
|
|
178
|
+
expect(
|
|
179
|
+
accessRuleHeaderMatches(
|
|
180
|
+
{ headerName: "x-debug", headerOperator: "equals", headerValue: "0" },
|
|
181
|
+
headers,
|
|
182
|
+
),
|
|
183
|
+
).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("never matches a rule with a header name but an invalid operator", () => {
|
|
187
|
+
expect(
|
|
188
|
+
accessRuleHeaderMatches(
|
|
189
|
+
{ headerName: "x-debug", headerOperator: "wat", headerValue: "1" },
|
|
190
|
+
headers,
|
|
191
|
+
),
|
|
192
|
+
).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("treats a missing headerValue as an empty string", () => {
|
|
196
|
+
// "contains empty string" is always true for a present header.
|
|
197
|
+
expect(
|
|
198
|
+
accessRuleHeaderMatches(
|
|
199
|
+
{ headerName: "x-debug", headerOperator: "contains" },
|
|
200
|
+
headers,
|
|
201
|
+
),
|
|
202
|
+
).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("encode/decodeHeaderValueList", () => {
|
|
207
|
+
it("round-trips a list of values", () => {
|
|
208
|
+
const values = ["ios", "android", "web"];
|
|
209
|
+
expect(decodeHeaderValueList(encodeHeaderValueList(values))).toEqual(
|
|
210
|
+
values,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("round-trips values containing the characters a delimiter would break on", () => {
|
|
215
|
+
const values = ["en-GB,en;q=0.9", 'a"b', "c\\d", "[bracketed]"];
|
|
216
|
+
expect(decodeHeaderValueList(encodeHeaderValueList(values))).toEqual(
|
|
217
|
+
values,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("treats a plain (non list-encoded) value as a one-value list", () => {
|
|
222
|
+
expect(decodeHeaderValueList("ios")).toEqual(["ios"]);
|
|
223
|
+
expect(decodeHeaderValueList("")).toEqual([""]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("rejects a list-shaped value that is not a usable list", () => {
|
|
227
|
+
expect(decodeHeaderValueList("[")).toBeUndefined();
|
|
228
|
+
expect(decodeHeaderValueList("[not json]")).toBeUndefined();
|
|
229
|
+
expect(decodeHeaderValueList("[]")).toBeUndefined();
|
|
230
|
+
expect(decodeHeaderValueList("[1,2]")).toBeUndefined();
|
|
231
|
+
expect(decodeHeaderValueList('["ok", null]')).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("evaluateHeaderCondition — multi-value allow-list operators", () => {
|
|
236
|
+
const headers: Record<string, string> = {
|
|
237
|
+
"x-app": "android",
|
|
238
|
+
"user-agent": "Mozilla/5.0 (Linux; Android 14) Chrome/120",
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const allowedApps = encodeHeaderValueList(["ios", "android"]);
|
|
242
|
+
|
|
243
|
+
it("does not block a request whose header is one of the allowed values", () => {
|
|
244
|
+
expect(
|
|
245
|
+
evaluateHeaderCondition("x-app", "notEqualsAny", allowedApps, headers),
|
|
246
|
+
).toBe(false);
|
|
247
|
+
expect(
|
|
248
|
+
evaluateHeaderCondition(
|
|
249
|
+
"x-app",
|
|
250
|
+
"notEqualsAny",
|
|
251
|
+
encodeHeaderValueList(["android"]),
|
|
252
|
+
headers,
|
|
253
|
+
),
|
|
254
|
+
).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("blocks a request whose header matches none of the allowed values", () => {
|
|
258
|
+
expect(
|
|
259
|
+
evaluateHeaderCondition(
|
|
260
|
+
"x-app",
|
|
261
|
+
"notEqualsAny",
|
|
262
|
+
encodeHeaderValueList(["ios", "web"]),
|
|
263
|
+
headers,
|
|
264
|
+
),
|
|
265
|
+
).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("blocks a request that omits the header entirely", () => {
|
|
269
|
+
expect(
|
|
270
|
+
evaluateHeaderCondition(
|
|
271
|
+
"x-missing",
|
|
272
|
+
"notEqualsAny",
|
|
273
|
+
allowedApps,
|
|
274
|
+
headers,
|
|
275
|
+
),
|
|
276
|
+
).toBe(true);
|
|
277
|
+
expect(
|
|
278
|
+
evaluateHeaderCondition(
|
|
279
|
+
"x-missing",
|
|
280
|
+
"notContainsAny",
|
|
281
|
+
allowedApps,
|
|
282
|
+
headers,
|
|
283
|
+
),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("matches a substring of the header for notContainsAny", () => {
|
|
288
|
+
expect(
|
|
289
|
+
evaluateHeaderCondition(
|
|
290
|
+
"user-agent",
|
|
291
|
+
"notContainsAny",
|
|
292
|
+
encodeHeaderValueList(["Android", "iPhone"]),
|
|
293
|
+
headers,
|
|
294
|
+
),
|
|
295
|
+
).toBe(false);
|
|
296
|
+
expect(
|
|
297
|
+
evaluateHeaderCondition(
|
|
298
|
+
"user-agent",
|
|
299
|
+
"notContainsAny",
|
|
300
|
+
encodeHeaderValueList(["iPhone", "Windows"]),
|
|
301
|
+
headers,
|
|
302
|
+
),
|
|
303
|
+
).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("is exact for notEqualsAny — a substring is not a match", () => {
|
|
307
|
+
expect(
|
|
308
|
+
evaluateHeaderCondition(
|
|
309
|
+
"x-app",
|
|
310
|
+
"notEqualsAny",
|
|
311
|
+
encodeHeaderValueList(["droid"]),
|
|
312
|
+
headers,
|
|
313
|
+
),
|
|
314
|
+
).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("never fires on a malformed value list, rather than blocking everything", () => {
|
|
318
|
+
for (const operator of ["notEqualsAny", "notContainsAny"] as const) {
|
|
319
|
+
expect(evaluateHeaderCondition("x-app", operator, "[]", headers)).toBe(
|
|
320
|
+
false,
|
|
321
|
+
);
|
|
322
|
+
expect(
|
|
323
|
+
evaluateHeaderCondition("x-missing", operator, "[bad", headers),
|
|
324
|
+
).toBe(false);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("is reachable through accessRuleHeaderMatches", () => {
|
|
329
|
+
expect(
|
|
330
|
+
accessRuleHeaderMatches(
|
|
331
|
+
{
|
|
332
|
+
headerName: "X-App",
|
|
333
|
+
headerOperator: "notEqualsAny",
|
|
334
|
+
headerValue: allowedApps,
|
|
335
|
+
},
|
|
336
|
+
headers,
|
|
337
|
+
),
|
|
338
|
+
).toBe(false);
|
|
339
|
+
expect(
|
|
340
|
+
accessRuleHeaderMatches(
|
|
341
|
+
{
|
|
342
|
+
headerName: "X-App",
|
|
343
|
+
headerOperator: "notEqualsAny",
|
|
344
|
+
headerValue: encodeHeaderValueList(["ios"]),
|
|
345
|
+
},
|
|
346
|
+
headers,
|
|
347
|
+
),
|
|
348
|
+
).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -50,7 +50,7 @@ describe("getRulesRedisQuery", () => {
|
|
|
50
50
|
const query = getRulesRedisQuery(filter, true);
|
|
51
51
|
|
|
52
52
|
expect(query).toBe(
|
|
53
|
-
"( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@userId) ismissing(@headersHash) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) )",
|
|
53
|
+
"( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@userId) ismissing(@headersHash) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) )",
|
|
54
54
|
);
|
|
55
55
|
});
|
|
56
56
|
|
|
@@ -126,7 +126,7 @@ describe("getRulesRedisQuery", () => {
|
|
|
126
126
|
const query = getRulesRedisQuery(filter, true);
|
|
127
127
|
|
|
128
128
|
expect(query).toBe(
|
|
129
|
-
"( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) )",
|
|
129
|
+
"( ( @numericIp:[100 100] | ( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[100 +inf] ) ) @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) )",
|
|
130
130
|
);
|
|
131
131
|
});
|
|
132
132
|
|
|
@@ -146,7 +146,7 @@ describe("getRulesRedisQuery", () => {
|
|
|
146
146
|
const query = getRulesRedisQuery(filter, true);
|
|
147
147
|
|
|
148
148
|
expect(query).toBe(
|
|
149
|
-
"( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[200 +inf] @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) )",
|
|
149
|
+
"( @numericIpMaskMin:[-inf 100] @numericIpMaskMax:[200 +inf] @ja4Hash:{ja4Hash} ismissing(@userAgentHash) ismissing(@headersHash) ismissing(@userId) ismissing(@headHash) ismissing(@coords) ismissing(@countryCode) ismissing(@asn) ismissing(@os) ismissing(@browser) ismissing(@headerMatch) ismissing(@headerName) ismissing(@headerValue) ismissing(@headerOperator) )",
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
@@ -101,6 +101,8 @@ describe("buildScopedBlockSubQueries", () => {
|
|
|
101
101
|
"coords",
|
|
102
102
|
"countryCode",
|
|
103
103
|
"asn",
|
|
104
|
+
"os",
|
|
105
|
+
"browser",
|
|
104
106
|
"numericIp",
|
|
105
107
|
"numericIpMaskMin",
|
|
106
108
|
"numericIpMaskMax",
|
|
@@ -109,6 +111,21 @@ describe("buildScopedBlockSubQueries", () => {
|
|
|
109
111
|
}
|
|
110
112
|
});
|
|
111
113
|
|
|
114
|
+
it("emits field probes for the UA-derived os and browser scopes", () => {
|
|
115
|
+
const subs = buildScopedBlockSubQueries(
|
|
116
|
+
{ os: "windows", browser: "firefox" },
|
|
117
|
+
"client-A",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Without its own probe a browser-only rule is reachable only via the
|
|
121
|
+
// no-user-scope fall-through, sharing that probe's candidate budget.
|
|
122
|
+
const osSub = subs.find((s) => s.kind === "field:os");
|
|
123
|
+
expect(osSub?.query).toContain("@os:{windows}");
|
|
124
|
+
|
|
125
|
+
const browserSub = subs.find((s) => s.kind === "field:browser");
|
|
126
|
+
expect(browserSub?.query).toContain("@browser:{firefox}");
|
|
127
|
+
});
|
|
128
|
+
|
|
112
129
|
it("uses NUMERIC range syntax for asn, not TAG syntax", () => {
|
|
113
130
|
const subs = buildScopedBlockSubQueries({ asn: 205016 }, "client-A");
|
|
114
131
|
|
|
@@ -116,6 +116,11 @@ describe("transformRule", () => {
|
|
|
116
116
|
countryCode: "US",
|
|
117
117
|
asn: 205016,
|
|
118
118
|
os: "macos",
|
|
119
|
+
browser: "firefox",
|
|
120
|
+
headerMatch: "1",
|
|
121
|
+
headerName: "x-test",
|
|
122
|
+
headerValue: "test",
|
|
123
|
+
headerOperator: "equals",
|
|
119
124
|
} satisfies AccessRule;
|
|
120
125
|
|
|
121
126
|
it("should transform access rule record into rule", () => {
|