@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +284 -0
  4. package/config/defaults.js +65 -0
  5. package/constants/patterns.js +77 -0
  6. package/package.json +89 -0
  7. package/src/admin/admin-router.js +106 -0
  8. package/src/admin/auth/admin-auth.js +176 -0
  9. package/src/admin/auth/index.js +13 -0
  10. package/src/admin/auth/strategies/alb-auth.js +49 -0
  11. package/src/admin/auth/strategies/cloudflare-access.js +34 -0
  12. package/src/admin/auth/strategies/combined.js +50 -0
  13. package/src/admin/auth/strategies/ip-allowlist.js +13 -0
  14. package/src/admin/auth/strategies/none.js +20 -0
  15. package/src/admin/auth/strategies/token.js +25 -0
  16. package/src/admin/auth/strategies/trusted-proxy.js +52 -0
  17. package/src/admin/auth/utils/constant-time.js +18 -0
  18. package/src/admin/auth/utils/external-identity.js +156 -0
  19. package/src/admin/auth/utils/header-utils.js +39 -0
  20. package/src/admin/auth/utils/result.js +39 -0
  21. package/src/admin/ban-normalizer.js +98 -0
  22. package/src/admin/index.js +12 -0
  23. package/src/admin/response.js +41 -0
  24. package/src/brute-force/brute-force-guard.js +268 -0
  25. package/src/brute-force/index.js +32 -0
  26. package/src/brute-force/key-builder.js +164 -0
  27. package/src/brute-force/result.js +35 -0
  28. package/src/core/engine.js +264 -0
  29. package/src/core/index.js +7 -0
  30. package/src/core/logger.js +3 -0
  31. package/src/core/rate-limit-result.js +13 -0
  32. package/src/core/rateLimiter.js +3 -0
  33. package/src/core/scoring.js +18 -0
  34. package/src/core/threat-event.js +69 -0
  35. package/src/detectors/hpp.js +30 -0
  36. package/src/detectors/index.js +19 -0
  37. package/src/detectors/nosql.js +53 -0
  38. package/src/detectors/path-traversal.js +72 -0
  39. package/src/detectors/prototype-pollution.js +69 -0
  40. package/src/detectors/request-shape.js +76 -0
  41. package/src/detectors/sql.js +18 -0
  42. package/src/detectors/xss.js +18 -0
  43. package/src/events/event-bus.js +51 -0
  44. package/src/events/index.js +19 -0
  45. package/src/events/memory-event-store.js +64 -0
  46. package/src/events/sanitize-event.js +54 -0
  47. package/src/events/threat-event.js +174 -0
  48. package/src/express/ip-resolver.js +109 -0
  49. package/src/express/middleware.js +379 -0
  50. package/src/express/request-targets.js +35 -0
  51. package/src/express/response.js +14 -0
  52. package/src/index.js +41 -0
  53. package/src/logger/console-reporter.js +75 -0
  54. package/src/middleware/index.js +7 -0
  55. package/src/middleware/parry_ddos.js +3 -0
  56. package/src/observability/index.js +6 -0
  57. package/src/observability/metrics.js +61 -0
  58. package/src/observability/snapshot.js +48 -0
  59. package/src/policies/index.js +15 -0
  60. package/src/policies/matcher.js +48 -0
  61. package/src/policies/normalize-policy.js +94 -0
  62. package/src/policies/presets.js +34 -0
  63. package/src/rate-limit/keys.js +7 -0
  64. package/src/rate-limit/limiter.js +124 -0
  65. package/src/stores/README.md +51 -0
  66. package/src/stores/index.js +6 -0
  67. package/src/stores/memory-store.js +278 -0
  68. package/src/stores/redis-store.js +349 -0
  69. package/src/utils/decode.js +56 -0
  70. package/src/utils/flatten.js +27 -0
  71. package/src/utils/normalize.js +21 -0
  72. package/types/index.d.ts +555 -0
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { sanitizeEvent } = require('../events/sanitize-event');
4
+
5
+ function normalizeAdminBanEntry(entry = {}, source = 'ban', now = Date.now()) {
6
+ const sanitized = sanitizeEvent(entry) || {};
7
+ const metadata = sanitizeEvent(sanitized.metadata || {}) || {};
8
+ const rawKey = String(sanitized.key || 'unknown');
9
+ const type = inferType(rawKey, metadata, source);
10
+ const expiresAtMs = pickNumber(
11
+ sanitized.expiresAt,
12
+ sanitized.banExpiresAt,
13
+ sanitized.blockExpiresAt
14
+ );
15
+ const ttlMs =
16
+ pickNumber(sanitized.ttlMs) ?? (expiresAtMs ? Math.max(0, expiresAtMs - now) : null);
17
+ const createdAtMs =
18
+ pickNumber(sanitized.createdAt, metadata.createdAt) ??
19
+ (expiresAtMs && ttlMs ? expiresAtMs - ttlMs : now);
20
+
21
+ return {
22
+ key: normalizePublicKey(rawKey, type),
23
+ type,
24
+ reason: String(metadata.reason || sanitized.reason || defaultReason(source)),
25
+ policyName: metadata.policyName || sanitized.policyName || inferPolicyName(rawKey) || null,
26
+ createdAt: toIso(createdAtMs),
27
+ expiresAt: expiresAtMs ? toIso(expiresAtMs) : null,
28
+ ttlMs,
29
+ };
30
+ }
31
+
32
+ async function listAdminBanEntries(store, options = {}) {
33
+ const now = Date.now();
34
+ const bans = store && typeof store.listBans === 'function' ? await store.listBans(options) : [];
35
+ const blocks =
36
+ store && typeof store.listBlocks === 'function' ? await store.listBlocks(options) : [];
37
+
38
+ return [
39
+ ...bans.map((entry) => normalizeAdminBanEntry(entry, 'ban', now)),
40
+ ...blocks.map((entry) => normalizeAdminBanEntry(entry, 'block', now)),
41
+ ];
42
+ }
43
+
44
+ function inferType(key, metadata, source) {
45
+ if (source === 'block' && key.startsWith('bf:')) return 'brute-force';
46
+ if (String(metadata.keyType || '').startsWith('body.email')) return 'identity';
47
+ if (key.startsWith('ip:') || isIpLike(key)) return 'ip';
48
+ if (key.includes('body.email') || looksLikeEmail(key)) return 'identity';
49
+ if (source === 'block') return 'brute-force';
50
+ return 'generic';
51
+ }
52
+
53
+ function normalizePublicKey(key, type) {
54
+ if (type === 'ip' && !key.startsWith('ip:')) return `ip:${key}`;
55
+ return key;
56
+ }
57
+
58
+ function inferPolicyName(key) {
59
+ const parts = String(key || '').split(':');
60
+ if (parts[0] === 'bf' && parts[1]) return parts[1];
61
+ if (parts[0] === 'route-rl' && parts[1]) return parts[1];
62
+ return null;
63
+ }
64
+
65
+ function defaultReason(source) {
66
+ return source === 'block'
67
+ ? 'Temporary application-layer block'
68
+ : 'Temporary application-layer ban';
69
+ }
70
+
71
+ function pickNumber(...values) {
72
+ for (const value of values) {
73
+ if (value == null || value === '') continue;
74
+ const parsed =
75
+ typeof value === 'string' && Number.isNaN(Number(value)) ? Date.parse(value) : Number(value);
76
+ if (Number.isFinite(parsed)) return parsed;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function toIso(value) {
82
+ const parsed = pickNumber(value) || Date.now();
83
+ return new Date(parsed).toISOString();
84
+ }
85
+
86
+ function isIpLike(value) {
87
+ return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || /^[0-9a-f:]+$/i.test(value);
88
+ }
89
+
90
+ function looksLikeEmail(value) {
91
+ return /[^\s:@]+@[^\s:@]+\.[^\s:@]+/.test(value);
92
+ }
93
+
94
+ module.exports = {
95
+ listAdminBanEntries,
96
+ normalizeAdminBanEntry,
97
+ inferType,
98
+ };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ const { createParryAdminRouter, resolveParryContext } = require('./admin-router');
4
+ const { createAdminAuthMiddleware, authenticateAdminRequest, requireAdminAuth } = require('./auth');
5
+
6
+ module.exports = {
7
+ createParryAdminRouter,
8
+ resolveParryContext,
9
+ createAdminAuthMiddleware,
10
+ authenticateAdminRequest,
11
+ requireAdminAuth,
12
+ };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ function json(res, statusCode, body) {
4
+ return res.status(statusCode).json(body);
5
+ }
6
+
7
+ function ok(res, body) {
8
+ return json(res, 200, body);
9
+ }
10
+
11
+ function unauthorized(res) {
12
+ return json(res, 401, {
13
+ error: {
14
+ code: 'ADMIN_UNAUTHORIZED',
15
+ message: 'Admin API authentication required',
16
+ },
17
+ code: 'ADMIN_UNAUTHORIZED',
18
+ message: 'Admin API authentication required',
19
+ });
20
+ }
21
+
22
+ function forbidden(res) {
23
+ return json(res, 403, {
24
+ error: {
25
+ code: 'ADMIN_FORBIDDEN',
26
+ message: 'Admin API access denied',
27
+ },
28
+ code: 'ADMIN_FORBIDDEN',
29
+ message: 'Admin API access denied',
30
+ });
31
+ }
32
+
33
+ function notFound(res) {
34
+ return json(res, 404, {
35
+ error: 'Not found',
36
+ code: 'ADMIN_NOT_FOUND',
37
+ message: 'Not found',
38
+ });
39
+ }
40
+
41
+ module.exports = { json, ok, unauthorized, forbidden, notFound };
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ const { buildBruteForceKeys } = require('./key-builder');
4
+ const { createAllowedResult, createBlockedResult } = require('./result');
5
+
6
+ function createBruteForceContext({
7
+ policy,
8
+ requestData,
9
+ req,
10
+ res,
11
+ store,
12
+ config,
13
+ logger,
14
+ eventBus,
15
+ }) {
16
+ const enabled = Boolean(policy?.bruteForce?.enabled);
17
+ const keys = enabled ? buildBruteForceKeys(policy, requestData) : [];
18
+ const state = {
19
+ manualAction: null,
20
+ manualReason: null,
21
+ processed: false,
22
+ };
23
+
24
+ return { policy, requestData, req, res, store, config, logger, eventBus, enabled, keys, state };
25
+ }
26
+
27
+ function attachParryRequestApi(req, context) {
28
+ const existing = req.parry && typeof req.parry === 'object' ? req.parry : {};
29
+ req.parry = {
30
+ ...existing,
31
+ recordAuthFailure(reason) {
32
+ context.state.manualAction = 'failure';
33
+ context.state.manualReason = reason || 'manual_failure';
34
+ },
35
+ recordAuthSuccess() {
36
+ context.state.manualAction = 'success';
37
+ context.state.manualReason = 'manual_success';
38
+ },
39
+ };
40
+ }
41
+
42
+ async function checkBruteForceBlock(context) {
43
+ if (!context.enabled || context.keys.length === 0) return createAllowedResult();
44
+
45
+ try {
46
+ for (const key of context.keys) {
47
+ const blocked = await context.store.isBlocked(key.key);
48
+ if (blocked.blocked) {
49
+ const event = createBruteForceEvent(context, 'BRUTE_FORCE_BLOCK', {
50
+ reason: 'Authentication attempts temporarily blocked',
51
+ severity: 'high',
52
+ keyTypes: context.keys.map((item) => item.type),
53
+ });
54
+ emitEvent(context, event);
55
+
56
+ return createBlockedResult({
57
+ statusCode: context.policy.bruteForce.blockedStatusCode,
58
+ blockExpiresAt: blocked.blockExpiresAt,
59
+ retryAfterMs: Math.max(0, blocked.blockExpiresAt - Date.now()),
60
+ event,
61
+ });
62
+ }
63
+ }
64
+ } catch (error) {
65
+ return handleStoreFailure(context, error);
66
+ }
67
+
68
+ return createAllowedResult();
69
+ }
70
+
71
+ function observeAuthenticationResult(context) {
72
+ if (
73
+ !context.enabled ||
74
+ context.keys.length === 0 ||
75
+ !context.res ||
76
+ typeof context.res.on !== 'function'
77
+ ) {
78
+ return;
79
+ }
80
+
81
+ context.res.on('finish', () => {
82
+ finalizeAuthenticationResult(context).catch((error) => {
83
+ const event = createStoreFailureEvent(context, error);
84
+ if (context.logger && typeof context.logger.logStoreError === 'function') {
85
+ context.logger.logStoreError(error, event);
86
+ }
87
+ });
88
+ });
89
+ }
90
+
91
+ async function finalizeAuthenticationResult(context) {
92
+ if (context.state.processed) return;
93
+ context.state.processed = true;
94
+
95
+ const action = resolveAction(context);
96
+ if (action === 'failure') {
97
+ await recordFailure(context, context.state.manualReason || 'status_failure');
98
+ } else if (action === 'success' && context.policy.bruteForce.resetOnSuccess) {
99
+ await resetCounters(context);
100
+ }
101
+ }
102
+
103
+ function resolveAction(context) {
104
+ if (context.state.manualAction) return context.state.manualAction;
105
+
106
+ const status = getResponseStatus(context.res);
107
+ if (context.policy.bruteForce.failureStatusCodes.includes(status)) return 'failure';
108
+ if (context.policy.bruteForce.successStatusCodes.includes(status)) return 'success';
109
+ return null;
110
+ }
111
+
112
+ async function recordFailure(context, reason) {
113
+ const attempts = [];
114
+ for (const key of context.keys) {
115
+ const attempt = await context.store.incrementCounter(
116
+ key.key,
117
+ context.policy.bruteForce.windowMs,
118
+ {
119
+ policyName: context.policy.name,
120
+ keyType: key.type,
121
+ reason,
122
+ }
123
+ );
124
+ attempts.push({ key, attempt });
125
+ }
126
+
127
+ emitEvent(
128
+ context,
129
+ createBruteForceEvent(context, 'BRUTE_FORCE_ATTEMPT', {
130
+ reason,
131
+ severity: 'medium',
132
+ keyTypes: context.keys.map((item) => item.type),
133
+ })
134
+ );
135
+
136
+ const shouldBlock = attempts.some(
137
+ ({ attempt }) => attempt.count >= context.policy.bruteForce.maxAttempts
138
+ );
139
+ if (!shouldBlock) return;
140
+
141
+ for (const key of context.keys) {
142
+ await context.store.blockKey(key.key, context.policy.bruteForce.blockDurationMs, {
143
+ policyName: context.policy.name,
144
+ keyType: key.type,
145
+ reason: 'max_attempts_exceeded',
146
+ });
147
+ }
148
+
149
+ emitEvent(
150
+ context,
151
+ createBruteForceEvent(context, 'BRUTE_FORCE_BLOCK', {
152
+ reason: 'max_attempts_exceeded',
153
+ severity: 'high',
154
+ keyTypes: context.keys.map((item) => item.type),
155
+ })
156
+ );
157
+ }
158
+
159
+ async function resetCounters(context) {
160
+ for (const key of context.keys) {
161
+ await context.store.resetCounter(key.key);
162
+ }
163
+
164
+ emitEvent(
165
+ context,
166
+ createBruteForceEvent(context, 'BRUTE_FORCE_RESET', {
167
+ reason: context.state.manualReason || 'auth_success',
168
+ severity: 'low',
169
+ keyTypes: context.keys.map((item) => item.type),
170
+ })
171
+ );
172
+ }
173
+
174
+ function handleStoreFailure(context, error) {
175
+ const mode = context.config.storeFailureMode === 'fail-closed' ? 'fail-closed' : 'fail-open';
176
+ const event = createStoreFailureEvent(context, error, mode);
177
+ if (context.logger && typeof context.logger.logStoreError === 'function') {
178
+ context.logger.logStoreError(error, event);
179
+ }
180
+
181
+ if (mode === 'fail-open') return createAllowedResult();
182
+
183
+ return createBlockedResult({
184
+ statusCode: 503,
185
+ storeFailure: true,
186
+ event,
187
+ });
188
+ }
189
+
190
+ function createBruteForceEvent(context, type, details = {}) {
191
+ return {
192
+ type,
193
+ module: 'brute-force',
194
+ detector: 'BRUTE_FORCE',
195
+ policyName: context.policy.name,
196
+ ip: context.requestData.ip,
197
+ method: context.requestData.method,
198
+ path: context.requestData.path,
199
+ keyTypes: details.keyTypes || [],
200
+ severity: details.severity || 'medium',
201
+ reason: details.reason,
202
+ timestamp: new Date().toISOString(),
203
+ requestId:
204
+ context.requestData.requestId || getHeader(context.requestData.headers, 'x-request-id'),
205
+ userAgent:
206
+ context.requestData.userAgent || getHeader(context.requestData.headers, 'user-agent'),
207
+ };
208
+ }
209
+
210
+ function createStoreFailureEvent(context, error, mode) {
211
+ return {
212
+ type: 'STORE_FAILURE',
213
+ module: 'brute-force',
214
+ policyName: context.policy?.name,
215
+ ip: context.requestData.ip,
216
+ method: context.requestData.method,
217
+ path: context.requestData.path,
218
+ timestamp: new Date().toISOString(),
219
+ reason: error && error.message ? error.message : String(error),
220
+ mode: mode || context.config.storeFailureMode || 'fail-open',
221
+ requestId:
222
+ context.requestData.requestId || getHeader(context.requestData.headers, 'x-request-id'),
223
+ userAgent:
224
+ context.requestData.userAgent || getHeader(context.requestData.headers, 'user-agent'),
225
+ };
226
+ }
227
+
228
+ function emitEvent(context, event) {
229
+ if (context.eventBus && typeof context.eventBus.emitThreat === 'function') {
230
+ context.eventBus.emitThreat(event, { req: context.req, res: context.res });
231
+ return;
232
+ }
233
+
234
+ if (context.logger && typeof context.logger.log === 'function') context.logger.log(event);
235
+
236
+ if (context.config.onThreat) {
237
+ try {
238
+ context.config.onThreat(event, context.req, context.res);
239
+ } catch (error) {
240
+ if (context.logger && typeof context.logger.logHookError === 'function') {
241
+ context.logger.logHookError(error, event);
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ function getResponseStatus(res) {
248
+ return Number(res.statusCode || res._status || 200);
249
+ }
250
+
251
+ function getHeader(headers, name) {
252
+ if (!headers) return undefined;
253
+ const lower = name.toLowerCase();
254
+ return (
255
+ headers[lower] ||
256
+ headers[name] ||
257
+ headers[Object.keys(headers).find((key) => key.toLowerCase() === lower)]
258
+ );
259
+ }
260
+
261
+ module.exports = {
262
+ createBruteForceContext,
263
+ attachParryRequestApi,
264
+ checkBruteForceBlock,
265
+ observeAuthenticationResult,
266
+ finalizeAuthenticationResult,
267
+ createBruteForceEvent,
268
+ };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ createBruteForceContext,
5
+ attachParryRequestApi,
6
+ checkBruteForceBlock,
7
+ observeAuthenticationResult,
8
+ finalizeAuthenticationResult,
9
+ createBruteForceEvent,
10
+ } = require('./brute-force-guard');
11
+ const {
12
+ buildBruteForceKeys,
13
+ buildRouteRateLimitKey,
14
+ buildKey,
15
+ resolveValue,
16
+ } = require('./key-builder');
17
+ const { createBlockedResponse, retryAfterSeconds } = require('./result');
18
+
19
+ module.exports = {
20
+ createBruteForceContext,
21
+ attachParryRequestApi,
22
+ checkBruteForceBlock,
23
+ observeAuthenticationResult,
24
+ finalizeAuthenticationResult,
25
+ createBruteForceEvent,
26
+ buildBruteForceKeys,
27
+ buildRouteRateLimitKey,
28
+ buildKey,
29
+ resolveValue,
30
+ createBlockedResponse,
31
+ retryAfterSeconds,
32
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const FORBIDDEN_SEGMENTS = new Set([
4
+ 'password',
5
+ 'pass',
6
+ 'token',
7
+ 'authorization',
8
+ 'cookie',
9
+ 'secret',
10
+ ]);
11
+
12
+ function buildBruteForceKeys(policy, requestData) {
13
+ const bruteForce = policy?.bruteForce || {};
14
+ return buildKeys({
15
+ policyName: policy.name,
16
+ specs: bruteForce.keys || [],
17
+ requestData,
18
+ namespace: 'bf',
19
+ });
20
+ }
21
+
22
+ function buildRouteRateLimitKey(policy, requestData) {
23
+ const spec = policy?.rateLimit?.key || 'ip';
24
+ const keys = buildKeys({
25
+ policyName: policy.name,
26
+ specs: [spec],
27
+ requestData,
28
+ namespace: 'route-rl',
29
+ });
30
+
31
+ return keys[0] || null;
32
+ }
33
+
34
+ function buildKeys({ policyName, specs, requestData, namespace }) {
35
+ const result = [];
36
+ for (const spec of specs) {
37
+ const key = buildKey(policyName, spec, requestData, namespace);
38
+ if (key) result.push(key);
39
+ }
40
+ return result;
41
+ }
42
+
43
+ function buildKey(policyName, spec, requestData, namespace) {
44
+ if (typeof spec === 'function') {
45
+ return buildCustomKey(policyName, spec, requestData, namespace);
46
+ }
47
+
48
+ const type = String(spec || '').trim();
49
+ if (!type || containsForbiddenSegment(type)) return null;
50
+
51
+ const parts = type
52
+ .split('+')
53
+ .map((part) => part.trim())
54
+ .filter(Boolean);
55
+ const values = [];
56
+
57
+ for (const part of parts) {
58
+ const value = resolveValue(part, requestData);
59
+ if (!value) return null;
60
+ values.push(value);
61
+ }
62
+
63
+ const normalizedValue = values.join(':');
64
+ return createKey(namespace, policyName, type, normalizedValue);
65
+ }
66
+
67
+ function buildCustomKey(policyName, spec, requestData, namespace) {
68
+ const value = spec(requestData);
69
+ if (value == null) return null;
70
+
71
+ if (typeof value === 'object') {
72
+ const type = sanitizeType(value.type || value.keyType || spec.name || 'custom');
73
+ const normalizedValue = normalizeScalar(value.value);
74
+ if (!normalizedValue) return null;
75
+ return createKey(namespace, policyName, type, normalizedValue);
76
+ }
77
+
78
+ const normalizedValue = normalizeScalar(value);
79
+ if (!normalizedValue) return null;
80
+ return createKey(namespace, policyName, sanitizeType(spec.name || 'custom'), normalizedValue);
81
+ }
82
+
83
+ function createKey(namespace, policyName, type, value) {
84
+ const keyType = sanitizeType(type);
85
+ return {
86
+ type: keyType,
87
+ value,
88
+ key: `${namespace}:${sanitizeType(policyName)}:${keyType}:${value}`,
89
+ };
90
+ }
91
+
92
+ function resolveValue(path, requestData) {
93
+ switch (path) {
94
+ case 'ip':
95
+ return normalizeScalar(requestData.ip);
96
+ case 'userAgent':
97
+ return normalizeScalar(
98
+ requestData.headers?.['user-agent'] || requestData.headers?.['User-Agent']
99
+ );
100
+ case 'method':
101
+ return normalizeScalar(requestData.method).toUpperCase();
102
+ case 'path':
103
+ return normalizePath(requestData.path || requestData.url);
104
+ default:
105
+ return resolvePathValue(path, requestData);
106
+ }
107
+ }
108
+
109
+ function resolvePathValue(path, requestData) {
110
+ if (containsForbiddenSegment(path)) return null;
111
+
112
+ const segments = String(path || '').split('.');
113
+ if (segments.length < 2) return null;
114
+
115
+ let current = requestData[segments[0]];
116
+ for (const segment of segments.slice(1)) {
117
+ if (current == null || typeof current !== 'object') return null;
118
+ current = current[segment];
119
+ }
120
+
121
+ return normalizeValueForPath(path, current);
122
+ }
123
+
124
+ function normalizeValueForPath(path, value) {
125
+ const normalized = normalizeScalar(value);
126
+ if (!normalized) return null;
127
+
128
+ if (/(email|username|login)$/i.test(path)) return normalized.toLowerCase();
129
+ return normalized;
130
+ }
131
+
132
+ function normalizeScalar(value) {
133
+ if (value == null) return null;
134
+ if (Array.isArray(value)) return normalizeScalar(value[0]);
135
+ if (typeof value === 'object') return null;
136
+
137
+ const normalized = String(value).trim();
138
+ return normalized.length > 0 ? normalized : null;
139
+ }
140
+
141
+ function normalizePath(value) {
142
+ const normalized = normalizeScalar(value) || '/';
143
+ const queryIndex = normalized.indexOf('?');
144
+ return queryIndex === -1 ? normalized : normalized.slice(0, queryIndex);
145
+ }
146
+
147
+ function containsForbiddenSegment(path) {
148
+ return String(path || '')
149
+ .split(/[.+]/)
150
+ .some((segment) => FORBIDDEN_SEGMENTS.has(segment.toLowerCase()));
151
+ }
152
+
153
+ function sanitizeType(value) {
154
+ return String(value || 'unknown')
155
+ .trim()
156
+ .replace(/[^a-zA-Z0-9_.+-]/g, '-');
157
+ }
158
+
159
+ module.exports = {
160
+ buildBruteForceKeys,
161
+ buildRouteRateLimitKey,
162
+ buildKey,
163
+ resolveValue,
164
+ };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ function createBlockedResponse(blocked) {
4
+ const retryAfter = retryAfterSeconds(blocked);
5
+ return {
6
+ statusCode: blocked.statusCode || 429,
7
+ headers: { 'Retry-After': retryAfter },
8
+ body: {
9
+ error: 'Too many authentication attempts',
10
+ code: 'BRUTE_FORCE_BLOCKED',
11
+ retryAfter,
12
+ },
13
+ };
14
+ }
15
+
16
+ function retryAfterSeconds(blocked) {
17
+ const now = Date.now();
18
+ const until = blocked.blockExpiresAt || blocked.banExpiresAt || now;
19
+ return Math.max(1, Math.ceil((until - now) / 1000));
20
+ }
21
+
22
+ function createAllowedResult(context = {}) {
23
+ return { allowed: true, blocked: false, ...context };
24
+ }
25
+
26
+ function createBlockedResult(context) {
27
+ return { allowed: false, blocked: true, ...context };
28
+ }
29
+
30
+ module.exports = {
31
+ createBlockedResponse,
32
+ createAllowedResult,
33
+ createBlockedResult,
34
+ retryAfterSeconds,
35
+ };