@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prosopo/user-access-policy",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^24",
|
|
@@ -43,29 +43,29 @@
|
|
|
43
43
|
"test": "npm run test:unit && npm run test:integration"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@prosopo/api": "4.1.
|
|
47
|
-
"@prosopo/api-route": "2.6.
|
|
48
|
-
"@prosopo/common": "3.1.
|
|
49
|
-
"@prosopo/logger": "2.0.
|
|
50
|
-
"@prosopo/redis-client": "1.0.
|
|
51
|
-
"@prosopo/types": "5.
|
|
52
|
-
"@prosopo/util": "3.3.
|
|
46
|
+
"@prosopo/api": "4.1.6",
|
|
47
|
+
"@prosopo/api-route": "2.6.58",
|
|
48
|
+
"@prosopo/common": "3.1.54",
|
|
49
|
+
"@prosopo/logger": "2.0.9",
|
|
50
|
+
"@prosopo/redis-client": "1.0.35",
|
|
51
|
+
"@prosopo/types": "5.7.0",
|
|
52
|
+
"@prosopo/util": "3.3.9",
|
|
53
53
|
"@redis/search": "5.0.0",
|
|
54
54
|
"cidr-calc": "1.0.4",
|
|
55
55
|
"dotenv": "16.4.5",
|
|
56
|
-
"ip-address": "10.
|
|
56
|
+
"ip-address": "10.7.0",
|
|
57
57
|
"redis": "5.0.0",
|
|
58
58
|
"zod": "3.23.8"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@prosopo/config": "3.3.
|
|
62
|
-
"@prosopo/util-crypto": "13.5.
|
|
61
|
+
"@prosopo/config": "3.3.14",
|
|
62
|
+
"@prosopo/util-crypto": "13.5.31",
|
|
63
63
|
"@types/node": "22.10.2",
|
|
64
64
|
"fast-glob": "3.3.3",
|
|
65
|
-
"mongoose": "
|
|
65
|
+
"mongoose": "9.9.4",
|
|
66
66
|
"vite": "8.1.5",
|
|
67
|
-
"vitest": "4.1.
|
|
68
|
-
"yargs": "
|
|
67
|
+
"vitest": "4.1.11",
|
|
68
|
+
"yargs": "18.1.0"
|
|
69
69
|
},
|
|
70
70
|
"author": "PROSOPO LIMITED <info@prosopo.io>",
|
|
71
71
|
"license": "Apache-2.0",
|
package/src/.export.ts
CHANGED
|
@@ -32,6 +32,23 @@ export { describeMatchedRule } from "./matchedRule.js";
|
|
|
32
32
|
|
|
33
33
|
export { classifyOs, OS_NAMES, type OsName } from "./classifyOs.js";
|
|
34
34
|
|
|
35
|
+
export {
|
|
36
|
+
classifyBrowser,
|
|
37
|
+
BROWSER_NAMES,
|
|
38
|
+
type BrowserName,
|
|
39
|
+
} from "./classifyBrowser.js";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
HEADER_OPERATORS,
|
|
43
|
+
type HeaderOperator,
|
|
44
|
+
HEADER_RULE_MARKER,
|
|
45
|
+
isHeaderOperator,
|
|
46
|
+
evaluateHeaderCondition,
|
|
47
|
+
accessRuleHeaderMatches,
|
|
48
|
+
encodeHeaderValueList,
|
|
49
|
+
decodeHeaderValueList,
|
|
50
|
+
} from "./headerMatch.js";
|
|
51
|
+
|
|
35
52
|
export {
|
|
36
53
|
type AccessRulesFilter,
|
|
37
54
|
type AccessRulesStorage,
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Kept in step with `@prosopo/decision-machines`' `uaClassify` — as `classifyOs`
|
|
17
|
+
* is — but defined here because the provider request path cannot depend on that
|
|
18
|
+
* package.
|
|
19
|
+
*/
|
|
20
|
+
export const BROWSER_NAMES = [
|
|
21
|
+
"chrome",
|
|
22
|
+
"safari",
|
|
23
|
+
"firefox",
|
|
24
|
+
"edge",
|
|
25
|
+
"opera",
|
|
26
|
+
"samsung_internet",
|
|
27
|
+
"wechat",
|
|
28
|
+
"facebook",
|
|
29
|
+
"instagram",
|
|
30
|
+
"ie",
|
|
31
|
+
"unknown",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type BrowserName = (typeof BROWSER_NAMES)[number];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Branch order is load-bearing: every browser below Chrome also carries a
|
|
38
|
+
* `chrome/` token, and Safari's signature appears in nearly every WebKit UA.
|
|
39
|
+
*
|
|
40
|
+
* Reads the User-Agent rather than the `sec-ch-ua` client hint because a client
|
|
41
|
+
* can simply omit client hints, but stripping the UA breaks far more.
|
|
42
|
+
*/
|
|
43
|
+
export const classifyBrowser = (userAgent: string | undefined): BrowserName => {
|
|
44
|
+
const ua = (userAgent || "").toLowerCase();
|
|
45
|
+
|
|
46
|
+
if (/\bedg(?:e|a|ios)?\//.test(ua)) {
|
|
47
|
+
return "edge";
|
|
48
|
+
}
|
|
49
|
+
if (/samsungbrowser/.test(ua)) {
|
|
50
|
+
return "samsung_internet";
|
|
51
|
+
}
|
|
52
|
+
if (/\bopr\/|opera/.test(ua)) {
|
|
53
|
+
return "opera";
|
|
54
|
+
}
|
|
55
|
+
if (/micromessenger/.test(ua)) {
|
|
56
|
+
return "wechat";
|
|
57
|
+
}
|
|
58
|
+
if (/fban|fbav|fbios/.test(ua)) {
|
|
59
|
+
return "facebook";
|
|
60
|
+
}
|
|
61
|
+
if (/instagram/.test(ua)) {
|
|
62
|
+
return "instagram";
|
|
63
|
+
}
|
|
64
|
+
if (/firefox|fxios/.test(ua)) {
|
|
65
|
+
return "firefox";
|
|
66
|
+
}
|
|
67
|
+
if (/msie|trident/.test(ua)) {
|
|
68
|
+
return "ie";
|
|
69
|
+
}
|
|
70
|
+
if (/\bchrome\/|\bcrios\//.test(ua)) {
|
|
71
|
+
return "chrome";
|
|
72
|
+
}
|
|
73
|
+
if (/safari\//.test(ua)) {
|
|
74
|
+
return "safari";
|
|
75
|
+
}
|
|
76
|
+
return "unknown";
|
|
77
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Operators for an access rule's arbitrary-header condition.
|
|
17
|
+
*
|
|
18
|
+
* `equals` / `contains` are the deny-list operators (block when the request's
|
|
19
|
+
* header matches). `notEquals` / `notContains` are their allow-list negations
|
|
20
|
+
* (block when the request's header does NOT match — including when the header
|
|
21
|
+
* is absent), so an allow-list card that says "only let requests with header X
|
|
22
|
+
* through" desugars to `notEquals` / `notContains` Block rules, exactly the way
|
|
23
|
+
* the OS allow-list desugars to Block rules on the complement.
|
|
24
|
+
*
|
|
25
|
+
* `notEqualsAny` / `notContainsAny` are the multi-value form of those two: the
|
|
26
|
+
* rule's `headerValue` holds a list (see `encodeHeaderValueList`) and the rule
|
|
27
|
+
* blocks unless the request's header matches *one of* the listed values. They
|
|
28
|
+
* exist because an allow-list over several values of the SAME header cannot be
|
|
29
|
+
* expressed as several single-value rules — each rule fires independently, so
|
|
30
|
+
* `notEquals ios` + `notEquals android` blocks both an iOS and an Android
|
|
31
|
+
* request. One rule holding both values is the only way to get the "any of"
|
|
32
|
+
* semantics the allow-list needs.
|
|
33
|
+
*/
|
|
34
|
+
export const HEADER_OPERATORS = [
|
|
35
|
+
"equals",
|
|
36
|
+
"contains",
|
|
37
|
+
"notEquals",
|
|
38
|
+
"notContains",
|
|
39
|
+
"notEqualsAny",
|
|
40
|
+
"notContainsAny",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export type HeaderOperator = (typeof HEADER_OPERATORS)[number];
|
|
44
|
+
|
|
45
|
+
export const isHeaderOperator = (
|
|
46
|
+
value: string | undefined,
|
|
47
|
+
): value is HeaderOperator =>
|
|
48
|
+
value !== undefined &&
|
|
49
|
+
(HEADER_OPERATORS as ReadonlyArray<string>).includes(value);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sentinel value carried both on every request's user scope (see
|
|
53
|
+
* `getRequestUserScope`) and on every header-restriction rule. Header rules
|
|
54
|
+
* match on data (`headerName` / `headerValue` / `headerOperator`) that Redis
|
|
55
|
+
* cannot evaluate — substring `contains` and per-rule operators aren't
|
|
56
|
+
* expressible as a TAG query, and an allow-list rule must still fire on a
|
|
57
|
+
* request that OMITS the target header, so candidate selection can't key off
|
|
58
|
+
* header presence. Redis returns header rules via the reader's
|
|
59
|
+
* `no-user-scope` fall-through probe (they constrain none of the indexed
|
|
60
|
+
* scope dimensions); this marker is what keeps them candidates through the
|
|
61
|
+
* JS-side `ruleApplies` scalar check, which would otherwise drop a rule
|
|
62
|
+
* carrying a scope field the request lacks. The concrete condition is then
|
|
63
|
+
* checked by `accessRuleHeaderMatches`.
|
|
64
|
+
*/
|
|
65
|
+
export const HEADER_RULE_MARKER = "1";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Encode the value list carried by a multi-value operator (`notEqualsAny` /
|
|
69
|
+
* `notContainsAny`) into the single `headerValue` string an access rule can
|
|
70
|
+
* store. JSON rather than a delimiter because header values are arbitrary
|
|
71
|
+
* text — any separator we picked could legitimately occur inside a value.
|
|
72
|
+
*/
|
|
73
|
+
export const encodeHeaderValueList = (values: ReadonlyArray<string>): string =>
|
|
74
|
+
JSON.stringify(values);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Inverse of {@link encodeHeaderValueList}, tolerant of rules written by hand
|
|
78
|
+
* (e.g. via the access-policy CLI) that carry a single literal value instead of
|
|
79
|
+
* an encoded list.
|
|
80
|
+
*
|
|
81
|
+
* Returns `undefined` for a value that looks list-encoded but isn't usable — an
|
|
82
|
+
* empty list, a non-array, non-string members, or malformed JSON. Callers treat
|
|
83
|
+
* that as a malformed rule and decline to fire it, so a garbled list can't
|
|
84
|
+
* silently block all traffic.
|
|
85
|
+
*/
|
|
86
|
+
export const decodeHeaderValueList = (value: string): string[] | undefined => {
|
|
87
|
+
if (!value.trimStart().startsWith("[")) {
|
|
88
|
+
// Not list-encoded: treat the whole string as a one-value list.
|
|
89
|
+
return [value];
|
|
90
|
+
}
|
|
91
|
+
let parsed: unknown;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(value);
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
!Array.isArray(parsed) ||
|
|
99
|
+
parsed.length === 0 ||
|
|
100
|
+
!parsed.every((entry): entry is string => typeof entry === "string")
|
|
101
|
+
) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Evaluate a single header condition against a request's headers. Returns
|
|
109
|
+
* `true` when the condition is satisfied — i.e. when a Block rule carrying it
|
|
110
|
+
* should fire for this request.
|
|
111
|
+
*
|
|
112
|
+
* `headers` is expected to be keyed by lower-cased header name (HTTP header
|
|
113
|
+
* names are case-insensitive); `headerName` is lower-cased here defensively.
|
|
114
|
+
* The negated operators treat an absent header as "not matching the value",
|
|
115
|
+
* which is what makes an allow-list block a request that drops the header.
|
|
116
|
+
*/
|
|
117
|
+
export const evaluateHeaderCondition = (
|
|
118
|
+
headerName: string,
|
|
119
|
+
operator: HeaderOperator,
|
|
120
|
+
value: string,
|
|
121
|
+
headers: Record<string, string>,
|
|
122
|
+
): boolean => {
|
|
123
|
+
const actual = headers[headerName.toLowerCase()];
|
|
124
|
+
switch (operator) {
|
|
125
|
+
case "equals":
|
|
126
|
+
return actual !== undefined && actual === value;
|
|
127
|
+
case "contains":
|
|
128
|
+
return actual?.includes(value) ?? false;
|
|
129
|
+
case "notEquals":
|
|
130
|
+
return actual === undefined || actual !== value;
|
|
131
|
+
case "notContains":
|
|
132
|
+
return actual === undefined || !actual.includes(value);
|
|
133
|
+
case "notEqualsAny": {
|
|
134
|
+
const values = decodeHeaderValueList(value);
|
|
135
|
+
if (values === undefined) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return actual === undefined || !values.includes(actual);
|
|
139
|
+
}
|
|
140
|
+
case "notContainsAny": {
|
|
141
|
+
const values = decodeHeaderValueList(value);
|
|
142
|
+
if (values === undefined) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return (
|
|
146
|
+
actual === undefined ||
|
|
147
|
+
!values.some((candidate) => actual.includes(candidate))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a rule's header condition (if any) is satisfied by the request's
|
|
155
|
+
* headers. A rule with no `headerName` carries no header constraint, so it is
|
|
156
|
+
* treated as satisfied and the rule's other dimensions decide the match. A
|
|
157
|
+
* rule with a `headerName` but an unrecognised operator is malformed and never
|
|
158
|
+
* matches (fail-safe: a garbled rule must not silently block traffic).
|
|
159
|
+
*/
|
|
160
|
+
export const accessRuleHeaderMatches = (
|
|
161
|
+
rule: {
|
|
162
|
+
headerName?: string;
|
|
163
|
+
headerValue?: string;
|
|
164
|
+
headerOperator?: string;
|
|
165
|
+
},
|
|
166
|
+
headers: Record<string, string>,
|
|
167
|
+
): boolean => {
|
|
168
|
+
if (rule.headerName === undefined) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (!isHeaderOperator(rule.headerOperator)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return evaluateHeaderCondition(
|
|
175
|
+
rule.headerName,
|
|
176
|
+
rule.headerOperator,
|
|
177
|
+
rule.headerValue ?? "",
|
|
178
|
+
headers,
|
|
179
|
+
);
|
|
180
|
+
};
|
|
@@ -32,6 +32,11 @@ const userAttributesSchema: SchemaDefinition<UserAttributesRecord> = {
|
|
|
32
32
|
countryCode: { type: String, required: false },
|
|
33
33
|
asn: { type: Number, required: false },
|
|
34
34
|
os: { type: String, required: false },
|
|
35
|
+
browser: { type: String, required: false },
|
|
36
|
+
headerMatch: { type: String, required: false },
|
|
37
|
+
headerName: { type: String, required: false },
|
|
38
|
+
headerValue: { type: String, required: false },
|
|
39
|
+
headerOperator: { type: String, required: false },
|
|
35
40
|
} satisfies AllKeys<UserAttributesRecord>;
|
|
36
41
|
|
|
37
42
|
const userIpSchema: SchemaDefinition<UserIpRecord> = {
|
|
@@ -95,6 +95,10 @@ const SPECIFICITY_EXPR = [
|
|
|
95
95
|
"exists(@countryCode)",
|
|
96
96
|
"exists(@asn)",
|
|
97
97
|
"exists(@os)",
|
|
98
|
+
"exists(@browser)",
|
|
99
|
+
// A header rule is one logical dimension: only the sentinel counts toward
|
|
100
|
+
// specificity, not the name/value/operator triple it also carries.
|
|
101
|
+
"exists(@headerMatch)",
|
|
98
102
|
"exists(@numericIp)",
|
|
99
103
|
"exists(@numericIpMaskMin)",
|
|
100
104
|
].join(" + ");
|
|
@@ -135,6 +139,11 @@ const RULE_LOAD_FIELDS = [
|
|
|
135
139
|
"@countryCode",
|
|
136
140
|
"@asn",
|
|
137
141
|
"@os",
|
|
142
|
+
"@browser",
|
|
143
|
+
"@headerMatch",
|
|
144
|
+
"@headerName",
|
|
145
|
+
"@headerValue",
|
|
146
|
+
"@headerOperator",
|
|
138
147
|
"@numericIp",
|
|
139
148
|
"@numericIpMaskMin",
|
|
140
149
|
"@numericIpMaskMax",
|
|
@@ -156,6 +165,7 @@ const readerSpecificity = (rule: AccessRule): number => {
|
|
|
156
165
|
if (rule.countryCode !== undefined) score++;
|
|
157
166
|
if (rule.asn !== undefined) score++;
|
|
158
167
|
if (rule.os !== undefined) score++;
|
|
168
|
+
if (rule.browser !== undefined) score++;
|
|
159
169
|
if (rule.numericIp !== undefined) score++;
|
|
160
170
|
if (rule.numericIpMaskMin !== undefined) score++;
|
|
161
171
|
return score;
|
|
@@ -41,6 +41,20 @@ export const userAttributesRedisSchema: RediSearchSchema = {
|
|
|
41
41
|
countryCode: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
42
42
|
asn: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
43
43
|
os: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
44
|
+
browser: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
45
|
+
// Header-restriction fields. NONE of these are queried on the hot path:
|
|
46
|
+
// the split-query builder probes neither `headerMatch` nor the
|
|
47
|
+
// name/value/operator triple, and a header rule populates none of the
|
|
48
|
+
// fields that builder does probe, so it is returned by the `no-user-scope`
|
|
49
|
+
// fall-through probe (the same route `os` rules take) and the concrete
|
|
50
|
+
// condition is evaluated in code by `accessRuleHeaderMatches`. They are
|
|
51
|
+
// indexed to satisfy the schema exhaustiveness check, and `headerMatch`
|
|
52
|
+
// additionally gates candidacy JS-side in `ruleApplies` and scores one
|
|
53
|
+
// specificity point in the reader's SPECIFICITY_EXPR.
|
|
54
|
+
headerMatch: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
55
|
+
headerName: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
56
|
+
headerValue: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
57
|
+
headerOperator: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
44
58
|
} satisfies AllKeys<UserAttributes>;
|
|
45
59
|
|
|
46
60
|
export const userScopeRedisSchema: RediSearchSchema = {
|
package/src/rule.ts
CHANGED
|
@@ -78,6 +78,20 @@ export type UserAttributes = {
|
|
|
78
78
|
// drop/limit requests from a given OS even when the client omits client
|
|
79
79
|
// hints.
|
|
80
80
|
os?: string;
|
|
81
|
+
browser?: string;
|
|
82
|
+
// Arbitrary-header matching (see `headerMatch.ts` and the portal Header
|
|
83
|
+
// Restriction card). A header rule carries `headerName` + `headerValue` +
|
|
84
|
+
// `headerOperator`; unlike the other dimensions these are checked in code
|
|
85
|
+
// against the raw request headers — Redis TAG can't express substring
|
|
86
|
+
// `contains` or per-rule operators — so they are not part of the Redis
|
|
87
|
+
// matching query. `headerMatch` is the indexed sentinel (always
|
|
88
|
+
// `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
|
|
89
|
+
// every request, so an allow-list rule still fires on a request that omits
|
|
90
|
+
// the target header.
|
|
91
|
+
headerMatch?: string;
|
|
92
|
+
headerName?: string;
|
|
93
|
+
headerValue?: string;
|
|
94
|
+
headerOperator?: string;
|
|
81
95
|
};
|
|
82
96
|
|
|
83
97
|
export type UserScope = UserAttributes & UserIp;
|
|
@@ -36,6 +36,14 @@ const userAttributesSchema = z.object({
|
|
|
36
36
|
// countryCode: a stale/unknown value just never matches a request rather
|
|
37
37
|
// than failing the whole rule parse.
|
|
38
38
|
os: z.coerce.string().optional(),
|
|
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(),
|
|
39
47
|
} satisfies AllKeys<UserAttributes>) satisfies ZodType<UserAttributes>;
|
|
40
48
|
|
|
41
49
|
const userAttributesInput = z
|
package/src/ruleRecord.ts
CHANGED
|
@@ -32,6 +32,11 @@ export const userAttributesRecordFields = [
|
|
|
32
32
|
"countryCode",
|
|
33
33
|
"asn",
|
|
34
34
|
"os",
|
|
35
|
+
"browser",
|
|
36
|
+
"headerMatch",
|
|
37
|
+
"headerName",
|
|
38
|
+
"headerValue",
|
|
39
|
+
"headerOperator",
|
|
35
40
|
] as const satisfies (keyof UserAttributesRecord)[];
|
|
36
41
|
|
|
37
42
|
export type UserIpRecord = {
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
BROWSER_NAMES,
|
|
18
|
+
type BrowserName,
|
|
19
|
+
classifyBrowser,
|
|
20
|
+
} from "#policy/classifyBrowser.js";
|
|
21
|
+
|
|
22
|
+
describe("classifyBrowser", () => {
|
|
23
|
+
const cases: Array<{ name: string; ua: string; expected: BrowserName }> = [
|
|
24
|
+
{
|
|
25
|
+
name: "Chrome on Windows",
|
|
26
|
+
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
27
|
+
expected: "chrome",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "Chrome on iOS",
|
|
31
|
+
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1",
|
|
32
|
+
expected: "chrome",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "Safari on macOS",
|
|
36
|
+
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
|
37
|
+
expected: "safari",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "Firefox on Linux",
|
|
41
|
+
ua: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
42
|
+
expected: "firefox",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Firefox on iOS",
|
|
46
|
+
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148 Safari/605.1.15",
|
|
47
|
+
expected: "firefox",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "Edge on Windows",
|
|
51
|
+
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
|
|
52
|
+
expected: "edge",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "Opera",
|
|
56
|
+
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0",
|
|
57
|
+
expected: "opera",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "Samsung Internet",
|
|
61
|
+
ua: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36",
|
|
62
|
+
expected: "samsung_internet",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "WeChat in-app browser",
|
|
66
|
+
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.42",
|
|
67
|
+
expected: "wechat",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "Facebook in-app browser",
|
|
71
|
+
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/442.0.0]",
|
|
72
|
+
expected: "facebook",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "Instagram in-app browser",
|
|
76
|
+
ua: "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Instagram 310.0.0.0 Android",
|
|
77
|
+
expected: "instagram",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "Internet Explorer 11",
|
|
81
|
+
ua: "Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko",
|
|
82
|
+
expected: "ie",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const { name, ua, expected } of cases) {
|
|
87
|
+
it(`classifies ${name} as ${expected}`, () => {
|
|
88
|
+
expect(classifyBrowser(ua)).toBe(expected);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it("returns 'unknown' for an empty User-Agent", () => {
|
|
93
|
+
expect(classifyBrowser("")).toBe("unknown");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns 'unknown' for an undefined User-Agent", () => {
|
|
97
|
+
expect(classifyBrowser(undefined)).toBe("unknown");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns 'unknown' for an unrecognised User-Agent", () => {
|
|
101
|
+
expect(classifyBrowser("curl/8.4.0")).toBe("unknown");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not classify a Chrome UA as Safari", () => {
|
|
105
|
+
expect(
|
|
106
|
+
classifyBrowser(
|
|
107
|
+
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
108
|
+
),
|
|
109
|
+
).toBe("chrome");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not classify an Edge UA as Chrome", () => {
|
|
113
|
+
expect(
|
|
114
|
+
classifyBrowser(
|
|
115
|
+
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
|
|
116
|
+
),
|
|
117
|
+
).toBe("edge");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not classify a Samsung Internet UA as Chrome", () => {
|
|
121
|
+
expect(
|
|
122
|
+
classifyBrowser(
|
|
123
|
+
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36",
|
|
124
|
+
),
|
|
125
|
+
).toBe("samsung_internet");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("is case-insensitive", () => {
|
|
129
|
+
expect(classifyBrowser("MOZILLA/5.0 FIREFOX/120.0")).toBe("firefox");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("only ever returns a value from BROWSER_NAMES", () => {
|
|
133
|
+
const uas = [...cases.map((c) => c.ua), "", "curl/8.4.0", "garbage"];
|
|
134
|
+
for (const ua of uas) {
|
|
135
|
+
expect(BROWSER_NAMES).toContain(classifyBrowser(ua));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|