@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
package/src/presets.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DetectionEngine } = require('./core/engine');
|
|
4
|
+
const { expressMiddleware } = require('./adapters/express');
|
|
5
|
+
const { Logger } = require('./logger');
|
|
6
|
+
|
|
7
|
+
const PRESETS = {
|
|
8
|
+
basic: {
|
|
9
|
+
level: 'balanced',
|
|
10
|
+
headers: true,
|
|
11
|
+
rateLimit: { max: 100, windowMs: 15 * 60 * 1000 },
|
|
12
|
+
cors: { origin: 'same-origin' },
|
|
13
|
+
csrf: false,
|
|
14
|
+
botDetection: { enabled: true, action: 'flag' },
|
|
15
|
+
behavioral: { enabled: true, entropyOnly: true },
|
|
16
|
+
dashboard: false,
|
|
17
|
+
logging: { level: 'info', format: 'text' }
|
|
18
|
+
},
|
|
19
|
+
medium: {
|
|
20
|
+
level: 'balanced',
|
|
21
|
+
headers: true,
|
|
22
|
+
rateLimit: { max: 200, windowMs: 15 * 60 * 1000 },
|
|
23
|
+
cors: { origin: 'same-origin' }, // user overrides this typically
|
|
24
|
+
csrf: false,
|
|
25
|
+
botDetection: { enabled: true, action: 'block', blockList: ['scrapy', 'python-requests'] },
|
|
26
|
+
behavioral: { enabled: true },
|
|
27
|
+
fileUpload: { enabled: true },
|
|
28
|
+
dashboard: false,
|
|
29
|
+
logging: { level: 'warn', format: 'json' }
|
|
30
|
+
},
|
|
31
|
+
hard: {
|
|
32
|
+
level: 'strict',
|
|
33
|
+
headers: true,
|
|
34
|
+
rateLimit: { max: 100, windowMs: 15 * 60 * 1000 },
|
|
35
|
+
cors: { origin: 'same-origin' },
|
|
36
|
+
csrf: { cookieName: '_fortify_csrf' },
|
|
37
|
+
botDetection: { enabled: true, action: 'block' },
|
|
38
|
+
behavioral: { enabled: true, learningRequests: 5000 },
|
|
39
|
+
adaptive: { enabled: true },
|
|
40
|
+
fileUpload: { enabled: true },
|
|
41
|
+
dashboard: { enabled: false }, // available but off by default
|
|
42
|
+
logging: { level: 'warn', format: 'json' }
|
|
43
|
+
},
|
|
44
|
+
advanced: {
|
|
45
|
+
level: 'strict',
|
|
46
|
+
headers: true,
|
|
47
|
+
rateLimit: { max: 100, windowMs: 15 * 60 * 1000 },
|
|
48
|
+
cors: { origin: 'same-origin' },
|
|
49
|
+
csrf: { cookieName: '_fortify_csrf' },
|
|
50
|
+
botDetection: { enabled: true, action: 'block' },
|
|
51
|
+
behavioral: { enabled: true, learningRequests: 5000 },
|
|
52
|
+
adaptive: { enabled: true },
|
|
53
|
+
fileUpload: { enabled: true, scanFilenameForInjection: true },
|
|
54
|
+
dashboard: { enabled: true, path: '/admin/security' },
|
|
55
|
+
logging: { level: 'debug', format: 'json' }
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function isObject(item) {
|
|
60
|
+
return (item && typeof item === 'object' && !Array.isArray(item));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function deepMerge(target, source) {
|
|
64
|
+
let output = Object.assign({}, target);
|
|
65
|
+
if (isObject(target) && isObject(source)) {
|
|
66
|
+
Object.keys(source).forEach(key => {
|
|
67
|
+
if (isObject(source[key])) {
|
|
68
|
+
if (!(key in target)) Object.assign(output, { [key]: source[key] });
|
|
69
|
+
else output[key] = deepMerge(target[key], source[key]);
|
|
70
|
+
} else {
|
|
71
|
+
Object.assign(output, { [key]: source[key] });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Main factory for fortifyjs middleware
|
|
80
|
+
* @param {string|Object} tier
|
|
81
|
+
* @param {Object} overrides
|
|
82
|
+
* @returns {Function} Express middleware stack
|
|
83
|
+
*/
|
|
84
|
+
function shield(tier = 'basic', overrides = {}) {
|
|
85
|
+
if (typeof tier === 'object') {
|
|
86
|
+
overrides = tier;
|
|
87
|
+
tier = 'basic';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const preset = PRESETS[tier];
|
|
91
|
+
if (!preset) {
|
|
92
|
+
throw new Error(`Unknown tier: "${tier}". Use: basic, medium, hard, advanced`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const config = deepMerge(preset, overrides);
|
|
96
|
+
|
|
97
|
+
// Create shared logger
|
|
98
|
+
const loggerOptions = typeof config.logging === 'object' ? config.logging : {};
|
|
99
|
+
const logger = new Logger(loggerOptions);
|
|
100
|
+
|
|
101
|
+
// Load shields (lazy load to avoid circular deps if needed)
|
|
102
|
+
const { rateLimiterFactory } = require('./shields/rate-limiter');
|
|
103
|
+
const headersFactory = require('./shields/headers');
|
|
104
|
+
const corsFactory = require('./shields/cors');
|
|
105
|
+
const csrfFactory = require('./shields/csrf');
|
|
106
|
+
const botFactory = require('./shields/bot-detector');
|
|
107
|
+
const { fileUploadShieldFactory } = require('./shields/file-upload');
|
|
108
|
+
const dashboardFactory = require('./dashboard/handler').createDashboardHandler;
|
|
109
|
+
const { AdaptiveBlocker } = require('./analyzers/adaptive');
|
|
110
|
+
|
|
111
|
+
// Build the stack
|
|
112
|
+
const stack = [];
|
|
113
|
+
|
|
114
|
+
if (config.rateLimit) {
|
|
115
|
+
stack.push(rateLimiterFactory(typeof config.rateLimit === 'object' ? config.rateLimit : {}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (config.headers) {
|
|
119
|
+
stack.push(headersFactory(typeof config.headers === 'object' ? config.headers : {}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (config.cors) {
|
|
123
|
+
stack.push(corsFactory(typeof config.cors === 'object' ? config.cors : {}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (config.csrf) {
|
|
127
|
+
stack.push(csrfFactory(typeof config.csrf === 'object' ? config.csrf : {}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (config.botDetection && config.botDetection.enabled) {
|
|
131
|
+
stack.push(botFactory(config.botDetection));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (config.adaptive && config.adaptive.enabled) {
|
|
135
|
+
const adaptiveBlocker = new AdaptiveBlocker(typeof config.adaptive === 'object' ? config.adaptive : {});
|
|
136
|
+
stack.push(adaptiveBlocker.middleware());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (config.fileUpload && config.fileUpload.enabled) {
|
|
140
|
+
stack.push(fileUploadShieldFactory(config.fileUpload));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (config.dashboard && config.dashboard.enabled) {
|
|
144
|
+
stack.push(dashboardFactory(config.dashboard, logger));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Build detector
|
|
148
|
+
const detectorOptions = {
|
|
149
|
+
level: config.level,
|
|
150
|
+
behavioral: config.behavioral
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Note: We use expressMiddleware factory here
|
|
154
|
+
// For other frameworks, this `shield()` factory would need to return the respective adapter's composition
|
|
155
|
+
// To keep it simple for v1, `shield()` returns Express middleware, and Fastify users use `fastifyPlugin` directly
|
|
156
|
+
|
|
157
|
+
const detectorMiddleware = expressMiddleware({
|
|
158
|
+
...detectorOptions,
|
|
159
|
+
logAttacks: (msg, evt) => logger.warn(msg, { meta: evt })
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
stack.push(detectorMiddleware);
|
|
163
|
+
|
|
164
|
+
return function fortifyjsStack(req, res, next) {
|
|
165
|
+
let index = 0;
|
|
166
|
+
function runNext(err) {
|
|
167
|
+
if (err) return next(err);
|
|
168
|
+
if (index >= stack.length) return next();
|
|
169
|
+
const middleware = stack[index++];
|
|
170
|
+
try {
|
|
171
|
+
const result = middleware(req, res, runNext);
|
|
172
|
+
if (result && typeof result.catch === 'function') {
|
|
173
|
+
result.catch(next);
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
next(e);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
runNext();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { shield, PRESETS };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const KNOWN_GOOD_BOTS = [
|
|
4
|
+
'googlebot',
|
|
5
|
+
'bingbot',
|
|
6
|
+
'slurp',
|
|
7
|
+
'duckduckbot',
|
|
8
|
+
'baiduspider'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const KNOWN_BAD_BOTS = [
|
|
12
|
+
'scrapy',
|
|
13
|
+
'python-requests',
|
|
14
|
+
'go-http-client',
|
|
15
|
+
'java/',
|
|
16
|
+
'libwww-perl',
|
|
17
|
+
'wget',
|
|
18
|
+
'curl'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates the bot detection shield middleware
|
|
23
|
+
* @param {Object} options - Shield configuration
|
|
24
|
+
* @returns {Function} middleware(req, res, next)
|
|
25
|
+
*/
|
|
26
|
+
module.exports = function createBotDetectorShield(options = {}) {
|
|
27
|
+
const enabled = options.enabled !== false;
|
|
28
|
+
const allowSearchEngines = options.allowSearchEngines !== false;
|
|
29
|
+
const blockList = options.blockList || KNOWN_BAD_BOTS;
|
|
30
|
+
const allowList = options.allowList || [];
|
|
31
|
+
const action = options.action || 'block'; // 'block' or 'flag'
|
|
32
|
+
|
|
33
|
+
return function botDetectorShield(req, res, next) {
|
|
34
|
+
if (!enabled) return next();
|
|
35
|
+
|
|
36
|
+
const ua = req.headers['user-agent'] || '';
|
|
37
|
+
const uaLower = ua.toLowerCase();
|
|
38
|
+
|
|
39
|
+
let isBot = false;
|
|
40
|
+
let botReason = '';
|
|
41
|
+
|
|
42
|
+
// 1. Check allow list
|
|
43
|
+
if (ua && allowList.some(b => uaLower.includes(b.toLowerCase()))) {
|
|
44
|
+
return next();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Check search engines
|
|
48
|
+
if (ua && allowSearchEngines && KNOWN_GOOD_BOTS.some(b => uaLower.includes(b))) {
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Empty User-Agent
|
|
53
|
+
if (!ua || ua.trim() === '') {
|
|
54
|
+
isBot = true;
|
|
55
|
+
botReason = 'empty-user-agent';
|
|
56
|
+
}
|
|
57
|
+
// 4. Check blocklist
|
|
58
|
+
else if (blockList.some(b => uaLower.includes(b.toLowerCase()))) {
|
|
59
|
+
isBot = true;
|
|
60
|
+
botReason = 'known-bad-bot';
|
|
61
|
+
}
|
|
62
|
+
// 5. Headless browsers
|
|
63
|
+
else if (uaLower.includes('headless') || uaLower.includes('phantomjs') || uaLower.includes('puppeteer')) {
|
|
64
|
+
isBot = true;
|
|
65
|
+
botReason = 'headless-browser';
|
|
66
|
+
}
|
|
67
|
+
// 6. Missing typical browser headers combined with suspicious UA
|
|
68
|
+
else if (!req.headers['accept'] && !req.headers['accept-language'] && !req.headers['accept-encoding']) {
|
|
69
|
+
isBot = true;
|
|
70
|
+
botReason = 'missing-browser-headers';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isBot) {
|
|
74
|
+
req.isBot = true;
|
|
75
|
+
req.botReason = botReason;
|
|
76
|
+
|
|
77
|
+
if (action === 'block') {
|
|
78
|
+
const err = new Error('Bot traffic detected');
|
|
79
|
+
err.status = 403;
|
|
80
|
+
err.code = 'EBOTBLOCKED';
|
|
81
|
+
err.reason = botReason;
|
|
82
|
+
return next(err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
next();
|
|
87
|
+
};
|
|
88
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates the CORS engine shield middleware
|
|
5
|
+
* @param {Object} options - Shield configuration
|
|
6
|
+
* @returns {Function} middleware(req, res, next)
|
|
7
|
+
*/
|
|
8
|
+
module.exports = function createCorsShield(options = {}) {
|
|
9
|
+
const allowOrigin = options.origin || '*';
|
|
10
|
+
const allowMethods = options.methods || 'GET,HEAD,PUT,PATCH,POST,DELETE';
|
|
11
|
+
const allowedHeaders = options.allowedHeaders;
|
|
12
|
+
const exposedHeaders = options.exposedHeaders;
|
|
13
|
+
const credentials = options.credentials || false;
|
|
14
|
+
const maxAge = options.maxAge;
|
|
15
|
+
|
|
16
|
+
if (credentials === true && allowOrigin === '*') {
|
|
17
|
+
throw new Error('CORS: Cannot use credentials=true with origin="*"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function handleOrigin(reqOrigin, originConfig, cb) {
|
|
21
|
+
if (typeof originConfig === 'function') {
|
|
22
|
+
originConfig(reqOrigin, cb);
|
|
23
|
+
} else if (Array.isArray(originConfig)) {
|
|
24
|
+
cb(null, originConfig.includes(reqOrigin) ? reqOrigin : false);
|
|
25
|
+
} else if (originConfig === '*') {
|
|
26
|
+
cb(null, '*');
|
|
27
|
+
} else if (typeof originConfig === 'string') {
|
|
28
|
+
cb(null, originConfig === reqOrigin ? reqOrigin : false);
|
|
29
|
+
} else if (originConfig instanceof RegExp) {
|
|
30
|
+
cb(null, originConfig.test(reqOrigin) ? reqOrigin : false);
|
|
31
|
+
} else {
|
|
32
|
+
cb(null, false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return function corsShield(req, res, next) {
|
|
37
|
+
const origin = req.headers.origin;
|
|
38
|
+
|
|
39
|
+
// Set Vary header for dynamic origin
|
|
40
|
+
if (allowOrigin !== '*') {
|
|
41
|
+
res.setHeader('Vary', 'Origin');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!origin) {
|
|
45
|
+
return next(); // Not a CORS request
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
handleOrigin(origin, allowOrigin, (err, matchedOrigin) => {
|
|
49
|
+
if (err) return next(err);
|
|
50
|
+
|
|
51
|
+
if (!matchedOrigin) {
|
|
52
|
+
if (req.method === 'OPTIONS') {
|
|
53
|
+
res.statusCode = 204;
|
|
54
|
+
return res.end();
|
|
55
|
+
}
|
|
56
|
+
return next();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
res.setHeader('Access-Control-Allow-Origin', matchedOrigin);
|
|
60
|
+
|
|
61
|
+
if (credentials) {
|
|
62
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (exposedHeaders) {
|
|
66
|
+
const exposed = Array.isArray(exposedHeaders) ? exposedHeaders.join(',') : exposedHeaders;
|
|
67
|
+
res.setHeader('Access-Control-Expose-Headers', exposed);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === 'OPTIONS') {
|
|
71
|
+
res.setHeader('Access-Control-Allow-Methods', Array.isArray(allowMethods) ? allowMethods.join(',') : allowMethods);
|
|
72
|
+
|
|
73
|
+
let requestHeaders = allowedHeaders;
|
|
74
|
+
if (!requestHeaders && req.headers['access-control-request-headers']) {
|
|
75
|
+
requestHeaders = req.headers['access-control-request-headers'];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (requestHeaders) {
|
|
79
|
+
const headersStr = Array.isArray(requestHeaders) ? requestHeaders.join(',') : requestHeaders;
|
|
80
|
+
res.setHeader('Access-Control-Allow-Headers', headersStr);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (maxAge !== undefined) {
|
|
84
|
+
res.setHeader('Access-Control-Max-Age', maxAge.toString());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
res.statusCode = 204;
|
|
88
|
+
res.setHeader('Content-Length', '0');
|
|
89
|
+
return res.end();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
next();
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates the CSRF protection shield middleware
|
|
7
|
+
* @param {Object} options - Shield configuration
|
|
8
|
+
* @returns {Function} middleware(req, res, next)
|
|
9
|
+
*/
|
|
10
|
+
module.exports = function createCsrfShield(options = {}) {
|
|
11
|
+
const secret = options.secret || crypto.randomBytes(32).toString('hex');
|
|
12
|
+
if (!options.secret) {
|
|
13
|
+
console.warn('fortifyjs: CSRF secret not provided. A random secret was generated, but it will not persist across restarts.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const cookieName = options.cookieName || '_fortify_csrf';
|
|
17
|
+
const headerName = options.headerName || 'x-csrf-token';
|
|
18
|
+
const bodyField = options.bodyField || '_csrf';
|
|
19
|
+
const sameSite = options.sameSite || 'Lax';
|
|
20
|
+
const secure = options.secure !== undefined ? options.secure : process.env.NODE_ENV === 'production';
|
|
21
|
+
const ignoreMethods = options.ignoreMethods || ['GET', 'HEAD', 'OPTIONS'];
|
|
22
|
+
const ignoreRoutes = options.ignoreRoutes || [];
|
|
23
|
+
|
|
24
|
+
function signToken(token) {
|
|
25
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
26
|
+
hmac.update(token);
|
|
27
|
+
return `${token}.${hmac.digest('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function verifyToken(signedToken) {
|
|
31
|
+
if (!signedToken || typeof signedToken !== 'string') return false;
|
|
32
|
+
const parts = signedToken.split('.');
|
|
33
|
+
if (parts.length !== 2) return false;
|
|
34
|
+
const [token, signature] = parts;
|
|
35
|
+
const expectedSigned = signToken(token);
|
|
36
|
+
if (expectedSigned.length !== signedToken.length) return false;
|
|
37
|
+
return crypto.timingSafeEqual(Buffer.from(expectedSigned), Buffer.from(signedToken));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchRoute(path, patterns) {
|
|
41
|
+
for (const pattern of patterns) {
|
|
42
|
+
if (pattern.endsWith('/*')) {
|
|
43
|
+
const prefix = pattern.slice(0, -2);
|
|
44
|
+
if (path.startsWith(prefix)) return true;
|
|
45
|
+
} else if (path === pattern) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return function csrfShield(req, res, next) {
|
|
53
|
+
// Basic route ignore (e.g. webhooks)
|
|
54
|
+
if (ignoreRoutes.length > 0 && req.path && matchRoute(req.path, ignoreRoutes)) {
|
|
55
|
+
return next();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse cookies if not already parsed
|
|
59
|
+
// We assume cookie-parser might not be present, so we do basic parsing
|
|
60
|
+
let cookies = req.cookies || {};
|
|
61
|
+
if (!req.cookies && req.headers.cookie) {
|
|
62
|
+
cookies = Object.fromEntries(
|
|
63
|
+
req.headers.cookie.split('; ').map(c => c.split('='))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const currentSignedToken = cookies[cookieName];
|
|
68
|
+
|
|
69
|
+
// Expose csrfToken generator for views
|
|
70
|
+
req.csrfToken = function() {
|
|
71
|
+
const newToken = crypto.randomBytes(16).toString('hex');
|
|
72
|
+
const newSignedToken = signToken(newToken);
|
|
73
|
+
|
|
74
|
+
let cookieHeader = `${cookieName}=${newSignedToken}; Path=/; HttpOnly; SameSite=${sameSite}`;
|
|
75
|
+
if (secure) cookieHeader += '; Secure';
|
|
76
|
+
|
|
77
|
+
res.setHeader('Set-Cookie', cookieHeader);
|
|
78
|
+
return newSignedToken;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// If method is safe, we don't enforce, just optionally generate/refresh token if missing
|
|
82
|
+
if (ignoreMethods.includes(req.method)) {
|
|
83
|
+
if (!currentSignedToken) {
|
|
84
|
+
req.csrfToken(); // trigger generation
|
|
85
|
+
}
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mutation method: Enforce token
|
|
90
|
+
let incomingToken = null;
|
|
91
|
+
|
|
92
|
+
// Check header
|
|
93
|
+
if (req.headers[headerName.toLowerCase()]) {
|
|
94
|
+
incomingToken = req.headers[headerName.toLowerCase()];
|
|
95
|
+
} else if (req.headers['x-xsrf-token']) {
|
|
96
|
+
incomingToken = req.headers['x-xsrf-token'];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check body
|
|
100
|
+
if (!incomingToken && req.body && typeof req.body === 'object') {
|
|
101
|
+
incomingToken = req.body[bodyField];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!incomingToken) {
|
|
105
|
+
const err = new Error('CSRF token missing');
|
|
106
|
+
err.status = 403;
|
|
107
|
+
err.code = 'EBADCSRFTOKEN';
|
|
108
|
+
return next(err);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!currentSignedToken || currentSignedToken !== incomingToken || !verifyToken(incomingToken)) {
|
|
112
|
+
const err = new Error('CSRF token invalid');
|
|
113
|
+
err.status = 403;
|
|
114
|
+
err.code = 'EBADCSRFTOKEN';
|
|
115
|
+
return next(err);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
next();
|
|
119
|
+
};
|
|
120
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DetectionEngine } = require('../core/engine');
|
|
4
|
+
|
|
5
|
+
const defaultOptions = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.docx'],
|
|
8
|
+
blockedExtensions: ['.php', '.asp', '.aspx', '.jsp', '.exe', '.sh', '.bat', '.cmd'],
|
|
9
|
+
maxFilenameLength: 255,
|
|
10
|
+
blockDoubleExtensions: true,
|
|
11
|
+
blockNullBytes: true,
|
|
12
|
+
blockPathTraversal: true,
|
|
13
|
+
blockDotFiles: true,
|
|
14
|
+
validateMimeType: true,
|
|
15
|
+
scanFilenameForInjection: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mimeMap = {
|
|
19
|
+
'.jpg': 'image/jpeg',
|
|
20
|
+
'.jpeg': 'image/jpeg',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.pdf': 'application/pdf',
|
|
24
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
25
|
+
'.doc': 'application/msword',
|
|
26
|
+
'.xls': 'application/vnd.ms-excel',
|
|
27
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
28
|
+
'.txt': 'text/plain',
|
|
29
|
+
'.csv': 'text/csv',
|
|
30
|
+
'.json': 'application/json',
|
|
31
|
+
'.xml': 'application/xml',
|
|
32
|
+
'.zip': 'application/zip',
|
|
33
|
+
'.tar': 'application/x-tar',
|
|
34
|
+
'.gz': 'application/gzip',
|
|
35
|
+
'.mp4': 'video/mp4',
|
|
36
|
+
'.mp3': 'audio/mpeg'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function fileUploadShieldFactory(options = {}) {
|
|
40
|
+
const config = Object.assign({}, defaultOptions, options);
|
|
41
|
+
|
|
42
|
+
if (!config.enabled) {
|
|
43
|
+
return (req, res, next) => next();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let engine = null;
|
|
47
|
+
if (config.scanFilenameForInjection) {
|
|
48
|
+
engine = new DetectionEngine();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function block(res, next, reason) {
|
|
52
|
+
res.status(403).json({ error: 'File upload blocked', reason });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateFile(file, res, next) {
|
|
56
|
+
if (!file) return true;
|
|
57
|
+
|
|
58
|
+
// multer-style file has originalname, some others might have name or filename
|
|
59
|
+
const filename = file.originalname || file.name || file.filename || (typeof file === 'string' ? file : '');
|
|
60
|
+
|
|
61
|
+
if (!filename) return true; // Could not determine filename, skip or block? We skip if we can't tell.
|
|
62
|
+
|
|
63
|
+
if (config.maxFilenameLength && filename.length > config.maxFilenameLength) {
|
|
64
|
+
block(res, next, 'Filename too long');
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config.blockNullBytes && filename.indexOf('\0') !== -1) {
|
|
69
|
+
block(res, next, 'Null byte detected in filename');
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (config.blockPathTraversal && (filename.includes('../') || filename.includes('..\\') || filename.includes('/') || filename.includes('\\'))) {
|
|
74
|
+
block(res, next, 'Path traversal detected in filename');
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (config.blockDotFiles && filename.startsWith('.')) {
|
|
79
|
+
block(res, next, 'Dot files are not allowed');
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parts = filename.split('.');
|
|
84
|
+
const ext = parts.length > 1 ? '.' + parts[parts.length - 1].toLowerCase() : '';
|
|
85
|
+
|
|
86
|
+
if (config.blockDoubleExtensions && parts.length > 2) {
|
|
87
|
+
block(res, next, 'Double extensions are not allowed');
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (config.blockedExtensions && config.blockedExtensions.length > 0) {
|
|
92
|
+
if (config.blockedExtensions.includes(ext)) {
|
|
93
|
+
block(res, next, 'File extension is blocked');
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (config.allowedExtensions && config.allowedExtensions.length > 0) {
|
|
99
|
+
if (!config.allowedExtensions.includes(ext)) {
|
|
100
|
+
block(res, next, 'File extension is not allowed');
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (config.validateMimeType && file.mimetype && ext) {
|
|
106
|
+
const expectedMime = mimeMap[ext];
|
|
107
|
+
if (expectedMime && !file.mimetype.toLowerCase().startsWith(expectedMime.split('/')[0])) {
|
|
108
|
+
// Relaxed checking, e.g. application/pdf could be exact, but just compare what we have roughly
|
|
109
|
+
// Or strict comparison:
|
|
110
|
+
if (file.mimetype.toLowerCase() !== expectedMime) {
|
|
111
|
+
block(res, next, 'MIME type does not match file extension');
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (config.scanFilenameForInjection && engine) {
|
|
118
|
+
const result = engine.detect(filename, { source: 'filename' });
|
|
119
|
+
if (result.label && result.label !== 'benign') {
|
|
120
|
+
block(res, next, 'Filename contains malicious payload');
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return function fileUploadShield(req, res, next) {
|
|
129
|
+
let filesToScan = [];
|
|
130
|
+
|
|
131
|
+
if (req.file) {
|
|
132
|
+
filesToScan.push(req.file);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (req.files) {
|
|
136
|
+
if (Array.isArray(req.files)) {
|
|
137
|
+
filesToScan = filesToScan.concat(req.files);
|
|
138
|
+
} else if (typeof req.files === 'object') {
|
|
139
|
+
Object.keys(req.files).forEach(key => {
|
|
140
|
+
const field = req.files[key];
|
|
141
|
+
if (Array.isArray(field)) {
|
|
142
|
+
filesToScan = filesToScan.concat(field);
|
|
143
|
+
} else {
|
|
144
|
+
filesToScan.push(field);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < filesToScan.length; i++) {
|
|
151
|
+
if (!validateFile(filesToScan[i], res, next)) {
|
|
152
|
+
return; // Response already sent
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
next();
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { fileUploadShieldFactory };
|