@roboteby/parry 1.1.0-rc.1
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 +19 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/config/defaults.js +65 -0
- package/constants/patterns.js +77 -0
- package/package.json +89 -0
- package/src/admin/admin-router.js +106 -0
- package/src/admin/auth/admin-auth.js +176 -0
- package/src/admin/auth/index.js +13 -0
- package/src/admin/auth/strategies/alb-auth.js +49 -0
- package/src/admin/auth/strategies/cloudflare-access.js +34 -0
- package/src/admin/auth/strategies/combined.js +50 -0
- package/src/admin/auth/strategies/ip-allowlist.js +13 -0
- package/src/admin/auth/strategies/none.js +20 -0
- package/src/admin/auth/strategies/token.js +25 -0
- package/src/admin/auth/strategies/trusted-proxy.js +52 -0
- package/src/admin/auth/utils/constant-time.js +18 -0
- package/src/admin/auth/utils/external-identity.js +156 -0
- package/src/admin/auth/utils/header-utils.js +39 -0
- package/src/admin/auth/utils/result.js +39 -0
- package/src/admin/ban-normalizer.js +98 -0
- package/src/admin/index.js +12 -0
- package/src/admin/response.js +41 -0
- package/src/brute-force/brute-force-guard.js +268 -0
- package/src/brute-force/index.js +32 -0
- package/src/brute-force/key-builder.js +164 -0
- package/src/brute-force/result.js +35 -0
- package/src/core/engine.js +264 -0
- package/src/core/index.js +7 -0
- package/src/core/logger.js +3 -0
- package/src/core/rate-limit-result.js +13 -0
- package/src/core/rateLimiter.js +3 -0
- package/src/core/scoring.js +18 -0
- package/src/core/threat-event.js +69 -0
- package/src/detectors/hpp.js +30 -0
- package/src/detectors/index.js +19 -0
- package/src/detectors/nosql.js +53 -0
- package/src/detectors/path-traversal.js +72 -0
- package/src/detectors/prototype-pollution.js +69 -0
- package/src/detectors/request-shape.js +76 -0
- package/src/detectors/sql.js +18 -0
- package/src/detectors/xss.js +18 -0
- package/src/events/event-bus.js +51 -0
- package/src/events/index.js +19 -0
- package/src/events/memory-event-store.js +64 -0
- package/src/events/sanitize-event.js +54 -0
- package/src/events/threat-event.js +174 -0
- package/src/express/ip-resolver.js +109 -0
- package/src/express/middleware.js +379 -0
- package/src/express/request-targets.js +35 -0
- package/src/express/response.js +14 -0
- package/src/index.js +41 -0
- package/src/logger/console-reporter.js +75 -0
- package/src/middleware/index.js +7 -0
- package/src/middleware/parry_ddos.js +3 -0
- package/src/observability/index.js +6 -0
- package/src/observability/metrics.js +61 -0
- package/src/observability/snapshot.js +48 -0
- package/src/policies/index.js +15 -0
- package/src/policies/matcher.js +48 -0
- package/src/policies/normalize-policy.js +94 -0
- package/src/policies/presets.js +34 -0
- package/src/rate-limit/keys.js +7 -0
- package/src/rate-limit/limiter.js +124 -0
- package/src/stores/README.md +51 -0
- package/src/stores/index.js +6 -0
- package/src/stores/memory-store.js +278 -0
- package/src/stores/redis-store.js +349 -0
- package/src/utils/decode.js +56 -0
- package/src/utils/flatten.js +27 -0
- package/src/utils/normalize.js +21 -0
- package/types/index.d.ts +555 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { unauthorized, forbidden } = require('../response');
|
|
4
|
+
const { authenticateToken } = require('./strategies/token');
|
|
5
|
+
const { authenticateIpAllowlist } = require('./strategies/ip-allowlist');
|
|
6
|
+
const { authenticateTrustedProxy } = require('./strategies/trusted-proxy');
|
|
7
|
+
const { authenticateCombined } = require('./strategies/combined');
|
|
8
|
+
const { authenticateNone } = require('./strategies/none');
|
|
9
|
+
const { authenticateCloudflareAccess } = require('./strategies/cloudflare-access');
|
|
10
|
+
const { authenticateAlbAuth } = require('./strategies/alb-auth');
|
|
11
|
+
|
|
12
|
+
function requireAdminAuth(options = {}, context = null) {
|
|
13
|
+
if (typeof options.auth === 'function') return createLegacyCallbackMiddleware(options.auth);
|
|
14
|
+
|
|
15
|
+
if (options.requireAuth && !options.auth) {
|
|
16
|
+
return function missingAuthMiddleware(_req, res) {
|
|
17
|
+
return unauthorized(res);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contextAuth = context?.config?.admin?.auth;
|
|
22
|
+
const authConfig = isAuthConfig(options.auth) ? options.auth : contextAuth;
|
|
23
|
+
if (!authConfig) return (_req, _res, next) => next();
|
|
24
|
+
|
|
25
|
+
return createAdminAuthMiddleware(authConfig, {
|
|
26
|
+
...context,
|
|
27
|
+
admin: context?.config?.admin,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createAdminAuthMiddleware(config, context = {}) {
|
|
32
|
+
validateAdminAuthConfig(config, context);
|
|
33
|
+
|
|
34
|
+
return function adminAuthMiddleware(req, res, next) {
|
|
35
|
+
return Promise.resolve(authenticateAdminRequest(req, config, context))
|
|
36
|
+
.then((result) => {
|
|
37
|
+
if (result.ok) {
|
|
38
|
+
req.parryAdmin = result.admin;
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (result.statusCode === 403) return forbidden(res);
|
|
43
|
+
return unauthorized(res);
|
|
44
|
+
})
|
|
45
|
+
.catch((error) => next(error));
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function authenticateAdminRequest(req, config, context = {}) {
|
|
50
|
+
const mode = normalizeMode(config?.mode);
|
|
51
|
+
|
|
52
|
+
if (mode === 'token') return authenticateToken(req, config, context);
|
|
53
|
+
if (mode === 'ip-allowlist') return authenticateIpAllowlist(req, config, context);
|
|
54
|
+
if (mode === 'trusted-proxy') return authenticateTrustedProxy(req, config, context);
|
|
55
|
+
if (mode === 'cloudflare-access') {
|
|
56
|
+
return authenticateCloudflareAccess(req, { ...config, mode }, context);
|
|
57
|
+
}
|
|
58
|
+
if (mode === 'alb-auth' || mode === 'cognito-alb') {
|
|
59
|
+
return authenticateAlbAuth(req, { ...config, mode }, context);
|
|
60
|
+
}
|
|
61
|
+
if (mode === 'combined') return authenticateCombined(req, config, context);
|
|
62
|
+
if (mode === 'none') return authenticateNone(req, config, context);
|
|
63
|
+
|
|
64
|
+
throw new Error(`Unsupported Admin API auth mode: ${mode}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateAdminAuthConfig(config, context = {}) {
|
|
68
|
+
const mode = normalizeMode(config?.mode);
|
|
69
|
+
|
|
70
|
+
if (mode === 'token' && !hasNonEmptyString(config.token)) {
|
|
71
|
+
throw new Error('Admin API token auth requires a non-empty token.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (mode === 'ip-allowlist' && !hasNonEmptyArray(config.allowedIps)) {
|
|
75
|
+
throw new Error('Admin API ip-allowlist auth requires allowedIps.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (mode === 'trusted-proxy' && !hasNonEmptyArray(config.trustedProxies)) {
|
|
79
|
+
throw new Error('Admin API trusted-proxy auth requires trustedProxies.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (mode === 'cloudflare-access' || mode === 'alb-auth' || mode === 'cognito-alb') {
|
|
83
|
+
validateExternalAuthConfig(mode, config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (mode === 'combined') {
|
|
87
|
+
const hasAllowAny = hasNonEmptyArray(config.allowAny);
|
|
88
|
+
const hasRequireAll = hasNonEmptyArray(config.requireAll);
|
|
89
|
+
if (hasAllowAny && hasRequireAll) {
|
|
90
|
+
throw new Error('Admin API combined auth accepts either allowAny or requireAll, not both.');
|
|
91
|
+
}
|
|
92
|
+
if (!hasAllowAny && !hasRequireAll) {
|
|
93
|
+
throw new Error('Admin API combined auth requires allowAny or requireAll.');
|
|
94
|
+
}
|
|
95
|
+
const children = hasAllowAny ? config.allowAny : config.requireAll;
|
|
96
|
+
for (const child of children) {
|
|
97
|
+
if (normalizeMode(child?.mode) === 'combined') {
|
|
98
|
+
throw new Error('Admin API combined auth cannot contain nested combined strategies.');
|
|
99
|
+
}
|
|
100
|
+
validateAdminAuthConfig(child, context);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
mode === 'none' &&
|
|
106
|
+
process.env.NODE_ENV === 'production' &&
|
|
107
|
+
!config.allowInsecureAdminApi &&
|
|
108
|
+
!context?.admin?.allowInsecureAdminApi
|
|
109
|
+
) {
|
|
110
|
+
throw new Error('Admin API auth mode "none" is not allowed in production.');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateExternalAuthConfig(mode, config) {
|
|
115
|
+
if (config.verifyJwt === true) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Admin API auth mode "${mode}" does not implement cryptographic JWT/JWKS verification in this version.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const hasTrustedProxies = hasNonEmptyArray(config.trustedProxies);
|
|
122
|
+
const hasSharedSecret = hasNonEmptyString(config.proxySharedSecret);
|
|
123
|
+
|
|
124
|
+
if (!hasTrustedProxies && !hasSharedSecret) {
|
|
125
|
+
throw new Error(`Admin API auth mode "${mode}" requires trustedProxies or proxySharedSecret.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createLegacyCallbackMiddleware(authCallback) {
|
|
130
|
+
return async function legacyAdminAuthMiddleware(req, res, next) {
|
|
131
|
+
try {
|
|
132
|
+
const allowed = await authCallback(req);
|
|
133
|
+
if (!allowed) return unauthorized(res);
|
|
134
|
+
|
|
135
|
+
req.parryAdmin = {
|
|
136
|
+
authenticated: true,
|
|
137
|
+
strategy: 'callback',
|
|
138
|
+
subject: 'callback',
|
|
139
|
+
email: null,
|
|
140
|
+
roles: [],
|
|
141
|
+
ip: req.ip || req.socket?.remoteAddress || 'unknown',
|
|
142
|
+
};
|
|
143
|
+
return next();
|
|
144
|
+
} catch (_error) {
|
|
145
|
+
return unauthorized(res);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isAuthConfig(value) {
|
|
151
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeMode(mode) {
|
|
155
|
+
const normalized = String(mode || 'token')
|
|
156
|
+
.trim()
|
|
157
|
+
.toLowerCase();
|
|
158
|
+
if (normalized === 'alb-cognito') return 'cognito-alb';
|
|
159
|
+
return normalized;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hasNonEmptyString(value) {
|
|
163
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasNonEmptyArray(value) {
|
|
167
|
+
return Array.isArray(value) && value.length > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
createAdminAuthMiddleware,
|
|
172
|
+
authenticateAdminRequest,
|
|
173
|
+
requireAdminAuth,
|
|
174
|
+
validateAdminAuthConfig,
|
|
175
|
+
normalizeMode,
|
|
176
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readHeader } = require('../utils/header-utils');
|
|
4
|
+
const { success, unauthorized, forbidden } = require('../utils/result');
|
|
5
|
+
const {
|
|
6
|
+
authenticateTrustedBoundary,
|
|
7
|
+
normalizeEmail,
|
|
8
|
+
normalizeSubject,
|
|
9
|
+
emailMatchesAllowlist,
|
|
10
|
+
subjectMatchesAllowlist,
|
|
11
|
+
hasEmailAllowlist,
|
|
12
|
+
decodeJwtClaimsUnsafe,
|
|
13
|
+
extractEmailFromClaims,
|
|
14
|
+
} = require('../utils/external-identity');
|
|
15
|
+
|
|
16
|
+
function authenticateAlbAuth(req, config) {
|
|
17
|
+
const boundary = authenticateTrustedBoundary(req, config);
|
|
18
|
+
if (!boundary.ok) return boundary;
|
|
19
|
+
|
|
20
|
+
const userHeader = config.userHeader || 'x-amzn-oidc-identity';
|
|
21
|
+
const dataHeader = config.dataHeader || 'x-amzn-oidc-data';
|
|
22
|
+
const subject = normalizeSubject(readHeader(req, userHeader));
|
|
23
|
+
if (!subject) return unauthorized();
|
|
24
|
+
|
|
25
|
+
if (!subjectMatchesAllowlist(subject, config.allowedSubjects)) return forbidden();
|
|
26
|
+
|
|
27
|
+
const claims = decodeJwtClaimsUnsafe(readHeader(req, dataHeader));
|
|
28
|
+
const email =
|
|
29
|
+
extractEmailFromClaims(claims) ||
|
|
30
|
+
normalizeEmail(readHeader(req, config.emailHeader || 'x-amzn-oidc-email'));
|
|
31
|
+
|
|
32
|
+
if (hasEmailAllowlist(config) && !emailMatchesAllowlist(email, config)) return forbidden();
|
|
33
|
+
|
|
34
|
+
const strategy = config.mode === 'cognito-alb' ? 'cognito-alb' : 'alb-auth';
|
|
35
|
+
|
|
36
|
+
return success(
|
|
37
|
+
req,
|
|
38
|
+
strategy,
|
|
39
|
+
{
|
|
40
|
+
subject,
|
|
41
|
+
email: email || null,
|
|
42
|
+
roles: [],
|
|
43
|
+
ip: boundary.ip,
|
|
44
|
+
},
|
|
45
|
+
config
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { authenticateAlbAuth };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readHeader } = require('../utils/header-utils');
|
|
4
|
+
const { success, unauthorized, forbidden } = require('../utils/result');
|
|
5
|
+
const {
|
|
6
|
+
authenticateTrustedBoundary,
|
|
7
|
+
normalizeEmail,
|
|
8
|
+
emailMatchesAllowlist,
|
|
9
|
+
} = require('../utils/external-identity');
|
|
10
|
+
|
|
11
|
+
function authenticateCloudflareAccess(req, config) {
|
|
12
|
+
const boundary = authenticateTrustedBoundary(req, config);
|
|
13
|
+
if (!boundary.ok) return boundary;
|
|
14
|
+
|
|
15
|
+
const emailHeader = config.emailHeader || 'cf-access-authenticated-user-email';
|
|
16
|
+
const email = normalizeEmail(readHeader(req, emailHeader));
|
|
17
|
+
if (!email) return unauthorized();
|
|
18
|
+
|
|
19
|
+
if (!emailMatchesAllowlist(email, config)) return forbidden();
|
|
20
|
+
|
|
21
|
+
return success(
|
|
22
|
+
req,
|
|
23
|
+
'cloudflare-access',
|
|
24
|
+
{
|
|
25
|
+
subject: email,
|
|
26
|
+
email,
|
|
27
|
+
roles: [],
|
|
28
|
+
ip: boundary.ip,
|
|
29
|
+
},
|
|
30
|
+
config
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { authenticateCloudflareAccess };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { success } = require('../utils/result');
|
|
4
|
+
|
|
5
|
+
async function authenticateCombined(req, config, context) {
|
|
6
|
+
const allowAny = Array.isArray(config.allowAny) ? config.allowAny : null;
|
|
7
|
+
const requireAll = Array.isArray(config.requireAll) ? config.requireAll : null;
|
|
8
|
+
|
|
9
|
+
if (allowAny) return authenticateAny(req, allowAny, context);
|
|
10
|
+
return authenticateAll(req, requireAll || [], context);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function authenticateAny(req, strategies, context) {
|
|
14
|
+
let sawForbidden = false;
|
|
15
|
+
const { authenticateAdminRequest } = require('../admin-auth');
|
|
16
|
+
|
|
17
|
+
for (const strategyConfig of strategies) {
|
|
18
|
+
const result = await authenticateAdminRequest(req, strategyConfig, context);
|
|
19
|
+
if (result.ok) {
|
|
20
|
+
return success(req, 'combined', {
|
|
21
|
+
...result.admin,
|
|
22
|
+
strategy: 'combined',
|
|
23
|
+
subject: result.admin.subject,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (result.statusCode === 403) sawForbidden = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return sawForbidden ? { ok: false, statusCode: 403 } : { ok: false, statusCode: 401 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function authenticateAll(req, strategies, context) {
|
|
33
|
+
const { authenticateAdminRequest } = require('../admin-auth');
|
|
34
|
+
let lastAdmin = null;
|
|
35
|
+
|
|
36
|
+
for (const strategyConfig of strategies) {
|
|
37
|
+
const result = await authenticateAdminRequest(req, strategyConfig, context);
|
|
38
|
+
if (!result.ok) return result;
|
|
39
|
+
lastAdmin = result.admin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return success(req, 'combined', {
|
|
43
|
+
...lastAdmin,
|
|
44
|
+
strategy: 'combined',
|
|
45
|
+
subject: lastAdmin?.subject || 'combined',
|
|
46
|
+
roles: lastAdmin?.roles || [],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { authenticateCombined };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp, isIpAllowed } = require('../../../express/ip-resolver');
|
|
4
|
+
const { success, forbidden } = require('../utils/result');
|
|
5
|
+
|
|
6
|
+
function authenticateIpAllowlist(req, config) {
|
|
7
|
+
const ip = getClientIp(req, config);
|
|
8
|
+
if (!isIpAllowed(ip, config.allowedIps)) return forbidden();
|
|
9
|
+
|
|
10
|
+
return success(req, 'ip-allowlist', { subject: `ip:${ip}`, ip }, config);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { authenticateIpAllowlist };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp } = require('../../../express/ip-resolver');
|
|
4
|
+
const { success } = require('../utils/result');
|
|
5
|
+
|
|
6
|
+
let warned = false;
|
|
7
|
+
|
|
8
|
+
function authenticateNone(req, config) {
|
|
9
|
+
if (!warned) {
|
|
10
|
+
warned = true;
|
|
11
|
+
console.warn(
|
|
12
|
+
'[parry] Admin API auth mode "none" is insecure and intended only for local development.'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ip = getClientIp(req, config);
|
|
17
|
+
return success(req, 'none', { subject: 'insecure-none', ip }, config);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { authenticateNone };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp } = require('../../../express/ip-resolver');
|
|
4
|
+
const { readHeader } = require('../utils/header-utils');
|
|
5
|
+
const { safeCompare } = require('../utils/constant-time');
|
|
6
|
+
const { success, unauthorized, forbidden } = require('../utils/result');
|
|
7
|
+
|
|
8
|
+
function authenticateToken(req, config) {
|
|
9
|
+
const headerName = config.header || 'x-parry-admin-token';
|
|
10
|
+
const supplied = readHeader(req, headerName);
|
|
11
|
+
if (!supplied) return unauthorized();
|
|
12
|
+
if (!safeCompare(String(config.token), String(supplied))) return forbidden();
|
|
13
|
+
|
|
14
|
+
return success(
|
|
15
|
+
req,
|
|
16
|
+
'token',
|
|
17
|
+
{
|
|
18
|
+
subject: 'local-token',
|
|
19
|
+
ip: getClientIp(req, config),
|
|
20
|
+
},
|
|
21
|
+
config
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { authenticateToken };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp, getDirectIp, isTrustedProxy } = require('../../../express/ip-resolver');
|
|
4
|
+
const { safeCompare } = require('../utils/constant-time');
|
|
5
|
+
const {
|
|
6
|
+
readHeader,
|
|
7
|
+
hasHeader,
|
|
8
|
+
headerEquals,
|
|
9
|
+
sanitizeHeaderValue,
|
|
10
|
+
parseRoles,
|
|
11
|
+
} = require('../utils/header-utils');
|
|
12
|
+
const { success, unauthorized, forbidden } = require('../utils/result');
|
|
13
|
+
|
|
14
|
+
function authenticateTrustedProxy(req, config) {
|
|
15
|
+
const directIp = getDirectIp(req);
|
|
16
|
+
if (!isTrustedProxy(directIp, config.trustedProxies || [])) return forbidden();
|
|
17
|
+
|
|
18
|
+
const requiredHeaders = config.requiredHeaders || {};
|
|
19
|
+
for (const [headerName, expectedValue] of Object.entries(requiredHeaders)) {
|
|
20
|
+
if (!hasHeader(req, headerName)) return unauthorized();
|
|
21
|
+
if (!headerEquals(req, headerName, expectedValue)) return forbidden();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (config.proxySharedSecret) {
|
|
25
|
+
const headerName = config.proxySharedSecretHeader || 'x-parry-proxy-secret';
|
|
26
|
+
const supplied = readHeader(req, headerName);
|
|
27
|
+
if (!supplied) return unauthorized();
|
|
28
|
+
if (!safeCompare(String(config.proxySharedSecret), String(supplied))) return forbidden();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const user = sanitizeHeaderValue(readHeader(req, config.userHeader || 'x-parry-admin-user'));
|
|
32
|
+
const email = sanitizeHeaderValue(readHeader(req, config.emailHeader || 'x-parry-admin-email'));
|
|
33
|
+
const roles = parseRoles(readHeader(req, config.rolesHeader || 'x-parry-admin-roles'));
|
|
34
|
+
const clientIp = getClientIp(req, {
|
|
35
|
+
...config,
|
|
36
|
+
trustProxyHeaders: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return success(
|
|
40
|
+
req,
|
|
41
|
+
'trusted-proxy',
|
|
42
|
+
{
|
|
43
|
+
subject: user ? `proxy:${user}` : email ? `proxy:${email}` : `proxy:${clientIp}`,
|
|
44
|
+
email: email || null,
|
|
45
|
+
roles,
|
|
46
|
+
ip: clientIp,
|
|
47
|
+
},
|
|
48
|
+
config
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { authenticateTrustedProxy };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function safeCompare(expected, actual) {
|
|
6
|
+
if (typeof expected !== 'string' || typeof actual !== 'string') return false;
|
|
7
|
+
if (!expected || !actual) return false;
|
|
8
|
+
|
|
9
|
+
const expectedHash = hash(expected);
|
|
10
|
+
const actualHash = hash(actual);
|
|
11
|
+
return crypto.timingSafeEqual(expectedHash, actualHash);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hash(value) {
|
|
15
|
+
return crypto.createHash('sha256').update(value, 'utf8').digest();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { safeCompare };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp, getDirectIp, isTrustedProxy } = require('../../../express/ip-resolver');
|
|
4
|
+
const { safeCompare } = require('./constant-time');
|
|
5
|
+
const { readHeader, sanitizeHeaderValue } = require('./header-utils');
|
|
6
|
+
const { unauthorized, forbidden } = require('./result');
|
|
7
|
+
|
|
8
|
+
function authenticateTrustedBoundary(req, config = {}) {
|
|
9
|
+
const trustedProxies = Array.isArray(config.trustedProxies) ? config.trustedProxies : [];
|
|
10
|
+
const hasTrustedProxyBoundary = trustedProxies.length > 0;
|
|
11
|
+
const hasSharedSecretBoundary = hasNonEmptyString(config.proxySharedSecret);
|
|
12
|
+
|
|
13
|
+
if (!hasTrustedProxyBoundary && !hasSharedSecretBoundary) return forbidden();
|
|
14
|
+
|
|
15
|
+
const directIp = getDirectIp(req);
|
|
16
|
+
const trustedProxyMatched = hasTrustedProxyBoundary && isTrustedProxy(directIp, trustedProxies);
|
|
17
|
+
|
|
18
|
+
if (trustedProxyMatched) {
|
|
19
|
+
const supplied = hasSharedSecretBoundary
|
|
20
|
+
? readHeader(req, config.proxySharedSecretHeader || 'x-parry-proxy-secret')
|
|
21
|
+
: '';
|
|
22
|
+
if (supplied && !safeCompare(String(config.proxySharedSecret), String(supplied))) {
|
|
23
|
+
return forbidden();
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
ip: getClientIp(req, {
|
|
28
|
+
...config,
|
|
29
|
+
trustProxyHeaders: true,
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (hasSharedSecretBoundary) {
|
|
35
|
+
const headerName = config.proxySharedSecretHeader || 'x-parry-proxy-secret';
|
|
36
|
+
const supplied = readHeader(req, headerName);
|
|
37
|
+
if (!supplied) return unauthorized();
|
|
38
|
+
if (!safeCompare(String(config.proxySharedSecret), String(supplied))) return forbidden();
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
ip: getClientIp(req, {
|
|
42
|
+
...config,
|
|
43
|
+
trustProxyHeaders: true,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return forbidden();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeEmail(value) {
|
|
52
|
+
const email = sanitizeHeaderValue(value, 320).toLowerCase();
|
|
53
|
+
if (!email) return '';
|
|
54
|
+
|
|
55
|
+
const atIndex = email.indexOf('@');
|
|
56
|
+
if (atIndex <= 0 || atIndex !== email.lastIndexOf('@') || atIndex === email.length - 1) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return email;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeSubject(value) {
|
|
64
|
+
return sanitizeHeaderValue(value, 256);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeList(values, options = {}) {
|
|
68
|
+
const lowercase = options.lowercase !== false;
|
|
69
|
+
const list = Array.isArray(values) ? values : [];
|
|
70
|
+
return list
|
|
71
|
+
.map((value) => sanitizeHeaderValue(value, 320))
|
|
72
|
+
.map((value) => (lowercase ? value.toLowerCase() : value))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function emailMatchesAllowlist(email, config = {}) {
|
|
77
|
+
const normalizedEmail = normalizeEmail(email);
|
|
78
|
+
const allowedEmails = normalizeList(config.allowedEmails);
|
|
79
|
+
const allowedDomains = normalizeList(config.allowedDomains).map((domain) =>
|
|
80
|
+
domain.startsWith('@') ? domain.slice(1) : domain
|
|
81
|
+
);
|
|
82
|
+
const hasEmailRules = allowedEmails.length > 0 || allowedDomains.length > 0;
|
|
83
|
+
|
|
84
|
+
if (!hasEmailRules) return true;
|
|
85
|
+
if (!normalizedEmail) return false;
|
|
86
|
+
if (allowedEmails.includes(normalizedEmail)) return true;
|
|
87
|
+
|
|
88
|
+
const domain = normalizedEmail.slice(normalizedEmail.indexOf('@') + 1);
|
|
89
|
+
return allowedDomains.includes(domain);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function subjectMatchesAllowlist(subject, allowedSubjects) {
|
|
93
|
+
const normalizedSubject = normalizeSubject(subject);
|
|
94
|
+
const allowed = normalizeList(allowedSubjects, { lowercase: false });
|
|
95
|
+
if (allowed.length === 0) return true;
|
|
96
|
+
if (!normalizedSubject) return false;
|
|
97
|
+
return allowed.includes(normalizedSubject);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasEmailAllowlist(config = {}) {
|
|
101
|
+
return (
|
|
102
|
+
(Array.isArray(config.allowedEmails) && config.allowedEmails.length > 0) ||
|
|
103
|
+
(Array.isArray(config.allowedDomains) && config.allowedDomains.length > 0)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function decodeJwtClaimsUnsafe(jwt) {
|
|
108
|
+
const raw = String(jwt || '').trim();
|
|
109
|
+
if (!raw || raw.length > 32_768) return null;
|
|
110
|
+
|
|
111
|
+
const parts = raw.split('.');
|
|
112
|
+
if (parts.length < 2) return null;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const payload = base64UrlDecode(parts[1]);
|
|
116
|
+
if (payload.length > 16_384) return null;
|
|
117
|
+
const parsed = JSON.parse(payload);
|
|
118
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
119
|
+
} catch (_error) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractEmailFromClaims(claims) {
|
|
125
|
+
if (!claims || typeof claims !== 'object') return '';
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
normalizeEmail(claims.email) ||
|
|
129
|
+
normalizeEmail(claims.upn) ||
|
|
130
|
+
normalizeEmail(claims.preferred_username)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function base64UrlDecode(value) {
|
|
135
|
+
const normalized = String(value || '')
|
|
136
|
+
.replace(/-/g, '+')
|
|
137
|
+
.replace(/_/g, '/');
|
|
138
|
+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
|
|
139
|
+
return Buffer.from(padded, 'base64').toString('utf8');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasNonEmptyString(value) {
|
|
143
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
authenticateTrustedBoundary,
|
|
148
|
+
normalizeEmail,
|
|
149
|
+
normalizeSubject,
|
|
150
|
+
normalizeList,
|
|
151
|
+
emailMatchesAllowlist,
|
|
152
|
+
subjectMatchesAllowlist,
|
|
153
|
+
hasEmailAllowlist,
|
|
154
|
+
decodeJwtClaimsUnsafe,
|
|
155
|
+
extractEmailFromClaims,
|
|
156
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getHeader } = require('../../../express/ip-resolver');
|
|
4
|
+
|
|
5
|
+
function readHeader(req, name) {
|
|
6
|
+
return getHeader(req.headers || {}, name);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasHeader(req, name) {
|
|
10
|
+
const value = readHeader(req, name);
|
|
11
|
+
return typeof value === 'string' && value.length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function headerEquals(req, name, expected) {
|
|
15
|
+
const actual = readHeader(req, name);
|
|
16
|
+
return String(actual || '') === String(expected || '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sanitizeHeaderValue(value, maxLength = 200) {
|
|
20
|
+
return String(value || '')
|
|
21
|
+
.replace(/[\r\n]/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.slice(0, maxLength);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseRoles(value) {
|
|
27
|
+
return String(value || '')
|
|
28
|
+
.split(',')
|
|
29
|
+
.map((role) => sanitizeHeaderValue(role, 80))
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
readHeader,
|
|
35
|
+
hasHeader,
|
|
36
|
+
headerEquals,
|
|
37
|
+
sanitizeHeaderValue,
|
|
38
|
+
parseRoles,
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getClientIp } = require('../../../express/ip-resolver');
|
|
4
|
+
|
|
5
|
+
function success(req, strategy, details = {}, options = {}) {
|
|
6
|
+
const ip = details.ip || getClientIp(req, options);
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
ok: true,
|
|
10
|
+
admin: {
|
|
11
|
+
authenticated: true,
|
|
12
|
+
strategy,
|
|
13
|
+
subject: details.subject || strategy,
|
|
14
|
+
email: details.email || null,
|
|
15
|
+
roles: Array.isArray(details.roles) ? details.roles : [],
|
|
16
|
+
ip,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unauthorized() {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
statusCode: 401,
|
|
25
|
+
code: 'ADMIN_UNAUTHORIZED',
|
|
26
|
+
message: 'Admin API authentication required',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function forbidden() {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
statusCode: 403,
|
|
34
|
+
code: 'ADMIN_FORBIDDEN',
|
|
35
|
+
message: 'Admin API access denied',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { success, unauthorized, forbidden };
|