@prosopo/user-access-policy 3.12.33 → 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 (61) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +10 -9
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +11 -10
  4. package/CHANGELOG.md +25 -0
  5. package/dist/.export.d.ts +1 -0
  6. package/dist/.export.d.ts.map +1 -1
  7. package/dist/.export.js +2 -1
  8. package/dist/.export.js.map +1 -1
  9. package/dist/cjs/.export.cjs +8 -0
  10. package/dist/cjs/headerMatch.cjs +122 -0
  11. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +16 -0
  12. package/dist/cjs/redis/reader/redisRulesReader.cjs +5 -0
  13. package/dist/cjs/redis/redisRuleIndex.cjs +16 -0
  14. package/dist/cjs/ruleInput/userScopeInput.cjs +5 -1
  15. package/dist/cjs/ruleRecord.cjs +5 -1
  16. package/dist/headerMatch.d.ts +13 -0
  17. package/dist/headerMatch.d.ts.map +1 -0
  18. package/dist/headerMatch.js +116 -0
  19. package/dist/headerMatch.js.map +1 -0
  20. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  21. package/dist/mongoose/mongooseRuleSchema.js +16 -0
  22. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  23. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  24. package/dist/redis/reader/redisRulesReader.js +5 -0
  25. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  26. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  27. package/dist/redis/redisRuleIndex.js +16 -0
  28. package/dist/redis/redisRuleIndex.js.map +1 -1
  29. package/dist/rule.d.ts +4 -0
  30. package/dist/rule.d.ts.map +1 -1
  31. package/dist/ruleInput/ruleInput.d.ts +24 -0
  32. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  33. package/dist/ruleInput/userScopeInput.d.ts +32 -0
  34. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  35. package/dist/ruleInput/userScopeInput.js +5 -1
  36. package/dist/ruleInput/userScopeInput.js.map +1 -1
  37. package/dist/ruleRecord.d.ts +2 -2
  38. package/dist/ruleRecord.d.ts.map +1 -1
  39. package/dist/ruleRecord.js +5 -1
  40. package/dist/ruleRecord.js.map +1 -1
  41. package/dist/tests/headerMatch.unit.test.d.ts +2 -0
  42. package/dist/tests/headerMatch.unit.test.d.ts.map +1 -0
  43. package/dist/tests/headerMatch.unit.test.js +202 -0
  44. package/dist/tests/headerMatch.unit.test.js.map +1 -0
  45. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +3 -3
  46. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  47. package/dist/tests/transformRule.unit.test.js +4 -0
  48. package/dist/tests/transformRule.unit.test.js.map +1 -1
  49. package/package.json +11 -11
  50. package/src/.export.ts +11 -0
  51. package/src/headerMatch.ts +180 -0
  52. package/src/mongoose/mongooseRuleSchema.ts +4 -0
  53. package/src/redis/reader/redisRulesReader.ts +7 -0
  54. package/src/redis/redisRuleIndex.ts +13 -0
  55. package/src/rule.ts +13 -0
  56. package/src/ruleInput/userScopeInput.ts +7 -0
  57. package/src/ruleRecord.ts +4 -0
  58. package/src/tests/headerMatch.unit.test.ts +350 -0
  59. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
  60. package/src/tests/transformRule.unit.test.ts +4 -0
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -37,6 +37,13 @@ const userAttributesSchema = z.object({
37
37
  // than failing the whole rule parse.
38
38
  os: z.coerce.string().optional(),
39
39
  browser: z.coerce.string().optional(),
40
+ // Arbitrary-header matching (see `headerMatch.ts`). `headerMatch` is the
41
+ // indexed sentinel; name/value/operator are rule data checked in code, not
42
+ // via the Redis query. Loose `string` like the other tags.
43
+ headerMatch: z.coerce.string().optional(),
44
+ headerName: z.coerce.string().optional(),
45
+ headerValue: z.coerce.string().optional(),
46
+ headerOperator: z.coerce.string().optional(),
40
47
  } satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
41
48
 
42
49
  const userAttributesInput = z
package/src/ruleRecord.ts CHANGED
@@ -33,6 +33,10 @@ export const userAttributesRecordFields = [
33
33
  "asn",
34
34
  "os",
35
35
  "browser",
36
+ "headerMatch",
37
+ "headerName",
38
+ "headerValue",
39
+ "headerOperator",
36
40
  ] as const satisfies (keyof UserAttributesRecord)[];
37
41
 
38
42
  export type UserIpRecord = {
@@ -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) ismissing(@browser) )",
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) ismissing(@browser) )",
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) ismissing(@browser) )",
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
 
@@ -117,6 +117,10 @@ describe("transformRule", () => {
117
117
  asn: 205016,
118
118
  os: "macos",
119
119
  browser: "firefox",
120
+ headerMatch: "1",
121
+ headerName: "x-test",
122
+ headerValue: "test",
123
+ headerOperator: "equals",
120
124
  } satisfies AccessRule;
121
125
 
122
126
  it("should transform access rule record into rule", () => {