@roboteby/parry 1.1.0-rc.1 → 2.0.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/CHANGELOG.md +54 -9
- package/README.md +89 -225
- package/config/defaults.js +4 -1
- package/config/validate.js +255 -0
- package/constants/patterns.js +3 -4
- package/package.json +55 -24
- package/src/admin/auth/admin-auth.js +81 -17
- package/src/admin/auth/strategies/none.js +6 -2
- package/src/core/engine.js +75 -15
- package/src/core/index.js +2 -2
- package/src/core/scoring.js +5 -1
- package/src/detectors/nosql.js +33 -13
- package/src/detectors/path-traversal.js +3 -1
- package/src/express/ip-resolver.js +16 -2
- package/src/express/middleware.js +13 -4
- package/src/express/request-targets.js +26 -16
- package/src/index.js +1 -1
- package/src/utils/decode.js +3 -1
- package/src/utils/normalize.js +3 -1
- package/types/admin.d.ts +26 -0
- package/types/brute-force.d.ts +44 -0
- package/types/core.d.ts +1 -0
- package/types/detectors.d.ts +9 -0
- package/types/events.d.ts +16 -0
- package/types/index.d.ts +68 -17
- package/types/observability.d.ts +12 -0
- package/types/policies.d.ts +21 -0
- package/types/stores.d.ts +10 -0
- package/src/core/logger.js +0 -3
- package/src/core/rateLimiter.js +0 -3
- package/src/middleware/index.js +0 -7
- package/src/middleware/parry_ddos.js +0 -3
- package/src/stores/README.md +0 -51
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ipaddr = require('ipaddr.js');
|
|
4
|
+
const { NOSQL_DANGEROUS_OPERATORS, NOSQL_SUSPICIOUS_OPERATORS } = require('../constants/patterns');
|
|
5
|
+
|
|
6
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
7
|
+
const NOSQL_PATH_PATTERN = /^(?:body|query|params)(?:\.|\[|$)/;
|
|
8
|
+
|
|
9
|
+
function assertObject(value, name) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
11
|
+
throw new TypeError(`${name} must be an object`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertPositive(value, name, { integer = false, allowZero = false } = {}) {
|
|
16
|
+
if (value === undefined) return;
|
|
17
|
+
if (
|
|
18
|
+
typeof value !== 'number' ||
|
|
19
|
+
!Number.isFinite(value) ||
|
|
20
|
+
(integer && !Number.isInteger(value)) ||
|
|
21
|
+
(allowZero ? value < 0 : value <= 0)
|
|
22
|
+
) {
|
|
23
|
+
throw new TypeError(
|
|
24
|
+
`${name} must be a ${allowZero ? 'non-negative' : 'positive'}${integer ? ' integer' : ' number'}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateIpOrCidr(value, name) {
|
|
30
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
31
|
+
throw new TypeError(`${name} must be a non-empty IP address or CIDR`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (value.includes('/')) ipaddr.parseCIDR(value);
|
|
36
|
+
else ipaddr.parse(value);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new TypeError(`${name} contains an invalid IP address or CIDR: ${value}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateTrustedProxies(value, name = 'trustedProxies') {
|
|
43
|
+
if (value === undefined) return;
|
|
44
|
+
if (!Array.isArray(value)) throw new TypeError(`${name} must be an array`);
|
|
45
|
+
value.forEach((entry, index) => validateIpOrCidr(entry, `${name}[${index}]`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeHeadersConfig(value) {
|
|
49
|
+
const defaultHeaders = ['user-agent', 'referer', 'x-forwarded-for', 'cookie'];
|
|
50
|
+
if (value === undefined) return { scan: defaultHeaders };
|
|
51
|
+
assertObject(value, 'headers');
|
|
52
|
+
const scan = value.scan === undefined ? defaultHeaders : value.scan;
|
|
53
|
+
if (!Array.isArray(scan)) throw new TypeError('headers.scan must be an array');
|
|
54
|
+
|
|
55
|
+
const normalized = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const header of scan) {
|
|
58
|
+
validateHeaderName(header, 'headers.scan');
|
|
59
|
+
const name = header.toLowerCase();
|
|
60
|
+
if (!seen.has(name)) {
|
|
61
|
+
seen.add(name);
|
|
62
|
+
normalized.push(name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { scan: normalized };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateHeaderName(value, name = 'header') {
|
|
69
|
+
if (typeof value !== 'string' || !HEADER_NAME_PATTERN.test(value)) {
|
|
70
|
+
throw new TypeError(`${name} contains an invalid header name: ${String(value)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeNoSQLConfig(value) {
|
|
75
|
+
if (value === undefined) return { enabled: true, allowedOperators: {} };
|
|
76
|
+
if (typeof value === 'boolean') return { enabled: value, allowedOperators: {} };
|
|
77
|
+
assertObject(value, 'nosql');
|
|
78
|
+
|
|
79
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
80
|
+
throw new TypeError('nosql.enabled must be a boolean');
|
|
81
|
+
}
|
|
82
|
+
const allowedOperators = value.allowedOperators || {};
|
|
83
|
+
assertObject(allowedOperators, 'nosql.allowedOperators');
|
|
84
|
+
|
|
85
|
+
const normalized = Object.create(null);
|
|
86
|
+
for (const [path, operators] of Object.entries(allowedOperators)) {
|
|
87
|
+
if (!NOSQL_PATH_PATTERN.test(path)) {
|
|
88
|
+
throw new TypeError(`nosql.allowedOperators contains an invalid exact path: ${path}`);
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(operators)) {
|
|
91
|
+
throw new TypeError(`nosql.allowedOperators.${path} must be an array`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
normalized[path] = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const operator of operators) {
|
|
97
|
+
if (typeof operator !== 'string' || !operator.startsWith('$')) {
|
|
98
|
+
throw new TypeError(`nosql.allowedOperators.${path} contains an invalid operator`);
|
|
99
|
+
}
|
|
100
|
+
if (NOSQL_DANGEROUS_OPERATORS.has(operator)) {
|
|
101
|
+
throw new TypeError(`NoSQL operator ${operator} can never be allowlisted`);
|
|
102
|
+
}
|
|
103
|
+
if (!NOSQL_SUSPICIOUS_OPERATORS.has(operator)) {
|
|
104
|
+
throw new TypeError(`Only suspicious NoSQL operators may be allowlisted: ${operator}`);
|
|
105
|
+
}
|
|
106
|
+
if (!seen.has(operator)) {
|
|
107
|
+
seen.add(operator);
|
|
108
|
+
normalized[path].push(operator);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
enabled: value.enabled !== false,
|
|
115
|
+
allowedOperators: normalized,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateRateConfig(rate, name) {
|
|
120
|
+
if (rate === undefined) return;
|
|
121
|
+
assertObject(rate, name);
|
|
122
|
+
assertPositive(rate.maxRequests, `${name}.maxRequests`, { integer: true });
|
|
123
|
+
assertPositive(rate.windowMs, `${name}.windowMs`, { integer: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateMatch(match, name) {
|
|
127
|
+
assertObject(match, name);
|
|
128
|
+
if (match.method === undefined && match.path === undefined) {
|
|
129
|
+
throw new TypeError(`${name} must define method or path`);
|
|
130
|
+
}
|
|
131
|
+
const validateList = (value, field, validateEntry) => {
|
|
132
|
+
const values = Array.isArray(value) ? value : [value];
|
|
133
|
+
if (values.length === 0 || values.some((entry) => !validateEntry(entry))) {
|
|
134
|
+
throw new TypeError(`${name}.${field} is invalid`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (match.method !== undefined) {
|
|
138
|
+
validateList(
|
|
139
|
+
match.method,
|
|
140
|
+
'method',
|
|
141
|
+
(entry) => typeof entry === 'string' && entry.trim() !== ''
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (match.path !== undefined) {
|
|
145
|
+
validateList(
|
|
146
|
+
match.path,
|
|
147
|
+
'path',
|
|
148
|
+
(entry) => entry instanceof RegExp || (typeof entry === 'string' && entry.trim() !== '')
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validateParryOptions(options) {
|
|
154
|
+
if (options === undefined) return;
|
|
155
|
+
assertObject(options, 'options');
|
|
156
|
+
|
|
157
|
+
assertPositive(options.maxRequests, 'maxRequests', { integer: true });
|
|
158
|
+
assertPositive(options.windowMs, 'windowMs', { integer: true });
|
|
159
|
+
assertPositive(options.banDurationMs, 'banDurationMs', { integer: true });
|
|
160
|
+
assertPositive(options.suspiciousThreshold, 'suspiciousThreshold', { integer: true });
|
|
161
|
+
assertPositive(options.maxObjectDepth, 'maxObjectDepth', { integer: true, allowZero: true });
|
|
162
|
+
if (options.rateLimit && typeof options.rateLimit === 'object') {
|
|
163
|
+
assertPositive(options.rateLimit.max, 'rateLimit.max', { integer: true });
|
|
164
|
+
assertPositive(options.rateLimit.maxRequests, 'rateLimit.maxRequests', { integer: true });
|
|
165
|
+
assertPositive(options.rateLimit.windowMs, 'rateLimit.windowMs', { integer: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (options.requestShape !== undefined) {
|
|
169
|
+
assertObject(options.requestShape, 'requestShape');
|
|
170
|
+
assertPositive(options.requestShape.maxDepth, 'requestShape.maxDepth', {
|
|
171
|
+
integer: true,
|
|
172
|
+
allowZero: true,
|
|
173
|
+
});
|
|
174
|
+
assertPositive(options.requestShape.maxKeys, 'requestShape.maxKeys', { integer: true });
|
|
175
|
+
assertPositive(options.requestShape.maxArrayLength, 'requestShape.maxArrayLength', {
|
|
176
|
+
integer: true,
|
|
177
|
+
});
|
|
178
|
+
assertPositive(options.requestShape.maxStringLength, 'requestShape.maxStringLength', {
|
|
179
|
+
integer: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options.events !== undefined) {
|
|
184
|
+
assertObject(options.events, 'events');
|
|
185
|
+
assertPositive(options.events.maxEvents, 'events.maxEvents', { integer: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options.requestId !== undefined) {
|
|
189
|
+
assertObject(options.requestId, 'requestId');
|
|
190
|
+
if (options.requestId.header !== undefined) {
|
|
191
|
+
validateHeaderName(options.requestId.header, 'requestId.header');
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
options.requestId.responseHeader !== undefined &&
|
|
195
|
+
options.requestId.responseHeader !== false
|
|
196
|
+
) {
|
|
197
|
+
validateHeaderName(options.requestId.responseHeader, 'requestId.responseHeader');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.admin !== undefined) {
|
|
202
|
+
assertObject(options.admin, 'admin');
|
|
203
|
+
if (options.admin.path !== undefined && !String(options.admin.path).startsWith('/')) {
|
|
204
|
+
throw new TypeError('admin.path must start with /');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (options.bruteForce !== undefined) {
|
|
209
|
+
assertObject(options.bruteForce, 'bruteForce');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (options.policies !== undefined) {
|
|
213
|
+
if (!Array.isArray(options.policies)) throw new TypeError('policies must be an array');
|
|
214
|
+
options.policies.forEach((policy, index) => {
|
|
215
|
+
assertObject(policy, `policies[${index}]`);
|
|
216
|
+
if (typeof policy.name !== 'string' || policy.name.trim() === '') {
|
|
217
|
+
throw new TypeError(`policies[${index}].name must be a non-empty string`);
|
|
218
|
+
}
|
|
219
|
+
validateMatch(policy.match, `policies[${index}].match`);
|
|
220
|
+
validateRateConfig(policy.rateLimit, `policies[${index}].rateLimit`);
|
|
221
|
+
if (policy.rateLimit) {
|
|
222
|
+
assertPositive(policy.rateLimit.max, `policies[${index}].rateLimit.max`, {
|
|
223
|
+
integer: true,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (policy.bruteForce) {
|
|
227
|
+
assertObject(policy.bruteForce, `policies[${index}].bruteForce`);
|
|
228
|
+
assertPositive(policy.bruteForce.maxAttempts, `policies[${index}].bruteForce.maxAttempts`, {
|
|
229
|
+
integer: true,
|
|
230
|
+
});
|
|
231
|
+
assertPositive(policy.bruteForce.windowMs, `policies[${index}].bruteForce.windowMs`, {
|
|
232
|
+
integer: true,
|
|
233
|
+
});
|
|
234
|
+
assertPositive(
|
|
235
|
+
policy.bruteForce.blockDurationMs,
|
|
236
|
+
`policies[${index}].bruteForce.blockDurationMs`,
|
|
237
|
+
{ integer: true }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
validateTrustedProxies(options.trustedProxies);
|
|
244
|
+
normalizeHeadersConfig(options.headers);
|
|
245
|
+
normalizeNoSQLConfig(options.nosql);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
normalizeHeadersConfig,
|
|
250
|
+
normalizeNoSQLConfig,
|
|
251
|
+
validateHeaderName,
|
|
252
|
+
validateIpOrCidr,
|
|
253
|
+
validateParryOptions,
|
|
254
|
+
validateTrustedProxies,
|
|
255
|
+
};
|
package/constants/patterns.js
CHANGED
|
@@ -17,8 +17,7 @@ const SQL_PATTERNS = [
|
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const XSS_PATTERNS = [
|
|
20
|
-
/<\s*script
|
|
21
|
-
/<\s*script\b[^>]*>/i,
|
|
20
|
+
/<\s*script\b[^>]{0,500}>/i,
|
|
22
21
|
/\bon\w+\s*=\s*["']?[^"'>]*/i,
|
|
23
22
|
/javascript\s*:/i,
|
|
24
23
|
/vbscript\s*:/i,
|
|
@@ -27,8 +26,8 @@ const XSS_PATTERNS = [
|
|
|
27
26
|
/<\s*svg\b[^>]*>[\s\S]*?(script|onload|onerror)/i,
|
|
28
27
|
/expression\s*\(/i,
|
|
29
28
|
/url\s*\(\s*["']?\s*javascript/i,
|
|
30
|
-
/\{\{[\s
|
|
31
|
-
/\$\{[\s
|
|
29
|
+
/\{\{[^{}]{0,200}(?:constructor(?:\.constructor)?|alert\s*\(|document\.|window\.)[^{}]{0,200}\}\}/i,
|
|
30
|
+
/\$\{[^{}]{0,200}(?:constructor(?:\.constructor)?|alert\s*\(|document\.|window\.)[^{}]{0,200}\}/i,
|
|
32
31
|
/<\s*(base|link|meta|style)\b[^>]*(http-equiv|href|content)\s*=\s*["']?[^"'>]*script/i,
|
|
33
32
|
/\0|%00/,
|
|
34
33
|
/autofocus.{0,30}onfocus/i,
|
package/package.json
CHANGED
|
@@ -1,39 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roboteby/parry",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Application-layer security middleware for Express.js with injection detection, abuse mitigation, brute-force protection and distributed rate limiting.",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "node tests/
|
|
9
|
-
"test:unit": "node
|
|
10
|
-
"test:
|
|
8
|
+
"test": "node --test tests/unit/*.test.js tests/integration/*.test.js tests/package/*.test.js",
|
|
9
|
+
"test:unit": "node --test tests/unit/*.test.js",
|
|
10
|
+
"test:integration": "node --test tests/integration/*.test.js",
|
|
11
|
+
"test:integ": "npm run test:integration",
|
|
12
|
+
"test:package": "node --test tests/package/*.test.js",
|
|
13
|
+
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
11
14
|
"test:fixtures": "node scripts/payloads/validate-fixtures.js",
|
|
12
|
-
"test:payload-regression": "node tests/regression
|
|
15
|
+
"test:payload-regression": "node --test tests/regression/*.test.js",
|
|
13
16
|
"test:payload-report": "node scripts/payloads/generate-payload-report.js",
|
|
14
17
|
"package:check": "node scripts/package/check-package.js",
|
|
15
18
|
"package:check-tag": "node scripts/package/check-version-tag.js",
|
|
16
19
|
"package:dry-run": "npm pack --dry-run",
|
|
17
20
|
"example": "node examples/express-basic.js",
|
|
18
|
-
"test:http": "node scripts/run-tests.js",
|
|
19
|
-
"start:test": "node scripts/test-server.js",
|
|
20
21
|
"docker:build:demo": "docker build -f docker/demo-api/Dockerfile -t parry-demo-api .",
|
|
21
|
-
"
|
|
22
|
-
"format
|
|
22
|
+
"lint": "eslint .",
|
|
23
|
+
"format": "prettier --write .",
|
|
24
|
+
"format:check": "prettier --check ."
|
|
23
25
|
},
|
|
24
26
|
"exports": {
|
|
25
27
|
".": {
|
|
26
28
|
"require": "./src/index.js",
|
|
27
29
|
"types": "./types/index.d.ts"
|
|
28
30
|
},
|
|
29
|
-
"./core":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"./
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
"./core": {
|
|
32
|
+
"require": "./src/core/index.js",
|
|
33
|
+
"types": "./types/core.d.ts"
|
|
34
|
+
},
|
|
35
|
+
"./detectors": {
|
|
36
|
+
"require": "./src/detectors/index.js",
|
|
37
|
+
"types": "./types/detectors.d.ts"
|
|
38
|
+
},
|
|
39
|
+
"./stores": {
|
|
40
|
+
"require": "./src/stores/index.js",
|
|
41
|
+
"types": "./types/stores.d.ts"
|
|
42
|
+
},
|
|
43
|
+
"./policies": {
|
|
44
|
+
"require": "./src/policies/index.js",
|
|
45
|
+
"types": "./types/policies.d.ts"
|
|
46
|
+
},
|
|
47
|
+
"./brute-force": {
|
|
48
|
+
"require": "./src/brute-force/index.js",
|
|
49
|
+
"types": "./types/brute-force.d.ts"
|
|
50
|
+
},
|
|
51
|
+
"./events": {
|
|
52
|
+
"require": "./src/events/index.js",
|
|
53
|
+
"types": "./types/events.d.ts"
|
|
54
|
+
},
|
|
55
|
+
"./observability": {
|
|
56
|
+
"require": "./src/observability/index.js",
|
|
57
|
+
"types": "./types/observability.d.ts"
|
|
58
|
+
},
|
|
59
|
+
"./admin": {
|
|
60
|
+
"require": "./src/admin/index.js",
|
|
61
|
+
"types": "./types/admin.d.ts"
|
|
62
|
+
},
|
|
37
63
|
"./package.json": "./package.json"
|
|
38
64
|
},
|
|
39
65
|
"files": [
|
|
@@ -66,24 +92,29 @@
|
|
|
66
92
|
"license": "MIT",
|
|
67
93
|
"repository": {
|
|
68
94
|
"type": "git",
|
|
69
|
-
"url": "git+
|
|
95
|
+
"url": "git+https://github.com/RobotEby/parry.git"
|
|
70
96
|
},
|
|
71
97
|
"bugs": {
|
|
72
|
-
"url": "https://github.com/RobotEby/parry
|
|
98
|
+
"url": "https://github.com/RobotEby/parry/issues"
|
|
73
99
|
},
|
|
74
|
-
"homepage": "https://github.com/RobotEby/parry
|
|
100
|
+
"homepage": "https://github.com/RobotEby/parry#readme",
|
|
75
101
|
"peerDependencies": {
|
|
76
102
|
"express": "^5.2.1"
|
|
77
103
|
},
|
|
78
104
|
"devDependencies": {
|
|
79
|
-
"eslint
|
|
105
|
+
"@eslint/js": "^10.0.1",
|
|
106
|
+
"@types/express": "^5.0.6",
|
|
107
|
+
"@types/node": "^26.4.0",
|
|
108
|
+
"eslint": "^10.9.1",
|
|
80
109
|
"express": "^5.2.1",
|
|
81
|
-
"
|
|
110
|
+
"globals": "^17.11.0",
|
|
111
|
+
"prettier": "^3.9.6",
|
|
112
|
+
"typescript": "^7.0.2"
|
|
82
113
|
},
|
|
83
114
|
"engines": {
|
|
84
|
-
"node": ">=
|
|
115
|
+
"node": ">=22"
|
|
85
116
|
},
|
|
86
117
|
"dependencies": {
|
|
87
|
-
"ipaddr.js": "^2.
|
|
118
|
+
"ipaddr.js": "^2.5.0"
|
|
88
119
|
}
|
|
89
120
|
}
|
|
@@ -5,27 +5,59 @@ const { authenticateToken } = require('./strategies/token');
|
|
|
5
5
|
const { authenticateIpAllowlist } = require('./strategies/ip-allowlist');
|
|
6
6
|
const { authenticateTrustedProxy } = require('./strategies/trusted-proxy');
|
|
7
7
|
const { authenticateCombined } = require('./strategies/combined');
|
|
8
|
-
const { authenticateNone } = require('./strategies/none');
|
|
8
|
+
const { authenticateNone, warnInsecureAdminApi } = require('./strategies/none');
|
|
9
9
|
const { authenticateCloudflareAccess } = require('./strategies/cloudflare-access');
|
|
10
10
|
const { authenticateAlbAuth } = require('./strategies/alb-auth');
|
|
11
|
+
const { validateHeaderName, validateTrustedProxies } = require('../../../config/validate');
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_MODES = new Set([
|
|
14
|
+
'token',
|
|
15
|
+
'ip-allowlist',
|
|
16
|
+
'trusted-proxy',
|
|
17
|
+
'cloudflare-access',
|
|
18
|
+
'alb-auth',
|
|
19
|
+
'cognito-alb',
|
|
20
|
+
'combined',
|
|
21
|
+
'none',
|
|
22
|
+
]);
|
|
11
23
|
|
|
12
24
|
function requireAdminAuth(options = {}, context = null) {
|
|
13
25
|
if (typeof options.auth === 'function') return createLegacyCallbackMiddleware(options.auth);
|
|
14
26
|
|
|
15
|
-
|
|
27
|
+
const contextAuth = context?.config?.admin?.auth;
|
|
28
|
+
const authConfig = isAuthConfig(options.auth) ? options.auth : contextAuth;
|
|
29
|
+
if (authConfig) {
|
|
30
|
+
return createAdminAuthMiddleware(authConfig, {
|
|
31
|
+
...context,
|
|
32
|
+
admin: context?.config?.admin,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const insecureOptIn =
|
|
37
|
+
options.allowInsecureAdminApi === true ||
|
|
38
|
+
options.requireAuth === false ||
|
|
39
|
+
context?.config?.admin?.allowInsecureAdminApi === true;
|
|
40
|
+
|
|
41
|
+
if (insecureOptIn) {
|
|
42
|
+
return createAdminAuthMiddleware(
|
|
43
|
+
{ mode: 'none', allowInsecureAdminApi: true },
|
|
44
|
+
{ ...context, admin: context?.config?.admin }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.env.NODE_ENV === 'production') {
|
|
49
|
+
throw new Error('Admin API requires an authentication strategy in production.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (options.requireAuth === true) {
|
|
16
53
|
return function missingAuthMiddleware(_req, res) {
|
|
17
54
|
return unauthorized(res);
|
|
18
55
|
};
|
|
19
56
|
}
|
|
20
57
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return createAdminAuthMiddleware(authConfig, {
|
|
26
|
-
...context,
|
|
27
|
-
admin: context?.config?.admin,
|
|
28
|
-
});
|
|
58
|
+
throw new Error(
|
|
59
|
+
'Admin API requires authentication. Configure auth or explicitly allow insecure local access.'
|
|
60
|
+
);
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
function createAdminAuthMiddleware(config, context = {}) {
|
|
@@ -67,17 +99,51 @@ async function authenticateAdminRequest(req, config, context = {}) {
|
|
|
67
99
|
function validateAdminAuthConfig(config, context = {}) {
|
|
68
100
|
const mode = normalizeMode(config?.mode);
|
|
69
101
|
|
|
102
|
+
if (!SUPPORTED_MODES.has(mode)) {
|
|
103
|
+
throw new Error(`Unsupported Admin API auth mode: ${mode}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
70
106
|
if (mode === 'token' && !hasNonEmptyString(config.token)) {
|
|
71
107
|
throw new Error('Admin API token auth requires a non-empty token.');
|
|
72
108
|
}
|
|
109
|
+
if (mode === 'token' && config.header !== undefined) {
|
|
110
|
+
validateHeaderName(config.header, 'auth.header');
|
|
111
|
+
}
|
|
73
112
|
|
|
74
113
|
if (mode === 'ip-allowlist' && !hasNonEmptyArray(config.allowedIps)) {
|
|
75
114
|
throw new Error('Admin API ip-allowlist auth requires allowedIps.');
|
|
76
115
|
}
|
|
116
|
+
if (mode === 'ip-allowlist') validateTrustedProxies(config.allowedIps, 'auth.allowedIps');
|
|
77
117
|
|
|
78
118
|
if (mode === 'trusted-proxy' && !hasNonEmptyArray(config.trustedProxies)) {
|
|
79
119
|
throw new Error('Admin API trusted-proxy auth requires trustedProxies.');
|
|
80
120
|
}
|
|
121
|
+
if (config.trustedProxies !== undefined) {
|
|
122
|
+
validateTrustedProxies(config.trustedProxies, 'auth.trustedProxies');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const field of [
|
|
126
|
+
'proxySharedSecretHeader',
|
|
127
|
+
'userHeader',
|
|
128
|
+
'emailHeader',
|
|
129
|
+
'rolesHeader',
|
|
130
|
+
'jwtHeader',
|
|
131
|
+
'dataHeader',
|
|
132
|
+
]) {
|
|
133
|
+
if (config[field] !== undefined) validateHeaderName(config[field], `auth.${field}`);
|
|
134
|
+
}
|
|
135
|
+
if (config.requiredHeaders !== undefined) {
|
|
136
|
+
if (
|
|
137
|
+
!config.requiredHeaders ||
|
|
138
|
+
typeof config.requiredHeaders !== 'object' ||
|
|
139
|
+
Array.isArray(config.requiredHeaders)
|
|
140
|
+
) {
|
|
141
|
+
throw new TypeError('auth.requiredHeaders must be an object');
|
|
142
|
+
}
|
|
143
|
+
for (const header of Object.keys(config.requiredHeaders)) {
|
|
144
|
+
validateHeaderName(header, 'auth.requiredHeaders');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
81
147
|
|
|
82
148
|
if (mode === 'cloudflare-access' || mode === 'alb-auth' || mode === 'cognito-alb') {
|
|
83
149
|
validateExternalAuthConfig(mode, config);
|
|
@@ -101,13 +167,11 @@ function validateAdminAuthConfig(config, context = {}) {
|
|
|
101
167
|
}
|
|
102
168
|
}
|
|
103
169
|
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
) {
|
|
110
|
-
throw new Error('Admin API auth mode "none" is not allowed in production.');
|
|
170
|
+
if (mode === 'none') {
|
|
171
|
+
if (process.env.NODE_ENV === 'production') {
|
|
172
|
+
throw new Error('Admin API auth mode "none" is not allowed in production.');
|
|
173
|
+
}
|
|
174
|
+
warnInsecureAdminApi();
|
|
111
175
|
}
|
|
112
176
|
}
|
|
113
177
|
|
|
@@ -5,16 +5,20 @@ const { success } = require('../utils/result');
|
|
|
5
5
|
|
|
6
6
|
let warned = false;
|
|
7
7
|
|
|
8
|
-
function
|
|
8
|
+
function warnInsecureAdminApi() {
|
|
9
9
|
if (!warned) {
|
|
10
10
|
warned = true;
|
|
11
11
|
console.warn(
|
|
12
12
|
'[parry] Admin API auth mode "none" is insecure and intended only for local development.'
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function authenticateNone(req, config) {
|
|
18
|
+
warnInsecureAdminApi();
|
|
15
19
|
|
|
16
20
|
const ip = getClientIp(req, config);
|
|
17
21
|
return success(req, 'none', { subject: 'insecure-none', ip }, config);
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
module.exports = { authenticateNone };
|
|
24
|
+
module.exports = { authenticateNone, warnInsecureAdminApi };
|