@prosopo/user-access-policy 3.12.33 → 3.14.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 +11 -10
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +12 -11
- package/CHANGELOG.md +53 -0
- package/dist/.export.d.ts +1 -0
- package/dist/.export.d.ts.map +1 -1
- package/dist/.export.js +2 -1
- package/dist/.export.js.map +1 -1
- package/dist/cjs/.export.cjs +8 -0
- package/dist/cjs/headerMatch.cjs +122 -0
- package/dist/cjs/mongoose/mongooseRuleSchema.cjs +20 -0
- package/dist/cjs/redis/reader/redisRulesReader.cjs +5 -0
- package/dist/cjs/redis/redisRuleIndex.cjs +20 -0
- package/dist/cjs/rule.cjs +1 -0
- package/dist/cjs/ruleInput/userScopeInput.cjs +13 -1
- package/dist/cjs/ruleRecord.cjs +6 -1
- 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 +5 -0
- package/dist/redis/reader/redisRulesReader.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 +7 -1
- package/dist/rule.d.ts.map +1 -1
- package/dist/rule.js +1 -0
- package/dist/rule.js.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 +13 -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/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/transformRule.unit.test.js +5 -0
- package/dist/tests/transformRule.unit.test.js.map +1 -1
- package/package.json +11 -11
- package/src/.export.ts +11 -0
- package/src/headerMatch.ts +180 -0
- package/src/mongoose/mongooseRuleSchema.ts +5 -0
- package/src/redis/reader/redisRulesReader.ts +7 -0
- package/src/redis/redisRuleIndex.ts +14 -0
- package/src/rule.ts +29 -0
- package/src/ruleInput/userScopeInput.ts +28 -0
- package/src/ruleRecord.ts +5 -0
- package/src/tests/headerMatch.unit.test.ts +350 -0
- package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +3 -3
- package/src/tests/transformRule.unit.test.ts +5 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/rule.ts
CHANGED
|
@@ -16,6 +16,15 @@ import type { CaptchaType } from "@prosopo/types";
|
|
|
16
16
|
export enum AccessPolicyType {
|
|
17
17
|
Block = "block",
|
|
18
18
|
Restrict = "restrict",
|
|
19
|
+
// Explicit allow-list: request that matches an Allow rule bypasses the
|
|
20
|
+
// challenge flow and gets an `authenticated` session (captchaType =
|
|
21
|
+
// authenticated on the frictionless response). Fires for any qualifying
|
|
22
|
+
// scope — verified Web Bot Auth agent, allow-listed IP, ja4/ja4_and_ip
|
|
23
|
+
// match, UA substring, ASN, country. IP-binding on the resulting
|
|
24
|
+
// session token defends against replay from a different IP. Opt-in per
|
|
25
|
+
// rule; the operator authors an Allow rule the same way they'd author
|
|
26
|
+
// a Block or Restrict rule.
|
|
27
|
+
Allow = "allow",
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
// Sentinel stamped on the Redis `clientId` field for rules that would
|
|
@@ -79,6 +88,26 @@ export type UserAttributes = {
|
|
|
79
88
|
// hints.
|
|
80
89
|
os?: string;
|
|
81
90
|
browser?: string;
|
|
91
|
+
// Arbitrary-header matching (see `headerMatch.ts` and the portal Header
|
|
92
|
+
// Restriction card). A header rule carries `headerName` + `headerValue` +
|
|
93
|
+
// `headerOperator`; unlike the other dimensions these are checked in code
|
|
94
|
+
// against the raw request headers — Redis TAG can't express substring
|
|
95
|
+
// `contains` or per-rule operators — so they are not part of the Redis
|
|
96
|
+
// matching query. `headerMatch` is the indexed sentinel (always
|
|
97
|
+
// `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
|
|
98
|
+
// every request, so an allow-list rule still fires on a request that omits
|
|
99
|
+
// the target header.
|
|
100
|
+
headerMatch?: string;
|
|
101
|
+
headerName?: string;
|
|
102
|
+
headerValue?: string;
|
|
103
|
+
headerOperator?: string;
|
|
104
|
+
// Canonical Web Bot Auth Signature-Agent URL — e.g.
|
|
105
|
+
// "https://signatures.openai.com/". Matched at runtime against the
|
|
106
|
+
// `Signature-Agent` header after RFC 9421 Ed25519 verification succeeds.
|
|
107
|
+
// Stored as-is like `countryCode` (exact equality after normalisation).
|
|
108
|
+
// Rules with this field only match requests carrying a valid Web Bot
|
|
109
|
+
// Auth signature; unverified traffic falls through.
|
|
110
|
+
webBotAuthAgent?: string;
|
|
82
111
|
};
|
|
83
112
|
|
|
84
113
|
export type UserScope = UserAttributes & UserIp;
|
|
@@ -22,6 +22,18 @@ import type { UserAttributesRecord, UserIpRecord } from "#policy/ruleRecord.js";
|
|
|
22
22
|
|
|
23
23
|
export type UserAttributesInput = UserAttributes & UserAttributesRecord;
|
|
24
24
|
|
|
25
|
+
// Cloudflare's Web Bot Auth reference implementation compares Signature-Agent
|
|
26
|
+
// values as URL objects, which are case-insensitive on scheme/host but
|
|
27
|
+
// case-sensitive on path. Store the canonical form so runtime matcher can use
|
|
28
|
+
// plain string equality.
|
|
29
|
+
function normaliseSignatureAgentUrl(raw: string): string {
|
|
30
|
+
const url = new URL(raw);
|
|
31
|
+
url.hostname = url.hostname.toLowerCase();
|
|
32
|
+
url.protocol = url.protocol.toLowerCase();
|
|
33
|
+
if (url.pathname === "/") url.pathname = "";
|
|
34
|
+
return url.toString().replace(/\/$/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const userAttributesSchema = z.object({
|
|
26
38
|
// coerce is used for safety, as e.g., incoming userId can be digital
|
|
27
39
|
userId: z.coerce.string().optional(),
|
|
@@ -37,6 +49,22 @@ const userAttributesSchema = z.object({
|
|
|
37
49
|
// than failing the whole rule parse.
|
|
38
50
|
os: z.coerce.string().optional(),
|
|
39
51
|
browser: z.coerce.string().optional(),
|
|
52
|
+
// Arbitrary-header matching (see `headerMatch.ts`). `headerMatch` is the
|
|
53
|
+
// indexed sentinel; name/value/operator are rule data checked in code, not
|
|
54
|
+
// via the Redis query. Loose `string` like the other tags.
|
|
55
|
+
headerMatch: z.coerce.string().optional(),
|
|
56
|
+
headerName: z.coerce.string().optional(),
|
|
57
|
+
headerValue: z.coerce.string().optional(),
|
|
58
|
+
headerOperator: z.coerce.string().optional(),
|
|
59
|
+
// Web Bot Auth Signature-Agent URL. Normalised at parse time
|
|
60
|
+
// (lowercase scheme+host, no trailing slash) so the runtime matcher can
|
|
61
|
+
// use exact string equality against the verified `Signature-Agent`
|
|
62
|
+
// header value.
|
|
63
|
+
webBotAuthAgent: z.coerce
|
|
64
|
+
.string()
|
|
65
|
+
.url()
|
|
66
|
+
.transform(normaliseSignatureAgentUrl)
|
|
67
|
+
.optional(),
|
|
40
68
|
} satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
|
|
41
69
|
|
|
42
70
|
const userAttributesInput = z
|
package/src/ruleRecord.ts
CHANGED
|
@@ -33,6 +33,11 @@ export const userAttributesRecordFields = [
|
|
|
33
33
|
"asn",
|
|
34
34
|
"os",
|
|
35
35
|
"browser",
|
|
36
|
+
"headerMatch",
|
|
37
|
+
"headerName",
|
|
38
|
+
"headerValue",
|
|
39
|
+
"headerOperator",
|
|
40
|
+
"webBotAuthAgent",
|
|
36
41
|
] as const satisfies (keyof UserAttributesRecord)[];
|
|
37
42
|
|
|
38
43
|
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) ismissing(@webBotAuthAgent) )",
|
|
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) ismissing(@webBotAuthAgent) )",
|
|
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) ismissing(@webBotAuthAgent) )",
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
@@ -117,6 +117,11 @@ 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",
|
|
124
|
+
webBotAuthAgent: "https://signatures.openai.com",
|
|
120
125
|
} satisfies AccessRule;
|
|
121
126
|
|
|
122
127
|
it("should transform access rule record into rule", () => {
|