secure-api-guard 0.0.0-stage → 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 +22 -0
- package/README.md +549 -2
- package/package.json +64 -4
- package/src/config/defaults.js +98 -0
- package/src/config/validateConfig.js +136 -0
- package/src/index.js +134 -0
- package/src/middleware/apiKeyAuth.js +197 -0
- package/src/middleware/ipFilter.js +77 -0
- package/src/middleware/rateLimiter.js +116 -0
- package/src/middleware/requestSize.js +146 -0
- package/src/middleware/requestValidator.js +163 -0
- package/src/middleware/securityHeaders.js +59 -0
- package/src/middleware/threatDetector.js +130 -0
- package/src/security/apiKey.js +90 -0
- package/src/security/detector.js +159 -0
- package/src/security/ipAddress.js +219 -0
- package/src/stores/memoryStore.js +135 -0
- package/src/stores/redisStore.js +229 -0
- package/src/types/index.d.ts +314 -0
- package/src/utils/errors.js +88 -0
- package/src/utils/logger.js +215 -0
- package/src/utils/response.js +58 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const rateLimit = require('express-rate-limit');
|
|
4
|
+
const defaults = require('../config/defaults');
|
|
5
|
+
const { validateRateLimiterConfig } = require('../config/validateConfig');
|
|
6
|
+
const { formatIp } = require('../security/ipAddress');
|
|
7
|
+
const { MemoryStore } = require('../stores/memoryStore');
|
|
8
|
+
const { RedisStore } = require('../stores/redisStore');
|
|
9
|
+
const { RateLimitError } = require('../utils/errors');
|
|
10
|
+
const { sendSecurityResponse } = require('../utils/response');
|
|
11
|
+
const { defaultLogger } = require('../utils/logger');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default key generator based on client IP.
|
|
15
|
+
*
|
|
16
|
+
* @param {import('express').Request} req
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function defaultKeyGenerator(req) {
|
|
20
|
+
const rawIp = req.ip || (req.socket && req.socket.remoteAddress) || '127.0.0.1';
|
|
21
|
+
return formatIp(rawIp);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a rate limiter middleware for Express.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} [userOptions] - Rate limiter configuration options.
|
|
28
|
+
* @param {number} [userOptions.windowMs=900000] - Window duration in ms (15 mins default).
|
|
29
|
+
* @param {number} [userOptions.maxRequests=100] - Maximum requests allowed per window.
|
|
30
|
+
* @param {number} [userOptions.max] - Alias for maxRequests.
|
|
31
|
+
* @param {number} [userOptions.statusCode=429] - HTTP status code on limit exceed.
|
|
32
|
+
* @param {string} [userOptions.message] - Custom message on rate limit.
|
|
33
|
+
* @param {Function} [userOptions.keyGenerator] - Custom key generation function (req => string).
|
|
34
|
+
* @param {Function} [userOptions.skip] - Function to skip rate limiting (req => boolean).
|
|
35
|
+
* @param {any} [userOptions.store] - Store instance ('memory', 'redis', or custom Store).
|
|
36
|
+
* @param {any} [userOptions.redisClient] - Redis client if store is 'redis'.
|
|
37
|
+
* @param {Function} [userOptions.handler] - Custom error handler.
|
|
38
|
+
* @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
|
|
39
|
+
* @returns {import('express').RequestHandler}
|
|
40
|
+
*/
|
|
41
|
+
function createRateLimiter(userOptions = {}) {
|
|
42
|
+
validateRateLimiterConfig(userOptions);
|
|
43
|
+
|
|
44
|
+
const options = {
|
|
45
|
+
...defaults.rateLimiter,
|
|
46
|
+
...userOptions
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const limit = options.maxRequests !== undefined ? options.maxRequests : (options.max !== undefined ? options.max : 100);
|
|
50
|
+
const logger = options.logger || defaultLogger;
|
|
51
|
+
|
|
52
|
+
// Resolve Store
|
|
53
|
+
let storeInstance;
|
|
54
|
+
if (options.store === 'redis' || (options.redisClient && !options.store)) {
|
|
55
|
+
if (!options.redisClient) {
|
|
56
|
+
throw new Error('RateLimiter: "redisClient" must be provided when store is "redis".');
|
|
57
|
+
}
|
|
58
|
+
storeInstance = new RedisStore({
|
|
59
|
+
client: options.redisClient,
|
|
60
|
+
windowMs: options.windowMs,
|
|
61
|
+
prefix: options.redisPrefix,
|
|
62
|
+
passOnConnectionError: options.passOnRedisError,
|
|
63
|
+
logger
|
|
64
|
+
});
|
|
65
|
+
} else if (typeof options.store === 'object' && options.store !== null) {
|
|
66
|
+
storeInstance = options.store;
|
|
67
|
+
} else {
|
|
68
|
+
// Default in-memory store
|
|
69
|
+
storeInstance = new MemoryStore({
|
|
70
|
+
windowMs: options.windowMs
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const keyGenerator = options.keyGenerator || defaultKeyGenerator;
|
|
75
|
+
|
|
76
|
+
const limiterMiddleware = rateLimit({
|
|
77
|
+
windowMs: options.windowMs,
|
|
78
|
+
limit,
|
|
79
|
+
standardHeaders: options.standardHeaders,
|
|
80
|
+
legacyHeaders: options.legacyHeaders,
|
|
81
|
+
store: storeInstance,
|
|
82
|
+
keyGenerator: (req, res) => keyGenerator(req),
|
|
83
|
+
skip: options.skip || (() => false),
|
|
84
|
+
handler: (req, res, next, rateLimitOptions) => {
|
|
85
|
+
const clientKey = keyGenerator(req);
|
|
86
|
+
const retryAfterSeconds = Math.ceil(options.windowMs / 1000);
|
|
87
|
+
|
|
88
|
+
logger.warn('RATE_LIMIT_EXCEEDED', `Rate limit exceeded for client: ${clientKey}`, {
|
|
89
|
+
req,
|
|
90
|
+
ip: clientKey,
|
|
91
|
+
details: {
|
|
92
|
+
limit,
|
|
93
|
+
windowMs: options.windowMs,
|
|
94
|
+
retryAfter: retryAfterSeconds
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const error = new RateLimitError(options.message, {
|
|
99
|
+
limit,
|
|
100
|
+
windowMs: options.windowMs,
|
|
101
|
+
retryAfter: retryAfterSeconds
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
sendSecurityResponse(req, res, error, options.statusCode, options.handler);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return limiterMiddleware;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
rateLimiter: createRateLimiter,
|
|
113
|
+
createRateLimiter,
|
|
114
|
+
defaultKeyGenerator
|
|
115
|
+
};
|
|
116
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bytes = require('bytes');
|
|
4
|
+
const defaults = require('../config/defaults');
|
|
5
|
+
const { validateRequestSizeConfig } = require('../config/validateConfig');
|
|
6
|
+
const { PayloadTooLargeError, SecurityError } = require('../utils/errors');
|
|
7
|
+
const { sendSecurityResponse } = require('../utils/response');
|
|
8
|
+
const { defaultLogger } = require('../utils/logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parses and validates Content-Length header against HTTP smuggling / desync attacks.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('express').Request} req
|
|
14
|
+
* @returns {{ length: number | null, error: string | null }}
|
|
15
|
+
*/
|
|
16
|
+
function parseContentLength(req) {
|
|
17
|
+
const rawHeader = req.headers && req.headers['content-length'];
|
|
18
|
+
|
|
19
|
+
if (rawHeader === undefined || rawHeader === null || rawHeader === '') {
|
|
20
|
+
return { length: null, error: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Handle array of headers or comma-separated multiple values
|
|
24
|
+
const values = Array.isArray(rawHeader)
|
|
25
|
+
? rawHeader
|
|
26
|
+
: String(rawHeader).split(',').map((v) => v.trim());
|
|
27
|
+
|
|
28
|
+
// Check for differing values in multiple headers (HTTP request smuggling risk)
|
|
29
|
+
const uniqueValues = new Set(values);
|
|
30
|
+
if (uniqueValues.size > 1) {
|
|
31
|
+
return {
|
|
32
|
+
length: null,
|
|
33
|
+
error: 'Conflicting Content-Length headers detected (possible HTTP smuggling).'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lengthStr = values[0];
|
|
38
|
+
if (!/^\d+$/.test(lengthStr)) {
|
|
39
|
+
return {
|
|
40
|
+
length: null,
|
|
41
|
+
error: 'Malformed Content-Length header: must be a non-negative integer.'
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = parseInt(lengthStr, 10);
|
|
46
|
+
return { length: parsed, error: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates request size limiting middleware.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} [userOptions]
|
|
53
|
+
* @param {string|number} [userOptions.maxSize='1mb'] - Max size in bytes or string ('1mb', '500kb').
|
|
54
|
+
* @param {number} [userOptions.statusCode=413] - HTTP status code on limit exceed.
|
|
55
|
+
* @param {string} [userOptions.message] - Custom error message.
|
|
56
|
+
* @param {Function} [userOptions.handler] - Custom response handler.
|
|
57
|
+
* @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
|
|
58
|
+
* @returns {import('express').RequestHandler}
|
|
59
|
+
*/
|
|
60
|
+
function createRequestSize(userOptions = {}) {
|
|
61
|
+
validateRequestSizeConfig(userOptions);
|
|
62
|
+
|
|
63
|
+
const options = {
|
|
64
|
+
...defaults.requestSize,
|
|
65
|
+
...userOptions
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const maxBytes = typeof options.maxSize === 'number'
|
|
69
|
+
? options.maxSize
|
|
70
|
+
: bytes.parse(options.maxSize) || 1048576; // default 1MB
|
|
71
|
+
|
|
72
|
+
const logger = options.logger || defaultLogger;
|
|
73
|
+
|
|
74
|
+
function rejectOversized(req, res, details) {
|
|
75
|
+
logger.warn('REQUEST_SIZE_EXCEEDED', `Payload exceeded size limit (${maxBytes} bytes)`, {
|
|
76
|
+
req,
|
|
77
|
+
details
|
|
78
|
+
});
|
|
79
|
+
res.setHeader('Connection', 'close');
|
|
80
|
+
const error = new PayloadTooLargeError(options.message, details);
|
|
81
|
+
return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return function requestSizeMiddleware(req, res, next) {
|
|
85
|
+
// 1. Inspect Content-Length header
|
|
86
|
+
const { length: contentLength, error: headerError } = parseContentLength(req);
|
|
87
|
+
|
|
88
|
+
if (headerError) {
|
|
89
|
+
logger.warn('REQUEST_SIZE_HEADER_INVALID', headerError, { req });
|
|
90
|
+
const secError = new SecurityError(headerError, 400, 'BAD_REQUEST');
|
|
91
|
+
return sendSecurityResponse(req, res, secError, 400, options.handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (contentLength !== null && contentLength > maxBytes) {
|
|
95
|
+
return rejectOversized(req, res, { contentLength, maxBytes });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Check if body is already parsed by upstream body parser
|
|
99
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
100
|
+
try {
|
|
101
|
+
const estimatedSize = Buffer.isBuffer(req.body)
|
|
102
|
+
? req.body.length
|
|
103
|
+
: Buffer.byteLength(JSON.stringify(req.body), 'utf8');
|
|
104
|
+
|
|
105
|
+
if (estimatedSize > maxBytes) {
|
|
106
|
+
return rejectOversized(req, res, { estimatedSize, maxBytes });
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Ignore serialization failure; proceed to streaming check
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. For unparsed streams or chunked transfer-encoding, monitor incoming chunks safely
|
|
114
|
+
let receivedBytes = 0;
|
|
115
|
+
let rejected = false;
|
|
116
|
+
|
|
117
|
+
const originalEmit = req.emit;
|
|
118
|
+
if (typeof originalEmit === 'function') {
|
|
119
|
+
req.emit = function (eventName, ...args) {
|
|
120
|
+
if (eventName === 'data' && !rejected) {
|
|
121
|
+
const chunk = args[0];
|
|
122
|
+
if (chunk) {
|
|
123
|
+
receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
124
|
+
if (receivedBytes > maxBytes) {
|
|
125
|
+
rejected = true;
|
|
126
|
+
if (typeof req.pause === 'function') {
|
|
127
|
+
req.pause();
|
|
128
|
+
}
|
|
129
|
+
rejectOversized(req, res, { receivedBytes, maxBytes });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return originalEmit.apply(this, [eventName, ...args]);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return next();
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
requestSize: createRequestSize,
|
|
144
|
+
createRequestSize,
|
|
145
|
+
parseContentLength
|
|
146
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const defaults = require('../config/defaults');
|
|
4
|
+
const { ValidationError } = require('../utils/errors');
|
|
5
|
+
const { sendSecurityResponse } = require('../utils/response');
|
|
6
|
+
const { defaultLogger } = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Formats validation issues into a clean, client-safe array of field-level errors.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array<any>} rawIssues
|
|
12
|
+
* @param {'body'|'query'|'params'|'headers'} target
|
|
13
|
+
* @returns {Array<{ field: string, message: string, code?: string }>}
|
|
14
|
+
*/
|
|
15
|
+
function formatIssues(rawIssues = [], target) {
|
|
16
|
+
return rawIssues.map((issue) => {
|
|
17
|
+
let field = target;
|
|
18
|
+
if (issue.path && issue.path.length > 0) {
|
|
19
|
+
field = `${target}.${issue.path.join('.')}`;
|
|
20
|
+
} else if (issue.field) {
|
|
21
|
+
field = `${target}.${issue.field}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
field,
|
|
26
|
+
message: issue.message || 'Invalid value',
|
|
27
|
+
code: issue.code || 'INVALID_INPUT'
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates a target object against a schema or validator function.
|
|
34
|
+
* Supports Zod schemas, Joi-like schemas, or custom functions.
|
|
35
|
+
*
|
|
36
|
+
* @param {any} schema - Validation schema or function.
|
|
37
|
+
* @param {any} data - Data to validate.
|
|
38
|
+
* @param {'body'|'query'|'params'|'headers'} target - Target name for error messages.
|
|
39
|
+
* @returns {Promise<{ success: boolean, data?: any, issues?: Array<any> }>}
|
|
40
|
+
*/
|
|
41
|
+
async function validateTarget(schema, data, target) {
|
|
42
|
+
if (!schema) {
|
|
43
|
+
return { success: true, data };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1. Custom function: (data) => boolean | { success, errors, data }
|
|
47
|
+
if (typeof schema === 'function' && !schema.safeParse && !schema.parse) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await schema(data);
|
|
50
|
+
if (res === true || (res && res.success === true)) {
|
|
51
|
+
return { success: true, data: res.data !== undefined ? res.data : data };
|
|
52
|
+
}
|
|
53
|
+
const issues = res && res.errors ? res.errors : [{ message: 'Validation rule failed' }];
|
|
54
|
+
return { success: false, issues: formatIssues(issues, target) };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { success: false, issues: [{ field: target, message: err.message, code: 'VALIDATION_ERROR' }] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Zod schema: safeParseAsync or safeParse
|
|
61
|
+
const parseFn = typeof schema.safeParseAsync === 'function'
|
|
62
|
+
? schema.safeParseAsync.bind(schema)
|
|
63
|
+
: (typeof schema.safeParse === 'function' ? schema.safeParse.bind(schema) : null);
|
|
64
|
+
|
|
65
|
+
if (parseFn) {
|
|
66
|
+
const result = await parseFn(data);
|
|
67
|
+
return result.success
|
|
68
|
+
? { success: true, data: result.data }
|
|
69
|
+
: { success: false, issues: formatIssues(result.error.issues, target) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Fallback for schema with parse() method
|
|
73
|
+
if (typeof schema.parse === 'function') {
|
|
74
|
+
try {
|
|
75
|
+
const parsedData = schema.parse(data);
|
|
76
|
+
return { success: true, data: parsedData };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const issues = err.issues || [{ message: err.message }];
|
|
79
|
+
return { success: false, issues: formatIssues(issues, target) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`Unsupported schema format for ${target}. Must be a Zod schema or validator function.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates request validation middleware.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} schemas - Validation schemas.
|
|
90
|
+
* @param {any} [schemas.body] - Schema for req.body.
|
|
91
|
+
* @param {any} [schemas.query] - Schema for req.query.
|
|
92
|
+
* @param {any} [schemas.params] - Schema for req.params.
|
|
93
|
+
* @param {any} [schemas.headers] - Schema for req.headers.
|
|
94
|
+
* @param {Object} [userOptions]
|
|
95
|
+
* @param {boolean} [userOptions.assignToReq=false] - Whether to mutate req.body/req.query with parsed data (default false: store in req.validated).
|
|
96
|
+
* @param {number} [userOptions.statusCode=400] - Status code on failure.
|
|
97
|
+
* @param {string} [userOptions.message] - Custom error message.
|
|
98
|
+
* @param {Function} [userOptions.handler] - Custom error handler.
|
|
99
|
+
* @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
|
|
100
|
+
* @returns {import('express').RequestHandler}
|
|
101
|
+
*/
|
|
102
|
+
function createRequestValidator(schemas = {}, userOptions = {}) {
|
|
103
|
+
// If user passed a single Zod schema directly as first arg
|
|
104
|
+
let targetSchemas = schemas;
|
|
105
|
+
if (schemas && (typeof schemas.safeParse === 'function' || typeof schemas.safeParseAsync === 'function')) {
|
|
106
|
+
targetSchemas = { body: schemas };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const options = {
|
|
110
|
+
...defaults.requestValidator,
|
|
111
|
+
...userOptions
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const logger = options.logger || defaultLogger;
|
|
115
|
+
|
|
116
|
+
return async function requestValidatorMiddleware(req, res, next) {
|
|
117
|
+
const targets = ['body', 'query', 'params', 'headers'];
|
|
118
|
+
const allIssues = [];
|
|
119
|
+
const validatedData = {};
|
|
120
|
+
|
|
121
|
+
for (const target of targets) {
|
|
122
|
+
const schema = targetSchemas[target];
|
|
123
|
+
if (schema) {
|
|
124
|
+
const dataToValidate = req[target] || {};
|
|
125
|
+
const result = await validateTarget(schema, dataToValidate, target);
|
|
126
|
+
|
|
127
|
+
if (!result.success) {
|
|
128
|
+
allIssues.push(...result.issues);
|
|
129
|
+
} else {
|
|
130
|
+
validatedData[target] = result.data;
|
|
131
|
+
if (options.assignToReq) {
|
|
132
|
+
req[target] = result.data;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (allIssues.length > 0) {
|
|
139
|
+
logger.warn('REQUEST_VALIDATION_FAILED', `Validation failed for ${req.method} ${req.path}`, {
|
|
140
|
+
req,
|
|
141
|
+
details: { issues: allIssues }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const error = new ValidationError(options.message, { issues: allIssues });
|
|
145
|
+
return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Attach validated data securely to req.validated
|
|
149
|
+
req.validated = {
|
|
150
|
+
...req.validated,
|
|
151
|
+
...validatedData
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return next();
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
requestValidator: createRequestValidator,
|
|
160
|
+
createRequestValidator,
|
|
161
|
+
formatIssues
|
|
162
|
+
};
|
|
163
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const helmet = require('helmet');
|
|
4
|
+
const defaults = require('../config/defaults');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates security headers middleware powered by Helmet with hardened API defaults.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} [userOptions]
|
|
10
|
+
* @param {boolean|Object} [userOptions.contentSecurityPolicy]
|
|
11
|
+
* @param {boolean|Object} [userOptions.hsts]
|
|
12
|
+
* @param {boolean|Object} [userOptions.frameguard]
|
|
13
|
+
* @param {boolean} [userOptions.noSniff]
|
|
14
|
+
* @param {boolean|Object} [userOptions.referrerPolicy]
|
|
15
|
+
* @param {boolean} [userOptions.noIndex=false] - Adds X-Robots-Tag: noindex, nofollow
|
|
16
|
+
* @param {boolean|string} [userOptions.permissionsPolicy=true] - Adds Permissions-Policy header
|
|
17
|
+
* @returns {import('express').RequestHandler}
|
|
18
|
+
*/
|
|
19
|
+
function createSecurityHeaders(userOptions = {}) {
|
|
20
|
+
const helmetOptions = {
|
|
21
|
+
...defaults.securityHeaders,
|
|
22
|
+
...userOptions
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Extract non-helmet custom options
|
|
26
|
+
const noIndex = userOptions.noIndex === true;
|
|
27
|
+
const permissionsPolicy = userOptions.permissionsPolicy !== undefined
|
|
28
|
+
? userOptions.permissionsPolicy
|
|
29
|
+
: 'geolocation=(), camera=(), microphone=(), payment=()';
|
|
30
|
+
|
|
31
|
+
delete helmetOptions.noIndex;
|
|
32
|
+
delete helmetOptions.permissionsPolicy;
|
|
33
|
+
|
|
34
|
+
const helmetMiddleware = helmet(helmetOptions);
|
|
35
|
+
|
|
36
|
+
return function securityHeadersMiddleware(req, res, next) {
|
|
37
|
+
// 1. Apply Helmet standard headers
|
|
38
|
+
helmetMiddleware(req, res, (err) => {
|
|
39
|
+
if (err) return next(err);
|
|
40
|
+
|
|
41
|
+
// 2. Extra API security headers
|
|
42
|
+
if (noIndex) {
|
|
43
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (permissionsPolicy && typeof permissionsPolicy === 'string') {
|
|
47
|
+
res.setHeader('Permissions-Policy', permissionsPolicy);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return next();
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
securityHeaders: createSecurityHeaders,
|
|
57
|
+
createSecurityHeaders
|
|
58
|
+
};
|
|
59
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const defaults = require('../config/defaults');
|
|
4
|
+
const { validateThreatDetectorConfig } = require('../config/validateConfig');
|
|
5
|
+
const { DEFAULT_THREAT_PATTERNS, scanValue } = require('../security/detector');
|
|
6
|
+
const { ThreatDetectedError } = require('../utils/errors');
|
|
7
|
+
const { sendSecurityResponse } = require('../utils/response');
|
|
8
|
+
const { defaultLogger } = require('../utils/logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates threat detection middleware.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} [userOptions]
|
|
14
|
+
* @param {'reject'|'log'|'pass'} [userOptions.action='reject'] - Action on threat detection.
|
|
15
|
+
* @param {number} [userOptions.statusCode=403] - HTTP status code when rejected.
|
|
16
|
+
* @param {string} [userOptions.message] - Custom rejection message.
|
|
17
|
+
* @param {boolean} [userOptions.inspectUrl=true] - Inspect URL path.
|
|
18
|
+
* @param {boolean} [userOptions.inspectQuery=true] - Inspect query parameters.
|
|
19
|
+
* @param {boolean} [userOptions.inspectBody=true] - Inspect request body.
|
|
20
|
+
* @param {boolean} [userOptions.inspectHeaders=false] - Inspect headers (default false to prevent false positives).
|
|
21
|
+
* @param {Array<string|RegExp>} [userOptions.whitelistedPaths=[]] - Paths to bypass threat detection.
|
|
22
|
+
* @param {string[]} [userOptions.whitelistedKeys=[]] - Field names to bypass inspection.
|
|
23
|
+
* @param {Array<Object>} [userOptions.customRules=[]] - Custom threat rules.
|
|
24
|
+
* @param {Function} [userOptions.handler] - Custom response handler.
|
|
25
|
+
* @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
|
|
26
|
+
* @returns {import('express').RequestHandler}
|
|
27
|
+
*/
|
|
28
|
+
function createThreatDetector(userOptions = {}) {
|
|
29
|
+
validateThreatDetectorConfig(userOptions);
|
|
30
|
+
|
|
31
|
+
const options = {
|
|
32
|
+
...defaults.threatDetector,
|
|
33
|
+
...userOptions
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const logger = options.logger || defaultLogger;
|
|
37
|
+
const allRules = [...DEFAULT_THREAT_PATTERNS, ...(options.customRules || [])];
|
|
38
|
+
const whitelistedKeys = (options.whitelistedKeys || []).map((k) => k.toLowerCase());
|
|
39
|
+
|
|
40
|
+
return function threatDetectorMiddleware(req, res, next) {
|
|
41
|
+
if (options.action === 'pass') {
|
|
42
|
+
return next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const currentPath = req.originalUrl || req.path || req.url || '';
|
|
46
|
+
|
|
47
|
+
// Check whitelisted paths
|
|
48
|
+
if (options.whitelistedPaths && options.whitelistedPaths.length > 0) {
|
|
49
|
+
const isWhitelisted = options.whitelistedPaths.some((p) =>
|
|
50
|
+
typeof p === 'string' ? (currentPath === p || currentPath.startsWith(p)) : (p instanceof RegExp && p.test(currentPath))
|
|
51
|
+
);
|
|
52
|
+
if (isWhitelisted) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const detectedThreats = [];
|
|
58
|
+
|
|
59
|
+
// 1. Inspect URL (path and raw query)
|
|
60
|
+
if (options.inspectUrl) {
|
|
61
|
+
let rawUrl = req.url || '';
|
|
62
|
+
try {
|
|
63
|
+
rawUrl = decodeURIComponent(rawUrl);
|
|
64
|
+
} catch {
|
|
65
|
+
// malformed URI encoding could itself be an attack vector
|
|
66
|
+
detectedThreats.push({
|
|
67
|
+
ruleId: 'MALFORMED_URI',
|
|
68
|
+
ruleName: 'Malformed URI Encoding',
|
|
69
|
+
path: 'url',
|
|
70
|
+
snippet: req.url,
|
|
71
|
+
severity: 'high'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (rawUrl) {
|
|
76
|
+
detectedThreats.push(...scanValue(rawUrl, allRules, 'url', whitelistedKeys));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Inspect Query
|
|
81
|
+
if (options.inspectQuery && req.query && typeof req.query === 'object') {
|
|
82
|
+
detectedThreats.push(...scanValue(req.query, allRules, 'query', whitelistedKeys));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Inspect Body
|
|
86
|
+
if (options.inspectBody && req.body && typeof req.body === 'object') {
|
|
87
|
+
detectedThreats.push(...scanValue(req.body, allRules, 'body', whitelistedKeys));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Inspect Headers
|
|
91
|
+
if (options.inspectHeaders && req.headers && typeof req.headers === 'object') {
|
|
92
|
+
// Exclude standard headers that regularly contain symbols
|
|
93
|
+
const ignoredHeaders = ['user-agent', 'accept', 'cookie', 'host', 'authorization'];
|
|
94
|
+
const headersToScan = {};
|
|
95
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
96
|
+
if (!ignoredHeaders.includes(k.toLowerCase())) {
|
|
97
|
+
headersToScan[k] = v;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
detectedThreats.push(...scanValue(headersToScan, allRules, 'headers', whitelistedKeys));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (detectedThreats.length > 0) {
|
|
104
|
+
req.detectedThreats = detectedThreats;
|
|
105
|
+
|
|
106
|
+
logger.warn('THREAT_DETECTED', `Threat patterns detected on ${req.method} ${currentPath}`, {
|
|
107
|
+
req,
|
|
108
|
+
details: {
|
|
109
|
+
threatCount: detectedThreats.length,
|
|
110
|
+
threats: detectedThreats.map((t) => ({ ruleId: t.ruleId, path: t.path, severity: t.severity }))
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (options.action === 'reject') {
|
|
115
|
+
const error = new ThreatDetectedError(options.message, options.statusCode, {
|
|
116
|
+
threats: detectedThreats.map((t) => ({ ruleId: t.ruleId, severity: t.severity }))
|
|
117
|
+
});
|
|
118
|
+
return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return next();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
threatDetector: createThreatDetector,
|
|
128
|
+
createThreatDetector
|
|
129
|
+
};
|
|
130
|
+
|