@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.
Files changed (80) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +12 -10
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +13 -11
  4. package/CHANGELOG.md +91 -0
  5. package/dist/.export.d.ts +2 -0
  6. package/dist/.export.d.ts.map +1 -1
  7. package/dist/.export.js +3 -1
  8. package/dist/.export.js.map +1 -1
  9. package/dist/cjs/.export.cjs +11 -0
  10. package/dist/cjs/classifyBrowser.cjs +44 -0
  11. package/dist/cjs/headerMatch.cjs +122 -0
  12. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +20 -0
  13. package/dist/cjs/redis/reader/redisRulesReader.cjs +8 -0
  14. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +3 -1
  15. package/dist/cjs/redis/redisRuleIndex.cjs +20 -0
  16. package/dist/cjs/ruleInput/userScopeInput.cjs +6 -1
  17. package/dist/cjs/ruleRecord.cjs +6 -1
  18. package/dist/classifyBrowser.d.ts +4 -0
  19. package/dist/classifyBrowser.d.ts.map +1 -0
  20. package/dist/classifyBrowser.js +43 -0
  21. package/dist/classifyBrowser.js.map +1 -0
  22. package/dist/headerMatch.d.ts +13 -0
  23. package/dist/headerMatch.d.ts.map +1 -0
  24. package/dist/headerMatch.js +116 -0
  25. package/dist/headerMatch.js.map +1 -0
  26. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  27. package/dist/mongoose/mongooseRuleSchema.js +20 -0
  28. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  29. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  30. package/dist/redis/reader/redisRulesReader.js +8 -0
  31. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  32. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
  33. package/dist/redis/reader/redisRulesSplitQuery.js +3 -1
  34. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
  35. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  36. package/dist/redis/redisRuleIndex.js +20 -0
  37. package/dist/redis/redisRuleIndex.js.map +1 -1
  38. package/dist/rule.d.ts +5 -0
  39. package/dist/rule.d.ts.map +1 -1
  40. package/dist/ruleInput/ruleInput.d.ts +30 -0
  41. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  42. package/dist/ruleInput/userScopeInput.d.ts +40 -0
  43. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  44. package/dist/ruleInput/userScopeInput.js +6 -1
  45. package/dist/ruleInput/userScopeInput.js.map +1 -1
  46. package/dist/ruleRecord.d.ts +2 -2
  47. package/dist/ruleRecord.d.ts.map +1 -1
  48. package/dist/ruleRecord.js +6 -1
  49. package/dist/ruleRecord.js.map +1 -1
  50. package/dist/tests/classifyBrowser.unit.test.d.ts +2 -0
  51. package/dist/tests/classifyBrowser.unit.test.d.ts.map +1 -0
  52. package/dist/tests/classifyBrowser.unit.test.js +99 -0
  53. package/dist/tests/classifyBrowser.unit.test.js.map +1 -0
  54. package/dist/tests/headerMatch.unit.test.d.ts +2 -0
  55. package/dist/tests/headerMatch.unit.test.d.ts.map +1 -0
  56. package/dist/tests/headerMatch.unit.test.js +202 -0
  57. package/dist/tests/headerMatch.unit.test.js.map +1 -0
  58. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
  59. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  60. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +9 -0
  61. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
  62. package/dist/tests/transformRule.unit.test.js +5 -0
  63. package/dist/tests/transformRule.unit.test.js.map +1 -1
  64. package/package.json +14 -14
  65. package/src/.export.ts +17 -0
  66. package/src/classifyBrowser.ts +77 -0
  67. package/src/headerMatch.ts +180 -0
  68. package/src/mongoose/mongooseRuleSchema.ts +5 -0
  69. package/src/redis/reader/redisRulesReader.ts +10 -0
  70. package/src/redis/reader/redisRulesSplitQuery.ts +2 -0
  71. package/src/redis/redisRuleIndex.ts +14 -0
  72. package/src/rule.ts +14 -0
  73. package/src/ruleInput/userScopeInput.ts +8 -0
  74. package/src/ruleRecord.ts +5 -0
  75. package/src/tests/classifyBrowser.unit.test.ts +138 -0
  76. package/src/tests/headerMatch.unit.test.ts +350 -0
  77. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
  78. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +17 -0
  79. package/src/tests/transformRule.unit.test.ts +5 -0
  80. 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", () => {