@chiranthmoger/fortifyjs 1.1.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/LICENSE +9 -0
- package/README.md +186 -0
- package/bin/fortifyjs.js +135 -0
- package/examples/fastify.js +18 -0
- package/examples/hono.js +13 -0
- package/examples/kitchen-sink.js +50 -0
- package/examples/koa.js +14 -0
- package/examples/minimal-express.js +13 -0
- package/examples/production-express.js +20 -0
- package/index.d.ts +101 -0
- package/package.json +70 -0
- package/src/adapters/express.js +1027 -0
- package/src/adapters/fastify.js +56 -0
- package/src/adapters/generic.js +15 -0
- package/src/adapters/hono.js +77 -0
- package/src/adapters/koa.js +58 -0
- package/src/adapters/nestjs.js +15 -0
- package/src/adapters/nextjs.js +84 -0
- package/src/analyzers/adaptive.js +92 -0
- package/src/analyzers/behavioral.js +264 -0
- package/src/core/confidence.js +23 -0
- package/src/core/engine.js +162 -0
- package/src/core/normalizer.js +149 -0
- package/src/core/whitelist.js +40 -0
- package/src/dashboard/handler.js +307 -0
- package/src/detectors/cmdi.js +82 -0
- package/src/detectors/crlf.js +33 -0
- package/src/detectors/graphql.js +56 -0
- package/src/detectors/hpp.js +38 -0
- package/src/detectors/ldap.js +50 -0
- package/src/detectors/nosqli.js +134 -0
- package/src/detectors/open-redirect.js +42 -0
- package/src/detectors/path-traversal.js +55 -0
- package/src/detectors/prototype-pollution.js +65 -0
- package/src/detectors/sqli.js +447 -0
- package/src/detectors/sqli.js.bak +446 -0
- package/src/detectors/ssrf.js +64 -0
- package/src/detectors/template-injection.js +34 -0
- package/src/detectors/xss.js +191 -0
- package/src/detectors/xxe.js +45 -0
- package/src/forensics/reporter.js +74 -0
- package/src/index.js +132 -0
- package/src/logger.js +110 -0
- package/src/presets.js +183 -0
- package/src/shields/bot-detector.js +88 -0
- package/src/shields/cors.js +95 -0
- package/src/shields/csrf.js +120 -0
- package/src/shields/file-upload.js +160 -0
- package/src/shields/headers.js +99 -0
- package/src/shields/rate-limiter.js +70 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { DetectionEngine } = require('../core/engine');
|
|
3
|
+
const { IPRateLimiter } = require('../shields/rate-limiter');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_THRESHOLD = 0.5;
|
|
7
|
+
const DEFAULT_SUSPICIOUS_THRESHOLD = 0.2;
|
|
8
|
+
const DEFAULT_MAX_LOGS = 500;
|
|
9
|
+
const DEFAULT_MAX_PAYLOAD_LENGTH = 50000;
|
|
10
|
+
const DEFAULT_MAX_DECODE_ITERATIONS = 8;
|
|
11
|
+
const DEFAULT_MAX_DEPTH = 20;
|
|
12
|
+
const DEFAULT_MAX_FIELDS = 1000;
|
|
13
|
+
const DETECTION_LEVELS = Object.freeze({
|
|
14
|
+
strict: Object.freeze({
|
|
15
|
+
threshold: 0.25,
|
|
16
|
+
suspiciousThreshold: 0.1,
|
|
17
|
+
maxSuspiciousRequests: 2
|
|
18
|
+
}),
|
|
19
|
+
balanced: Object.freeze({
|
|
20
|
+
threshold: DEFAULT_THRESHOLD,
|
|
21
|
+
suspiciousThreshold: DEFAULT_SUSPICIOUS_THRESHOLD,
|
|
22
|
+
maxSuspiciousRequests: 3
|
|
23
|
+
}),
|
|
24
|
+
permissive: Object.freeze({
|
|
25
|
+
threshold: 0.85,
|
|
26
|
+
suspiciousThreshold: 0.5,
|
|
27
|
+
maxSuspiciousRequests: 5
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
const HTTP_METHODS = ['all', 'get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
31
|
+
const DEFAULT_REDACT_KEYS = ['password', 'passwd', 'pwd', 'token', 'secret', 'authorization', 'cookie', 'api_key', 'apikey'];
|
|
32
|
+
function sanitizeForLog(value) {
|
|
33
|
+
return String(value)
|
|
34
|
+
.replace(/\r/g, '\\r')
|
|
35
|
+
.replace(/\n/g, '\\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncateForLog(value, maxLength = 500) {
|
|
39
|
+
const text = sanitizeForLog(value);
|
|
40
|
+
if (text.length <= maxLength) return text;
|
|
41
|
+
return `${text.slice(0, maxLength)}...[truncated ${text.length - maxLength} chars]`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readRequestProperty(obj, key, fallback = undefined) {
|
|
45
|
+
try {
|
|
46
|
+
const value = obj?.[key];
|
|
47
|
+
return value === undefined ? fallback : value;
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isURLSearchParams(value) {
|
|
54
|
+
return typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isMap(value) {
|
|
58
|
+
return value instanceof Map;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isSet(value) {
|
|
62
|
+
return value instanceof Set;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectionEntries(value) {
|
|
66
|
+
if (isURLSearchParams(value) || isMap(value)) return [...value.entries()];
|
|
67
|
+
if (isSet(value)) return [...value.values()].map((entryValue, index) => [String(index), entryValue]);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function schemaKeys(source) {
|
|
72
|
+
if (isURLSearchParams(source) || isMap(source)) {
|
|
73
|
+
return [...new Set([...source.keys()].map(key => String(key)))];
|
|
74
|
+
}
|
|
75
|
+
return Object.keys(source);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function schemaHasKey(source, key) {
|
|
79
|
+
if (isURLSearchParams(source)) return source.has(key);
|
|
80
|
+
if (isMap(source)) return source.has(key);
|
|
81
|
+
return Object.prototype.hasOwnProperty.call(source, key);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getIp(req) {
|
|
85
|
+
const ip = readRequestProperty(req, 'ip', null);
|
|
86
|
+
if (ip) return ip;
|
|
87
|
+
const connection = readRequestProperty(req, 'connection', null);
|
|
88
|
+
return readRequestProperty(connection, 'remoteAddress', 'unknown') || 'unknown';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getRequestUrl(req) {
|
|
92
|
+
return readRequestProperty(req, 'originalUrl', null) || readRequestProperty(req, 'url', '') || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getRoutePath(req) {
|
|
96
|
+
const route = readRequestProperty(req, 'route', null);
|
|
97
|
+
const routePathValue = route ? readRequestProperty(route, 'path', null) : null;
|
|
98
|
+
if (routePathValue) {
|
|
99
|
+
const routePath = Array.isArray(routePathValue) ? routePathValue.join('|') : String(routePathValue);
|
|
100
|
+
return `${readRequestProperty(req, 'baseUrl', '') || ''}${routePath}`;
|
|
101
|
+
}
|
|
102
|
+
return readRequestProperty(req, 'path', null) || getRequestUrl(req).split('?')[0] || '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sanitizeRequestId(value) {
|
|
106
|
+
if (value === null || value === undefined) return null;
|
|
107
|
+
return truncateForLog(value, 128);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultRawRequestId(req) {
|
|
111
|
+
return req.id || req.requestId || req.headers?.['x-request-id'] || req.headers?.['x-correlation-id'] || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function callbackErrorMessage(error) {
|
|
115
|
+
try {
|
|
116
|
+
return sanitizeForLog(error && error.message ? error.message : String(error));
|
|
117
|
+
} catch (_) {
|
|
118
|
+
return '[unavailable]';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function callbackErrorContext(error, context = {}) {
|
|
123
|
+
return {
|
|
124
|
+
type: 'fortifyjs.callback_error',
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
hook: context.hook || 'unknown',
|
|
127
|
+
message: callbackErrorMessage(error),
|
|
128
|
+
eventType: context.event?.type || null,
|
|
129
|
+
eventLabel: context.event?.label || null,
|
|
130
|
+
eventPath: context.event?.path || null
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isSensitivePath(path, redactKeys = DEFAULT_REDACT_KEYS) {
|
|
135
|
+
const lowered = String(path || '').toLowerCase();
|
|
136
|
+
return redactKeys.some(key => lowered.includes(String(key).toLowerCase()));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function payloadPreview(payload, path, options = {}) {
|
|
140
|
+
if (isSensitivePath(path, options.redactKeys)) return '[redacted]';
|
|
141
|
+
return truncateForLog(payload, options.maxLogPayloadLength ?? 300);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function payloadFingerprint(payload) {
|
|
145
|
+
const normalized = String(payload)
|
|
146
|
+
.toLowerCase()
|
|
147
|
+
.replace(/[a-z]+/g, 'a')
|
|
148
|
+
.replace(/\d+/g, '0')
|
|
149
|
+
.replace(/\s+/g, ' ')
|
|
150
|
+
.slice(0, 1000);
|
|
151
|
+
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createDetectionEvent(req, payload, detection, options = {}) {
|
|
155
|
+
const matches = detection.matches || [];
|
|
156
|
+
const action = options.dryRun ? 'observe' : (detection.action || 'block');
|
|
157
|
+
return {
|
|
158
|
+
type: 'fortifyjs.threat',
|
|
159
|
+
detected: true,
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
action,
|
|
162
|
+
blocked: action === 'block',
|
|
163
|
+
dryRun: options.dryRun === true,
|
|
164
|
+
requestId: options.getRequestId(req),
|
|
165
|
+
method: readRequestProperty(req, 'method', null),
|
|
166
|
+
url: getRequestUrl(req),
|
|
167
|
+
route: getRoutePath(req),
|
|
168
|
+
ip: getIp(req),
|
|
169
|
+
label: detection.label,
|
|
170
|
+
confidence: detection.confidence,
|
|
171
|
+
path: detection.path,
|
|
172
|
+
matches,
|
|
173
|
+
matchedSignalIds: matches.map(match => match.id),
|
|
174
|
+
payloadPreview: payloadPreview(payload, detection.path, options),
|
|
175
|
+
payloadLength: String(payload).length,
|
|
176
|
+
reason: detection.reason || null
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatEvent(event, format = 'text') {
|
|
181
|
+
if (format === 'json') return event;
|
|
182
|
+
const safe = (value, fallback = '-') => {
|
|
183
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
184
|
+
return sanitizeForLog(value);
|
|
185
|
+
};
|
|
186
|
+
return `[fortifyjs] ${event.action === 'observe' ? 'Attack Observed' : 'Attack Blocked'}: ${safe(event.label)} from IP: ${safe(event.ip)} | requestId: ${safe(event.requestId)} | path: ${safe(event.path)} | confidence: ${safe(event.confidence)} | Payload: ${safe(event.payloadPreview)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createLearningEvent(req, payload, result, path, options = {}) {
|
|
190
|
+
const matchedSignalIds = (result.matches || []).map(match => match.id);
|
|
191
|
+
const fingerprint = payloadFingerprint(payload);
|
|
192
|
+
return {
|
|
193
|
+
type: 'fortifyjs.learning',
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
requestId: options.getRequestId(req),
|
|
196
|
+
method: readRequestProperty(req, 'method', null),
|
|
197
|
+
url: getRequestUrl(req),
|
|
198
|
+
route: getRoutePath(req),
|
|
199
|
+
ip: getIp(req),
|
|
200
|
+
label: result.label,
|
|
201
|
+
confidence: result.confidence,
|
|
202
|
+
path,
|
|
203
|
+
matches: result.matches || [],
|
|
204
|
+
matchedSignalIds,
|
|
205
|
+
clusterKey: `${result.label}:${matchedSignalIds.join('+') || 'unknown'}:${fingerprint}`,
|
|
206
|
+
payloadPreview: payloadPreview(payload, path, options),
|
|
207
|
+
payloadLength: String(payload).length
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeDetectionLevel(level = 'balanced') {
|
|
212
|
+
const normalized = String(level || 'balanced').toLowerCase();
|
|
213
|
+
if (!DETECTION_LEVELS[normalized]) {
|
|
214
|
+
throw new Error(`Unknown fortifyjs detection level: ${level}`);
|
|
215
|
+
}
|
|
216
|
+
return normalized;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveDetectionSettings(options = {}) {
|
|
220
|
+
const level = normalizeDetectionLevel(options.level ?? options.detectionLevel ?? 'balanced');
|
|
221
|
+
const defaults = DETECTION_LEVELS[level];
|
|
222
|
+
return {
|
|
223
|
+
level,
|
|
224
|
+
threshold: options.threshold ?? defaults.threshold,
|
|
225
|
+
suspiciousThreshold: options.suspiciousThreshold ?? defaults.suspiciousThreshold,
|
|
226
|
+
maxSuspiciousRequests: options.maxSuspiciousRequests ?? defaults.maxSuspiciousRequests
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeMode(mode) {
|
|
231
|
+
if (mode === undefined || mode === null) return null;
|
|
232
|
+
const normalized = String(mode).toLowerCase();
|
|
233
|
+
if (['block', 'blocking', 'enforce', 'enforced'].includes(normalized)) return 'block';
|
|
234
|
+
if (['log', 'observe', 'monitor', 'dry-run', 'dryrun'].includes(normalized)) return 'log';
|
|
235
|
+
throw new Error(`Unknown fortifyjs mode: ${mode}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function toList(value) {
|
|
239
|
+
if (value === undefined || value === null) return [];
|
|
240
|
+
return Array.isArray(value) ? value : [value];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mergeLists(...values) {
|
|
244
|
+
return values.flatMap(toList);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isPlainRecord(value) {
|
|
248
|
+
return value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof RegExp);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function matchesPattern(pattern, value, req) {
|
|
252
|
+
const text = String(value || '');
|
|
253
|
+
if (typeof pattern === 'function') return pattern(text, req) === true;
|
|
254
|
+
if (pattern instanceof RegExp) {
|
|
255
|
+
pattern.lastIndex = 0;
|
|
256
|
+
return pattern.test(text);
|
|
257
|
+
}
|
|
258
|
+
const expected = String(pattern);
|
|
259
|
+
if (expected.endsWith('*')) return text.startsWith(expected.slice(0, -1));
|
|
260
|
+
return text === expected;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function patternMatchesAny(patterns, value, req) {
|
|
264
|
+
return patterns.some(pattern => matchesPattern(pattern, value, req));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function routeAllowPatterns(options = {}) {
|
|
268
|
+
const allowlist = options.allowlist || {};
|
|
269
|
+
return mergeLists(options.allowRoutes, options.allowedRoutes, allowlist.routes);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function paramAllowPatterns(req, options = {}) {
|
|
273
|
+
const allowlist = options.allowlist || {};
|
|
274
|
+
const values = mergeLists(
|
|
275
|
+
options.allowParams,
|
|
276
|
+
options.allowedParams,
|
|
277
|
+
options.allowParameters,
|
|
278
|
+
allowlist.params,
|
|
279
|
+
allowlist.parameters
|
|
280
|
+
);
|
|
281
|
+
const patterns = [];
|
|
282
|
+
|
|
283
|
+
for (const value of values) {
|
|
284
|
+
if (isPlainRecord(value)) {
|
|
285
|
+
for (const [routePattern, routePatterns] of Object.entries(value)) {
|
|
286
|
+
if (requestMatchesPattern(req, routePattern)) patterns.push(...toList(routePatterns));
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
patterns.push(value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return patterns;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function requestMatchesPattern(req, pattern) {
|
|
297
|
+
return schemaCandidates(req).some(candidate => matchesPattern(pattern, candidate, req));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isRouteAllowed(req, options = {}) {
|
|
301
|
+
return routeAllowPatterns(options).some(pattern => requestMatchesPattern(req, pattern));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isParamAllowed(req, path, options = {}) {
|
|
305
|
+
return patternMatchesAny(paramAllowPatterns(req, options), path, req);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function routeDetectionMaps(options = {}) {
|
|
309
|
+
const allowlist = options.allowlist || {};
|
|
310
|
+
return [
|
|
311
|
+
options.routeLevels,
|
|
312
|
+
options.routeDetectionLevels,
|
|
313
|
+
options.routeThresholds,
|
|
314
|
+
allowlist.routeLevels
|
|
315
|
+
].filter(isPlainRecord);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveRouteDetectionOverride(req, options = {}) {
|
|
319
|
+
for (const map of routeDetectionMaps(options)) {
|
|
320
|
+
for (const [pattern, override] of Object.entries(map)) {
|
|
321
|
+
if (requestMatchesPattern(req, pattern)) return override;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function resolveRequestDetectionSettings(req, options = {}, baseSettings = resolveDetectionSettings(options)) {
|
|
328
|
+
const override = resolveRouteDetectionOverride(req, options);
|
|
329
|
+
if (!override) return baseSettings;
|
|
330
|
+
if (typeof override === 'string') return resolveDetectionSettings({ level: override });
|
|
331
|
+
return resolveDetectionSettings(override);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeMaxLogs(maxLogs) {
|
|
335
|
+
const numeric = Number(maxLogs ?? DEFAULT_MAX_LOGS);
|
|
336
|
+
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : DEFAULT_MAX_LOGS;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function createMemoryLogStore(maxLogs = DEFAULT_MAX_LOGS) {
|
|
340
|
+
const limit = normalizeMaxLogs(maxLogs);
|
|
341
|
+
const entries = [];
|
|
342
|
+
return {
|
|
343
|
+
maxLogs: limit,
|
|
344
|
+
add(event) {
|
|
345
|
+
entries.push({ ...event });
|
|
346
|
+
if (entries.length > limit) entries.splice(0, entries.length - limit);
|
|
347
|
+
},
|
|
348
|
+
list() {
|
|
349
|
+
return entries.slice();
|
|
350
|
+
},
|
|
351
|
+
clear() {
|
|
352
|
+
entries.length = 0;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parsePositiveInteger(value, fallback = null) {
|
|
358
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
359
|
+
const parsed = Number(value);
|
|
360
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function createLogsHandler(logStore, options = {}) {
|
|
364
|
+
return (req, res) => {
|
|
365
|
+
const allLogs = logStore && typeof logStore.list === 'function' ? logStore.list() : [];
|
|
366
|
+
const queryLimit = readRequestProperty(readRequestProperty(req, 'query', {}), 'limit', null);
|
|
367
|
+
const limit = parsePositiveInteger(queryLimit, parsePositiveInteger(options.limit, null));
|
|
368
|
+
const logs = limit ? allLogs.slice(-limit) : allLogs;
|
|
369
|
+
return res.status(200).json(logs);
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function normalizeSchemaRule(rule) {
|
|
374
|
+
if (!rule) return null;
|
|
375
|
+
if (Array.isArray(rule)) return { allowed: rule, required: [], allowUnknown: false };
|
|
376
|
+
const required = rule.required || [];
|
|
377
|
+
return {
|
|
378
|
+
allowed: rule.allowed || rule.fields || required,
|
|
379
|
+
required,
|
|
380
|
+
allowUnknown: rule.allowUnknown === true
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function pathnameFromRequest(req) {
|
|
385
|
+
return (getRequestUrl(req).split('?')[0] || readRequestProperty(req, 'path', '') || '').replace(/\/+$/, '') || '/';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function schemaCandidates(req) {
|
|
389
|
+
const method = String(readRequestProperty(req, 'method', '') || '').toUpperCase();
|
|
390
|
+
const route = getRoutePath(req);
|
|
391
|
+
const path = pathnameFromRequest(req);
|
|
392
|
+
const candidates = [...new Set([route, path].filter(Boolean))];
|
|
393
|
+
return [
|
|
394
|
+
...candidates.map(candidate => `${method} ${candidate}`),
|
|
395
|
+
...candidates
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolveSchema(req, options = {}) {
|
|
400
|
+
if (options.schema) return options.schema;
|
|
401
|
+
if (!options.schemas) return null;
|
|
402
|
+
for (const key of schemaCandidates(req)) {
|
|
403
|
+
if (options.schemas[key]) return options.schemas[key];
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateSchemaSource(sourceName, source, rule) {
|
|
409
|
+
const normalized = normalizeSchemaRule(rule);
|
|
410
|
+
if (!normalized) return null;
|
|
411
|
+
|
|
412
|
+
const required = new Set(normalized.required);
|
|
413
|
+
|
|
414
|
+
if (!source || typeof source !== 'object' || Buffer.isBuffer(source)) {
|
|
415
|
+
for (const key of required) {
|
|
416
|
+
return {
|
|
417
|
+
payload: key,
|
|
418
|
+
detection: {
|
|
419
|
+
label: 'schema_violation',
|
|
420
|
+
confidence: 1,
|
|
421
|
+
path: `${sourceName}.${key}`,
|
|
422
|
+
reason: 'missing_required_field',
|
|
423
|
+
matches: [{ id: 'schema-missing-required-field', label: 'schema', confidence: 1 }]
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let keys;
|
|
431
|
+
try {
|
|
432
|
+
keys = schemaKeys(source);
|
|
433
|
+
} catch (_) {
|
|
434
|
+
return {
|
|
435
|
+
payload: `[Unreadable ${sourceName}]`,
|
|
436
|
+
detection: {
|
|
437
|
+
label: 'schema_violation',
|
|
438
|
+
confidence: 1,
|
|
439
|
+
path: sourceName,
|
|
440
|
+
reason: 'unreadable_object',
|
|
441
|
+
matches: [{ id: 'schema-unreadable-object', label: 'schema', confidence: 1 }]
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const allowed = new Set(normalized.allowed);
|
|
446
|
+
|
|
447
|
+
if (!normalized.allowUnknown && allowed.size > 0) {
|
|
448
|
+
for (const key of keys) {
|
|
449
|
+
if (!allowed.has(key)) {
|
|
450
|
+
return {
|
|
451
|
+
payload: key,
|
|
452
|
+
detection: {
|
|
453
|
+
label: 'schema_violation',
|
|
454
|
+
confidence: 1,
|
|
455
|
+
path: `${sourceName}.${key}`,
|
|
456
|
+
reason: 'unexpected_field',
|
|
457
|
+
matches: [{ id: 'schema-unexpected-field', label: 'schema', confidence: 1 }]
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const key of required) {
|
|
465
|
+
if (!schemaHasKey(source, key)) {
|
|
466
|
+
return {
|
|
467
|
+
payload: key,
|
|
468
|
+
detection: {
|
|
469
|
+
label: 'schema_violation',
|
|
470
|
+
confidence: 1,
|
|
471
|
+
path: `${sourceName}.${key}`,
|
|
472
|
+
reason: 'missing_required_field',
|
|
473
|
+
matches: [{ id: 'schema-missing-required-field', label: 'schema', confidence: 1 }]
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function validateSchema(req, schema) {
|
|
483
|
+
if (!schema) return null;
|
|
484
|
+
return (
|
|
485
|
+
validateSchemaSource('query', req.query, schema.query) ||
|
|
486
|
+
validateSchemaSource('body', req.body, schema.body) ||
|
|
487
|
+
validateSchemaSource('params', req.params, schema.params) ||
|
|
488
|
+
validateSchemaSource('headers', req.headers, schema.headers) ||
|
|
489
|
+
validateSchemaSource('cookies', req.cookies, schema.cookies)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function expressMiddleware(options = {}) {
|
|
494
|
+
const detector = options.detector || new DetectionEngine({
|
|
495
|
+
maxPayloadLength: options.maxPayloadLength,
|
|
496
|
+
maxDecodeIterations: options.maxDecodeIterations
|
|
497
|
+
});
|
|
498
|
+
const detectionSettings = resolveDetectionSettings(options);
|
|
499
|
+
const learning = options.learning === true ? { enabled: true } : (options.learning || {});
|
|
500
|
+
const mode = normalizeMode(options.mode);
|
|
501
|
+
const dryRun = typeof options.dryRun === 'boolean'
|
|
502
|
+
? options.dryRun
|
|
503
|
+
: (mode === 'log'
|
|
504
|
+
? true
|
|
505
|
+
: (mode === 'block' ? false : (learning.enabled === true || options.learning === true || detectionSettings.level === 'permissive')));
|
|
506
|
+
const maxSuspiciousRequests = detectionSettings.maxSuspiciousRequests;
|
|
507
|
+
const maxRateLimitEventsPerKey = Math.max(options.maxRateLimitEventsPerKey ?? 1000, maxSuspiciousRequests);
|
|
508
|
+
const rateLimiter = new IPRateLimiter(
|
|
509
|
+
options.rateLimitWindowMs ?? 300000,
|
|
510
|
+
options.maxRateLimitCapacity ?? 10000,
|
|
511
|
+
maxRateLimitEventsPerKey
|
|
512
|
+
);
|
|
513
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
514
|
+
const maxFields = options.maxFields ?? DEFAULT_MAX_FIELDS;
|
|
515
|
+
const blockStatus = options.blockStatus ?? 403;
|
|
516
|
+
const scanQuery = options.scanQuery !== false;
|
|
517
|
+
const scanBody = options.scanBody !== false;
|
|
518
|
+
const scanHeaders = options.scanHeaders !== false;
|
|
519
|
+
const scanCookies = options.scanCookies !== false;
|
|
520
|
+
const scanParams = options.scanParams !== false;
|
|
521
|
+
const scanKeys = options.scanKeys !== false;
|
|
522
|
+
const scanRawBody = options.scanRawBody !== false;
|
|
523
|
+
const skip = typeof options.skip === 'function' ? options.skip : null;
|
|
524
|
+
const onThreat = typeof options.onThreat === 'function' ? options.onThreat : null;
|
|
525
|
+
const onLearningEvent = typeof options.onLearningEvent === 'function'
|
|
526
|
+
? options.onLearningEvent
|
|
527
|
+
: (typeof learning.onEvent === 'function' ? learning.onEvent : null);
|
|
528
|
+
const learningEnabled = learning.enabled === true || options.learning === true || onLearningEvent !== null;
|
|
529
|
+
const logFormat = options.logFormat || (options.jsonLogs ? 'json' : 'text');
|
|
530
|
+
const logger = typeof options.logAttacks === 'function'
|
|
531
|
+
? options.logAttacks
|
|
532
|
+
: (options.logAttacks ? console.warn : null);
|
|
533
|
+
const requestIdGetter = typeof options.getRequestId === 'function' ? options.getRequestId : defaultRawRequestId;
|
|
534
|
+
const rateLimitKeyGetter = typeof options.rateLimitKey === 'function' ? options.rateLimitKey : getIp;
|
|
535
|
+
const onCallbackError = typeof options.onCallbackError === 'function' ? options.onCallbackError : null;
|
|
536
|
+
const hasProvidedLogStore = Boolean(options.logStore);
|
|
537
|
+
const logStore = options.logStore || createMemoryLogStore(options.maxLogs);
|
|
538
|
+
const storeLogs = Boolean(
|
|
539
|
+
options.logRequests ||
|
|
540
|
+
options.logs ||
|
|
541
|
+
options.exposeLogs ||
|
|
542
|
+
options.storeLogs ||
|
|
543
|
+
dryRun ||
|
|
544
|
+
(hasProvidedLogStore && !options._internalLogStore) ||
|
|
545
|
+
learningEnabled
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const reportCallbackError = (error, context = {}) => {
|
|
549
|
+
if (!onCallbackError) return;
|
|
550
|
+
|
|
551
|
+
const safeContext = callbackErrorContext(error, context);
|
|
552
|
+
try {
|
|
553
|
+
const result = onCallbackError(error, safeContext);
|
|
554
|
+
Promise.resolve(result).catch(() => {});
|
|
555
|
+
} catch (_) {
|
|
556
|
+
// User error reporting must never affect request handling.
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const safeCall = (hook, callback, args, context = {}) => {
|
|
561
|
+
if (typeof callback !== 'function') return undefined;
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const result = callback(...args);
|
|
565
|
+
Promise.resolve(result).catch(error => reportCallbackError(error, { ...context, hook }));
|
|
566
|
+
return result;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
reportCallbackError(error, { ...context, hook });
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const safeRequestId = (req) => {
|
|
574
|
+
try {
|
|
575
|
+
return sanitizeRequestId(requestIdGetter(req));
|
|
576
|
+
} catch (error) {
|
|
577
|
+
reportCallbackError(error, { hook: 'getRequestId' });
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const safeRateLimitKey = async (req, fallback) => {
|
|
583
|
+
try {
|
|
584
|
+
const key = rateLimitKeyGetter(req);
|
|
585
|
+
const resolvedKey = await key;
|
|
586
|
+
return String(resolvedKey || fallback || 'unknown');
|
|
587
|
+
} catch (error) {
|
|
588
|
+
reportCallbackError(error, { hook: 'rateLimitKey' });
|
|
589
|
+
return String(fallback || 'unknown');
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const eventOptions = {
|
|
594
|
+
dryRun,
|
|
595
|
+
redactKeys: options.redactKeys || DEFAULT_REDACT_KEYS,
|
|
596
|
+
maxLogPayloadLength: options.maxLogPayloadLength,
|
|
597
|
+
getRequestId: safeRequestId
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const writeLog = (event) => {
|
|
601
|
+
if (storeLogs && logStore && typeof logStore.add === 'function') logStore.add(event);
|
|
602
|
+
if (logger) safeCall('logAttacks', logger, [formatEvent(event, logFormat), event], { event });
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const emitLearning = (req, payload, result, path) => {
|
|
606
|
+
if (!learningEnabled) return;
|
|
607
|
+
const event = createLearningEvent(req, payload, result, path, eventOptions);
|
|
608
|
+
req.fortifyjsLearning = req.fortifyjsLearning || [];
|
|
609
|
+
req.fortifyjsLearning.push(event);
|
|
610
|
+
if (storeLogs && logStore && typeof logStore.add === 'function') logStore.add(event);
|
|
611
|
+
safeCall('onLearningEvent', onLearningEvent, [event, req], { event });
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const middleware = async (req, res, next) => {
|
|
615
|
+
try {
|
|
616
|
+
if (skip) {
|
|
617
|
+
try {
|
|
618
|
+
const skipResult = skip(req);
|
|
619
|
+
const shouldSkip = await skipResult;
|
|
620
|
+
if (shouldSkip) return next();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
reportCallbackError(error, { hook: 'skip' });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (isRouteAllowed(req, options)) return next();
|
|
627
|
+
|
|
628
|
+
const ip = getIp(req);
|
|
629
|
+
const rateLimitKey = await safeRateLimitKey(req, ip);
|
|
630
|
+
const requestDetectionSettings = resolveRequestDetectionSettings(req, options, detectionSettings);
|
|
631
|
+
const scannedSources = req.fortifyjsScannedSources instanceof Set
|
|
632
|
+
? req.fortifyjsScannedSources
|
|
633
|
+
: new Set();
|
|
634
|
+
req.fortifyjsScannedSources = scannedSources;
|
|
635
|
+
|
|
636
|
+
let scannedFields = 0;
|
|
637
|
+
|
|
638
|
+
const reportDetection = (payload, detection) => {
|
|
639
|
+
const event = createDetectionEvent(req, payload, detection, eventOptions);
|
|
640
|
+
req.fortifyjsDetections = req.fortifyjsDetections || [];
|
|
641
|
+
req.fortifyjsDetections.push(event);
|
|
642
|
+
req.fortifyjs = req.fortifyjs || event;
|
|
643
|
+
safeCall('onThreat', onThreat, [event, req], { event });
|
|
644
|
+
writeLog(event);
|
|
645
|
+
return { isMalicious: true, label: detection.label };
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const scanString = async (str, path) => {
|
|
649
|
+
if (typeof str !== 'string' || str.length === 0) return false;
|
|
650
|
+
if (isParamAllowed(req, path, options)) return false;
|
|
651
|
+
const result = detector.detect(str);
|
|
652
|
+
let finalLabel = result.label;
|
|
653
|
+
let finalConfidence = result.confidence;
|
|
654
|
+
let isMalicious = result.label !== 'benign' && result.confidence >= requestDetectionSettings.threshold;
|
|
655
|
+
|
|
656
|
+
if (!isMalicious && result.label !== 'benign' && result.confidence >= requestDetectionSettings.suspiciousThreshold) {
|
|
657
|
+
emitLearning(req, str, result, path);
|
|
658
|
+
const suspiciousCount = rateLimiter.recordSuspicious(rateLimitKey);
|
|
659
|
+
if (suspiciousCount >= requestDetectionSettings.maxSuspiciousRequests) {
|
|
660
|
+
isMalicious = true;
|
|
661
|
+
finalLabel = "rate_limit_escalation";
|
|
662
|
+
finalConfidence = requestDetectionSettings.threshold;
|
|
663
|
+
writeLog({
|
|
664
|
+
type: 'fortifyjs.rate_limit',
|
|
665
|
+
timestamp: new Date().toISOString(),
|
|
666
|
+
action: dryRun ? 'observe' : 'block',
|
|
667
|
+
blocked: !dryRun,
|
|
668
|
+
dryRun,
|
|
669
|
+
requestId: eventOptions.getRequestId(req),
|
|
670
|
+
method: readRequestProperty(req, 'method', null),
|
|
671
|
+
url: getRequestUrl(req),
|
|
672
|
+
route: getRoutePath(req),
|
|
673
|
+
ip: getIp(req),
|
|
674
|
+
label: finalLabel,
|
|
675
|
+
confidence: finalConfidence,
|
|
676
|
+
path,
|
|
677
|
+
matches: result.matches || [],
|
|
678
|
+
matchedSignalIds: (result.matches || []).map(match => match.id),
|
|
679
|
+
payloadPreview: payloadPreview(str, path, eventOptions),
|
|
680
|
+
payloadLength: String(str).length,
|
|
681
|
+
reason: 'repeated_suspicious_probe'
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (isMalicious) {
|
|
687
|
+
return reportDetection(str, {
|
|
688
|
+
label: finalLabel,
|
|
689
|
+
confidence: finalConfidence,
|
|
690
|
+
path,
|
|
691
|
+
matches: result.matches
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const deepScan = async (obj, path, currentDepth = 0, seen = new WeakSet()) => {
|
|
698
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
699
|
+
if (currentDepth > maxDepth) {
|
|
700
|
+
return reportDetection("[JSON Depth Exceeded]", { label: "dos", confidence: 1, path, reason: 'max_depth_exceeded' });
|
|
701
|
+
}
|
|
702
|
+
if (seen.has(obj)) return false;
|
|
703
|
+
seen.add(obj);
|
|
704
|
+
let attackFound = false;
|
|
705
|
+
|
|
706
|
+
const scanEntry = async (key, val, childPath, scanKey = true) => {
|
|
707
|
+
scannedFields++;
|
|
708
|
+
if (scannedFields > maxFields) {
|
|
709
|
+
return reportDetection("[Field Limit Exceeded]", { label: "dos", confidence: 1, path, reason: 'max_fields_exceeded' });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (scanKey) {
|
|
713
|
+
const keyAttack = scanKeys ? await scanString(String(key), `${childPath}.__key`) : false;
|
|
714
|
+
if (keyAttack) {
|
|
715
|
+
if (!dryRun) return keyAttack;
|
|
716
|
+
attackFound = attackFound || keyAttack;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (typeof val === 'string') {
|
|
721
|
+
const valAttack = await scanString(val, childPath);
|
|
722
|
+
if (valAttack) {
|
|
723
|
+
if (!dryRun) return valAttack;
|
|
724
|
+
attackFound = attackFound || valAttack;
|
|
725
|
+
}
|
|
726
|
+
} else if (Buffer.isBuffer(val)) {
|
|
727
|
+
const valAttack = await scanString(val.toString('utf8'), childPath);
|
|
728
|
+
if (valAttack) {
|
|
729
|
+
if (!dryRun) return valAttack;
|
|
730
|
+
attackFound = attackFound || valAttack;
|
|
731
|
+
}
|
|
732
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
733
|
+
const nestedAttack = await deepScan(val, childPath, currentDepth + 1, seen);
|
|
734
|
+
if (nestedAttack) {
|
|
735
|
+
if (!dryRun) return nestedAttack;
|
|
736
|
+
attackFound = attackFound || nestedAttack;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return false;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
let entries;
|
|
744
|
+
try {
|
|
745
|
+
entries = collectionEntries(obj);
|
|
746
|
+
} catch (_) {
|
|
747
|
+
return reportDetection("[Object Enumeration Failed]", {
|
|
748
|
+
label: "dos",
|
|
749
|
+
confidence: 1,
|
|
750
|
+
path,
|
|
751
|
+
reason: 'object_enumeration_failed',
|
|
752
|
+
matches: [{ id: 'object-enumeration-failed', label: 'dos', confidence: 1 }]
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (entries) {
|
|
757
|
+
const scanEntryKeys = !isSet(obj);
|
|
758
|
+
for (const [key, val] of entries) {
|
|
759
|
+
const stringKey = String(key);
|
|
760
|
+
const childPath = scanEntryKeys ? `${path}.${stringKey}` : `${path}[${stringKey}]`;
|
|
761
|
+
const entryAttack = await scanEntry(stringKey, val, childPath, scanEntryKeys);
|
|
762
|
+
if (entryAttack) {
|
|
763
|
+
if (!dryRun) return entryAttack;
|
|
764
|
+
attackFound = attackFound || entryAttack;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return attackFound;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let keys;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
keys = Object.keys(obj);
|
|
774
|
+
} catch (_) {
|
|
775
|
+
return reportDetection("[Object Enumeration Failed]", {
|
|
776
|
+
label: "dos",
|
|
777
|
+
confidence: 1,
|
|
778
|
+
path,
|
|
779
|
+
reason: 'object_enumeration_failed',
|
|
780
|
+
matches: [{ id: 'object-enumeration-failed', label: 'dos', confidence: 1 }]
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
for (const key of keys) {
|
|
785
|
+
const childPath = Array.isArray(obj) ? `${path}[${key}]` : `${path}.${key}`;
|
|
786
|
+
let val;
|
|
787
|
+
try {
|
|
788
|
+
val = obj[key];
|
|
789
|
+
} catch (_) {
|
|
790
|
+
return reportDetection("[Object Property Access Failed]", {
|
|
791
|
+
label: "dos",
|
|
792
|
+
confidence: 1,
|
|
793
|
+
path: childPath,
|
|
794
|
+
reason: 'object_property_access_failed',
|
|
795
|
+
matches: [{ id: 'object-property-access-failed', label: 'dos', confidence: 1 }]
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const entryAttack = await scanEntry(key, val, childPath);
|
|
800
|
+
if (entryAttack) {
|
|
801
|
+
if (!dryRun) return entryAttack;
|
|
802
|
+
attackFound = attackFound || entryAttack;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return attackFound;
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
let schemaResult;
|
|
809
|
+
try {
|
|
810
|
+
schemaResult = validateSchema(req, resolveSchema(req, options));
|
|
811
|
+
} catch (_) {
|
|
812
|
+
schemaResult = {
|
|
813
|
+
payload: '[Schema Source Read Failed]',
|
|
814
|
+
detection: {
|
|
815
|
+
label: 'dos',
|
|
816
|
+
confidence: 1,
|
|
817
|
+
path: 'schema',
|
|
818
|
+
reason: 'schema_source_read_failed',
|
|
819
|
+
matches: [{ id: 'schema-source-read-failed', label: 'dos', confidence: 1 }]
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (schemaResult) {
|
|
825
|
+
const attack = reportDetection(schemaResult.payload, schemaResult.detection);
|
|
826
|
+
if (!dryRun) return res.status(blockStatus).json({
|
|
827
|
+
error: 'Forbidden',
|
|
828
|
+
message: 'Malicious payload detected by fortifyjs',
|
|
829
|
+
details: { label: attack.label }
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const sources = [];
|
|
834
|
+
const sourceReadFailures = [];
|
|
835
|
+
const addSource = (enabled, sourceName, readSource, options = {}) => {
|
|
836
|
+
if (!enabled) return;
|
|
837
|
+
try {
|
|
838
|
+
const source = readSource();
|
|
839
|
+
if (options.skipUndefined && source === undefined) return;
|
|
840
|
+
sources.push([sourceName, source]);
|
|
841
|
+
} catch (_) {
|
|
842
|
+
sourceReadFailures.push(sourceName);
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
addSource(scanQuery, 'query', () => req.query);
|
|
847
|
+
addSource(scanBody, 'body', () => req.body);
|
|
848
|
+
addSource(scanRawBody, 'rawBody', () => req.rawBody, { skipUndefined: true });
|
|
849
|
+
addSource(scanHeaders, 'headers', () => req.headers);
|
|
850
|
+
addSource(scanParams, 'params', () => req.params);
|
|
851
|
+
addSource(scanCookies, 'cookies', () => req.cookies);
|
|
852
|
+
|
|
853
|
+
for (const sourceName of sourceReadFailures) {
|
|
854
|
+
const attack = reportDetection(`[${sourceName} Source Read Failed]`, {
|
|
855
|
+
label: 'dos',
|
|
856
|
+
confidence: 1,
|
|
857
|
+
path: sourceName,
|
|
858
|
+
reason: 'source_read_failed',
|
|
859
|
+
matches: [{ id: 'source-read-failed', label: 'dos', confidence: 1 }]
|
|
860
|
+
});
|
|
861
|
+
if (!dryRun) return res.status(blockStatus).json({
|
|
862
|
+
error: 'Forbidden',
|
|
863
|
+
message: 'Malicious payload detected by fortifyjs',
|
|
864
|
+
details: { label: attack.label }
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const [sourceName, source] of sources) {
|
|
869
|
+
if (scannedSources.has(sourceName)) continue;
|
|
870
|
+
if (!source) continue;
|
|
871
|
+
|
|
872
|
+
let attack = false;
|
|
873
|
+
if (Buffer.isBuffer(source)) {
|
|
874
|
+
attack = await scanString(source.toString('utf8'), sourceName);
|
|
875
|
+
} else if (typeof source === 'string') {
|
|
876
|
+
attack = await scanString(source, sourceName);
|
|
877
|
+
} else if (typeof source === 'object') {
|
|
878
|
+
attack = await deepScan(source, sourceName);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (attack) {
|
|
882
|
+
if (!dryRun) return res.status(blockStatus).json({
|
|
883
|
+
error: 'Forbidden',
|
|
884
|
+
message: 'Malicious payload detected by fortifyjs',
|
|
885
|
+
details: { label: attack.label }
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
scannedSources.add(sourceName);
|
|
889
|
+
}
|
|
890
|
+
next();
|
|
891
|
+
} catch (error) {
|
|
892
|
+
next(error);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
middleware.logStore = logStore;
|
|
897
|
+
middleware.logsHandler = (handlerOptions = {}) => createLogsHandler(logStore, handlerOptions);
|
|
898
|
+
return middleware;
|
|
899
|
+
}
|
|
900
|
+
function mergeOptions(base, override) {
|
|
901
|
+
return { ...base, ...(override || {}) };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
class fortifyjsQueryError extends Error {
|
|
905
|
+
constructor(result) {
|
|
906
|
+
super('Unsafe SQL query detected by fortifyjs');
|
|
907
|
+
this.name = 'fortifyjsQueryError';
|
|
908
|
+
this.result = result;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function samplePayload(sample) {
|
|
912
|
+
if (typeof sample === 'string') return sample;
|
|
913
|
+
if (!sample || typeof sample !== 'object') return '';
|
|
914
|
+
return sample.payload ?? sample.text ?? sample.value ?? '';
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function expectedMaliciousLabel(label) {
|
|
918
|
+
if (label === undefined || label === null) return null;
|
|
919
|
+
const normalized = String(label).toLowerCase();
|
|
920
|
+
if (['benign', 'safe', 'normal', 'clean'].includes(normalized)) return false;
|
|
921
|
+
if (['sqli', 'xss', 'nosql', 'malicious', 'attack', 'blocked'].includes(normalized)) return true;
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
function fortifyjs(options = {}) {
|
|
925
|
+
const logStore = options.logStore || createMemoryLogStore(options.maxLogs);
|
|
926
|
+
const baseOptions = {
|
|
927
|
+
...options,
|
|
928
|
+
logStore,
|
|
929
|
+
_internalLogStore: !options.logStore,
|
|
930
|
+
detector: options.detector || new DetectionEngine({
|
|
931
|
+
maxPayloadLength: options.maxPayloadLength,
|
|
932
|
+
maxDecodeIterations: options.maxDecodeIterations
|
|
933
|
+
})
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
global(overrides = {}) {
|
|
938
|
+
return expressMiddleware(mergeOptions(baseOptions, overrides));
|
|
939
|
+
},
|
|
940
|
+
route(overrides = {}) {
|
|
941
|
+
return expressMiddleware(mergeOptions({
|
|
942
|
+
...baseOptions,
|
|
943
|
+
scanParams: true
|
|
944
|
+
}, overrides));
|
|
945
|
+
},
|
|
946
|
+
verify(overrides = {}) {
|
|
947
|
+
return this.route(overrides);
|
|
948
|
+
},
|
|
949
|
+
middleware(overrides = {}) {
|
|
950
|
+
return expressMiddleware(mergeOptions(baseOptions, overrides));
|
|
951
|
+
},
|
|
952
|
+
nestjs(overrides = {}) {
|
|
953
|
+
return nestjsMiddleware(mergeOptions(baseOptions, overrides));
|
|
954
|
+
},
|
|
955
|
+
logs() {
|
|
956
|
+
return logStore.list();
|
|
957
|
+
},
|
|
958
|
+
clearLogs() {
|
|
959
|
+
logStore.clear();
|
|
960
|
+
},
|
|
961
|
+
logsHandler(handlerOptions = {}) {
|
|
962
|
+
return createLogsHandler(logStore, handlerOptions);
|
|
963
|
+
},
|
|
964
|
+
mountLogs(app, path = baseOptions.logsPath || '/admin/fortifyjs/logs', handlerOptions = {}) {
|
|
965
|
+
if (!app || typeof app.get !== 'function') {
|
|
966
|
+
throw new TypeError('mountLogs(app) requires an Express-compatible app with app.get().');
|
|
967
|
+
}
|
|
968
|
+
app.get(path, createLogsHandler(logStore, handlerOptions));
|
|
969
|
+
return app;
|
|
970
|
+
},
|
|
971
|
+
logStore,
|
|
972
|
+
detector: baseOptions.detector
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function isPlainOptions(value) {
|
|
976
|
+
return value && typeof value === 'object' && !Array.isArray(value) && typeof value !== 'function';
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function secureRouter(options = {}) {
|
|
980
|
+
let express;
|
|
981
|
+
try {
|
|
982
|
+
express = require('express');
|
|
983
|
+
} catch (e) {
|
|
984
|
+
throw new Error('secureRouter() requires express to be installed in the host application.');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const router = express.Router(options.routerOptions || {});
|
|
988
|
+
const guard = fortifyjs(options);
|
|
989
|
+
router.use(guard.global({ ...(options.globalOptions || {}), scanParams: false }));
|
|
990
|
+
if (options.exposeLogs) {
|
|
991
|
+
router.get(options.logsPath || '/admin/fortifyjs/logs', guard.logsHandler());
|
|
992
|
+
}
|
|
993
|
+
const routeGuard = (routeOptions = {}) => guard.route({
|
|
994
|
+
...routeOptions,
|
|
995
|
+
schema: routeOptions.schema,
|
|
996
|
+
scanQuery: false,
|
|
997
|
+
scanBody: false,
|
|
998
|
+
scanHeaders: false,
|
|
999
|
+
scanCookies: false,
|
|
1000
|
+
scanRawBody: false,
|
|
1001
|
+
scanParams: routeOptions.scanParams !== false
|
|
1002
|
+
});
|
|
1003
|
+
const consumeRouteOptions = (handlers) => {
|
|
1004
|
+
let routeOptions = options.routeOptions || {};
|
|
1005
|
+
if (handlers.length > 0 && isPlainOptions(handlers[0])) {
|
|
1006
|
+
routeOptions = mergeOptions(routeOptions, handlers.shift());
|
|
1007
|
+
}
|
|
1008
|
+
return routeOptions;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
for (const method of HTTP_METHODS) {
|
|
1012
|
+
const original = router[method].bind(router);
|
|
1013
|
+
router[method] = (path, ...handlers) => {
|
|
1014
|
+
const routeOptions = consumeRouteOptions(handlers);
|
|
1015
|
+
|
|
1016
|
+
return original(
|
|
1017
|
+
path,
|
|
1018
|
+
routeGuard(routeOptions),
|
|
1019
|
+
...handlers
|
|
1020
|
+
);
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
router.fortifyjs = guard;
|
|
1025
|
+
return router;
|
|
1026
|
+
}
|
|
1027
|
+
module.exports = { expressMiddleware, fortifyjs, secureRouter, expectedMaliciousLabel, samplePayload };
|