s3db.js 13.5.1 → 13.6.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/README.md +25 -10
- package/dist/{s3db.cjs.js → s3db.cjs} +30323 -24958
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +24026 -18654
- package/dist/s3db.es.js.map +1 -1
- package/package.json +216 -20
- package/src/concerns/id.js +90 -6
- package/src/concerns/index.js +2 -1
- package/src/concerns/password-hashing.js +150 -0
- package/src/database.class.js +4 -0
- package/src/plugins/api/auth/basic-auth.js +23 -1
- package/src/plugins/api/auth/index.js +49 -3
- package/src/plugins/api/auth/oauth2-auth.js +171 -0
- package/src/plugins/api/auth/oidc-auth.js +789 -0
- package/src/plugins/api/auth/oidc-client.js +462 -0
- package/src/plugins/api/auth/path-auth-matcher.js +284 -0
- package/src/plugins/api/concerns/event-emitter.js +134 -0
- package/src/plugins/api/concerns/failban-manager.js +651 -0
- package/src/plugins/api/concerns/guards-helpers.js +402 -0
- package/src/plugins/api/concerns/metrics-collector.js +346 -0
- package/src/plugins/api/index.js +503 -54
- package/src/plugins/api/middlewares/failban.js +305 -0
- package/src/plugins/api/middlewares/rate-limit.js +301 -0
- package/src/plugins/api/middlewares/request-id.js +74 -0
- package/src/plugins/api/middlewares/security-headers.js +120 -0
- package/src/plugins/api/middlewares/session-tracking.js +194 -0
- package/src/plugins/api/routes/auth-routes.js +23 -3
- package/src/plugins/api/routes/resource-routes.js +71 -29
- package/src/plugins/api/server.js +1017 -94
- package/src/plugins/api/utils/guards.js +213 -0
- package/src/plugins/api/utils/mime-types.js +154 -0
- package/src/plugins/api/utils/openapi-generator.js +44 -11
- package/src/plugins/api/utils/path-matcher.js +173 -0
- package/src/plugins/api/utils/static-filesystem.js +262 -0
- package/src/plugins/api/utils/static-s3.js +231 -0
- package/src/plugins/api/utils/template-engine.js +188 -0
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
- package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
- package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
- package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
- package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
- package/src/plugins/cloud-inventory/index.js +20 -0
- package/src/plugins/cloud-inventory/registry.js +146 -0
- package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
- package/src/plugins/cloud-inventory.plugin.js +1333 -0
- package/src/plugins/concerns/plugin-dependencies.js +61 -1
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- package/src/plugins/identity/README.md +335 -0
- package/src/plugins/identity/concerns/mfa-manager.js +204 -0
- package/src/plugins/identity/concerns/password.js +138 -0
- package/src/plugins/identity/concerns/resource-schemas.js +273 -0
- package/src/plugins/identity/concerns/token-generator.js +172 -0
- package/src/plugins/identity/email-service.js +422 -0
- package/src/plugins/identity/index.js +1052 -0
- package/src/plugins/identity/oauth2-server.js +1033 -0
- package/src/plugins/identity/oidc-discovery.js +285 -0
- package/src/plugins/identity/rsa-keys.js +323 -0
- package/src/plugins/identity/server.js +500 -0
- package/src/plugins/identity/session-manager.js +453 -0
- package/src/plugins/identity/ui/layouts/base.js +251 -0
- package/src/plugins/identity/ui/middleware.js +135 -0
- package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
- package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
- package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
- package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
- package/src/plugins/identity/ui/pages/admin/users.js +263 -0
- package/src/plugins/identity/ui/pages/consent.js +262 -0
- package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
- package/src/plugins/identity/ui/pages/login.js +144 -0
- package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
- package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
- package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
- package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
- package/src/plugins/identity/ui/pages/profile.js +361 -0
- package/src/plugins/identity/ui/pages/register.js +226 -0
- package/src/plugins/identity/ui/pages/reset-password.js +128 -0
- package/src/plugins/identity/ui/pages/verify-email.js +172 -0
- package/src/plugins/identity/ui/routes.js +2541 -0
- package/src/plugins/identity/ui/styles/main.css +465 -0
- package/src/plugins/index.js +4 -1
- package/src/plugins/ml/base-model.class.js +32 -7
- package/src/plugins/ml/classification-model.class.js +1 -1
- package/src/plugins/ml/timeseries-model.class.js +3 -1
- package/src/plugins/ml.plugin.js +124 -32
- package/src/plugins/shared/error-handler.js +147 -0
- package/src/plugins/shared/index.js +9 -0
- package/src/plugins/shared/middlewares/compression.js +117 -0
- package/src/plugins/shared/middlewares/cors.js +49 -0
- package/src/plugins/shared/middlewares/index.js +11 -0
- package/src/plugins/shared/middlewares/logging.js +54 -0
- package/src/plugins/shared/middlewares/rate-limit.js +73 -0
- package/src/plugins/shared/middlewares/security.js +158 -0
- package/src/plugins/shared/response-formatter.js +264 -0
- package/src/resource.class.js +140 -12
- package/src/schema.class.js +30 -1
- package/src/validator.class.js +57 -6
- package/dist/s3db.cjs.js.map +0 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failban Manager - Internal IP banning manager for API Plugin
|
|
3
|
+
*
|
|
4
|
+
* fail2ban-style automatic banning system integrated into API Plugin.
|
|
5
|
+
* NOT a standalone plugin - managed internally by ApiServer.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Auto-ban after multiple rate limit violations
|
|
9
|
+
* - Persistent ban storage in S3DB
|
|
10
|
+
* - TTL-based auto-unban
|
|
11
|
+
* - IP Whitelist/Blacklist support
|
|
12
|
+
* - GeoIP Country blocking (MaxMind GeoLite2)
|
|
13
|
+
* - Events: security:banned, security:unbanned, security:violation, security:country_blocked
|
|
14
|
+
* - Admin endpoints for manual ban management
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const manager = new FailbanManager({
|
|
18
|
+
* database,
|
|
19
|
+
* enabled: true,
|
|
20
|
+
* maxViolations: 3,
|
|
21
|
+
* violationWindow: 3600000,
|
|
22
|
+
* banDuration: 86400000,
|
|
23
|
+
* whitelist: ['127.0.0.1'],
|
|
24
|
+
* geo: {
|
|
25
|
+
* enabled: true,
|
|
26
|
+
* databasePath: '/path/to/GeoLite2-Country.mmdb',
|
|
27
|
+
* allowedCountries: ['BR', 'US']
|
|
28
|
+
* }
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* await manager.initialize();
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { requirePluginDependency } from '../../concerns/plugin-dependencies.js';
|
|
35
|
+
|
|
36
|
+
export class FailbanManager {
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.options = {
|
|
39
|
+
enabled: options.enabled !== false,
|
|
40
|
+
database: options.database,
|
|
41
|
+
maxViolations: options.maxViolations || 3,
|
|
42
|
+
violationWindow: options.violationWindow || 3600000,
|
|
43
|
+
banDuration: options.banDuration || 86400000,
|
|
44
|
+
whitelist: options.whitelist || ['127.0.0.1', '::1'],
|
|
45
|
+
blacklist: options.blacklist || [],
|
|
46
|
+
persistViolations: options.persistViolations !== false,
|
|
47
|
+
verbose: options.verbose || false,
|
|
48
|
+
geo: {
|
|
49
|
+
enabled: options.geo?.enabled || false,
|
|
50
|
+
databasePath: options.geo?.databasePath || null,
|
|
51
|
+
allowedCountries: options.geo?.allowedCountries || [],
|
|
52
|
+
blockedCountries: options.geo?.blockedCountries || [],
|
|
53
|
+
blockUnknown: options.geo?.blockUnknown || false,
|
|
54
|
+
cacheResults: options.geo?.cacheResults !== false
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
this.database = options.database;
|
|
59
|
+
this.bansResource = null;
|
|
60
|
+
this.violationsResource = null;
|
|
61
|
+
this.memoryCache = new Map();
|
|
62
|
+
this.geoCache = new Map();
|
|
63
|
+
this.geoReader = null;
|
|
64
|
+
this.cleanupTimer = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Initialize failban manager
|
|
69
|
+
*/
|
|
70
|
+
async initialize() {
|
|
71
|
+
if (!this.options.enabled) {
|
|
72
|
+
if (this.options.verbose) {
|
|
73
|
+
console.log('[Failban] Disabled, skipping initialization');
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!this.database) {
|
|
79
|
+
throw new Error('[Failban] Database instance is required');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Initialize GeoIP if enabled
|
|
83
|
+
if (this.options.geo.enabled) {
|
|
84
|
+
await this._initializeGeoIP();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Create bans resource with TTL
|
|
88
|
+
this.bansResource = await this._createBansResource();
|
|
89
|
+
|
|
90
|
+
// Create violations tracking resource (optional)
|
|
91
|
+
if (this.options.persistViolations) {
|
|
92
|
+
this.violationsResource = await this._createViolationsResource();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Load existing bans into memory cache
|
|
96
|
+
await this._loadBansIntoCache();
|
|
97
|
+
|
|
98
|
+
// Setup cleanup timer for memory cache
|
|
99
|
+
this._setupCleanupTimer();
|
|
100
|
+
|
|
101
|
+
if (this.options.verbose) {
|
|
102
|
+
console.log('[Failban] Initialized');
|
|
103
|
+
console.log(`[Failban] Max violations: ${this.options.maxViolations}`);
|
|
104
|
+
console.log(`[Failban] Violation window: ${this.options.violationWindow}ms`);
|
|
105
|
+
console.log(`[Failban] Ban duration: ${this.options.banDuration}ms`);
|
|
106
|
+
console.log(`[Failban] Whitelist: ${this.options.whitelist.join(', ')}`);
|
|
107
|
+
|
|
108
|
+
if (this.options.geo.enabled) {
|
|
109
|
+
console.log(`[Failban] GeoIP enabled`);
|
|
110
|
+
console.log(`[Failban] Allowed countries: ${this.options.geo.allowedCountries.join(', ') || 'none'}`);
|
|
111
|
+
console.log(`[Failban] Blocked countries: ${this.options.geo.blockedCountries.join(', ') || 'none'}`);
|
|
112
|
+
console.log(`[Failban] Block unknown: ${this.options.geo.blockUnknown}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create bans resource with TTL support
|
|
119
|
+
* @private
|
|
120
|
+
*/
|
|
121
|
+
async _createBansResource() {
|
|
122
|
+
const resourceName = '_api_failban_bans';
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return await this.database.getResource(resourceName);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const resource = await this.database.createResource({
|
|
128
|
+
name: resourceName,
|
|
129
|
+
attributes: {
|
|
130
|
+
ip: 'string|required',
|
|
131
|
+
reason: 'string',
|
|
132
|
+
violations: 'number',
|
|
133
|
+
bannedAt: 'string',
|
|
134
|
+
expiresAt: 'string|required',
|
|
135
|
+
metadata: {
|
|
136
|
+
userAgent: 'string',
|
|
137
|
+
path: 'string',
|
|
138
|
+
lastViolation: 'string'
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
behavior: 'body-overflow',
|
|
142
|
+
timestamps: true,
|
|
143
|
+
partitions: {
|
|
144
|
+
byExpiry: {
|
|
145
|
+
fields: { expiresAtCohort: 'string' }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Apply TTL plugin to this resource
|
|
151
|
+
const ttlPlugin = this.database.plugins?.ttl || this.database.plugins?.TTLPlugin;
|
|
152
|
+
if (ttlPlugin) {
|
|
153
|
+
ttlPlugin.options.resources = ttlPlugin.options.resources || {};
|
|
154
|
+
ttlPlugin.options.resources[resourceName] = {
|
|
155
|
+
enabled: true,
|
|
156
|
+
field: 'expiresAt'
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (this.options.verbose) {
|
|
160
|
+
console.log('[Failban] TTL configured for bans resource');
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
console.warn('[Failban] TTLPlugin not found - bans will not auto-expire from DB');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return resource;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create violations tracking resource
|
|
172
|
+
* @private
|
|
173
|
+
*/
|
|
174
|
+
async _createViolationsResource() {
|
|
175
|
+
const resourceName = '_api_failban_violations';
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return await this.database.getResource(resourceName);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return await this.database.createResource({
|
|
181
|
+
name: resourceName,
|
|
182
|
+
attributes: {
|
|
183
|
+
ip: 'string|required',
|
|
184
|
+
timestamp: 'string|required',
|
|
185
|
+
type: 'string',
|
|
186
|
+
path: 'string',
|
|
187
|
+
userAgent: 'string'
|
|
188
|
+
},
|
|
189
|
+
behavior: 'body-overflow',
|
|
190
|
+
timestamps: true,
|
|
191
|
+
partitions: {
|
|
192
|
+
byIp: {
|
|
193
|
+
fields: { ip: 'string' }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load existing bans into memory cache
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
async _loadBansIntoCache() {
|
|
205
|
+
try {
|
|
206
|
+
const bans = await this.bansResource.list({ limit: 1000 });
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
|
|
209
|
+
for (const ban of bans) {
|
|
210
|
+
const expiresAt = new Date(ban.expiresAt).getTime();
|
|
211
|
+
if (expiresAt > now) {
|
|
212
|
+
this.memoryCache.set(ban.ip, {
|
|
213
|
+
expiresAt,
|
|
214
|
+
reason: ban.reason,
|
|
215
|
+
violations: ban.violations
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (this.options.verbose) {
|
|
221
|
+
console.log(`[Failban] Loaded ${this.memoryCache.size} active bans into cache`);
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error('[Failban] Failed to load bans:', err.message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Setup cleanup timer for memory cache
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
_setupCleanupTimer() {
|
|
233
|
+
this.cleanupTimer = setInterval(() => {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
let cleaned = 0;
|
|
236
|
+
|
|
237
|
+
for (const [ip, ban] of this.memoryCache.entries()) {
|
|
238
|
+
if (ban.expiresAt <= now) {
|
|
239
|
+
this.memoryCache.delete(ip);
|
|
240
|
+
cleaned++;
|
|
241
|
+
|
|
242
|
+
// Emit unban event
|
|
243
|
+
this.database.emit?.('security:unbanned', {
|
|
244
|
+
ip,
|
|
245
|
+
reason: 'expired',
|
|
246
|
+
bannedFor: ban.reason
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (this.options.verbose && cleaned > 0) {
|
|
252
|
+
console.log(`[Failban] Cleaned ${cleaned} expired bans from cache`);
|
|
253
|
+
}
|
|
254
|
+
}, 60000); // Every minute
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Initialize GeoIP reader
|
|
259
|
+
* @private
|
|
260
|
+
*/
|
|
261
|
+
async _initializeGeoIP() {
|
|
262
|
+
if (!this.options.geo.databasePath) {
|
|
263
|
+
console.warn('[Failban] GeoIP enabled but no databasePath provided');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const Reader = await requirePluginDependency(
|
|
269
|
+
'@maxmind/geoip2-node',
|
|
270
|
+
'ApiPlugin (Failban)',
|
|
271
|
+
'GeoIP country blocking'
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
this.geoReader = await Reader.open(this.options.geo.databasePath);
|
|
275
|
+
|
|
276
|
+
if (this.options.verbose) {
|
|
277
|
+
console.log(`[Failban] GeoIP database loaded from ${this.options.geo.databasePath}`);
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error('[Failban] Failed to initialize GeoIP:', err.message);
|
|
281
|
+
console.warn('[Failban] GeoIP features will be disabled');
|
|
282
|
+
this.options.geo.enabled = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get country code for IP address
|
|
288
|
+
*/
|
|
289
|
+
getCountryCode(ip) {
|
|
290
|
+
if (!this.options.geo.enabled || !this.geoReader) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.options.geo.cacheResults && this.geoCache.has(ip)) {
|
|
295
|
+
return this.geoCache.get(ip);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const response = this.geoReader.country(ip);
|
|
300
|
+
const countryCode = response?.country?.isoCode || null;
|
|
301
|
+
|
|
302
|
+
if (this.options.geo.cacheResults) {
|
|
303
|
+
this.geoCache.set(ip, countryCode);
|
|
304
|
+
|
|
305
|
+
if (this.geoCache.size > 10000) {
|
|
306
|
+
const firstKey = this.geoCache.keys().next().value;
|
|
307
|
+
this.geoCache.delete(firstKey);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return countryCode;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (this.options.verbose) {
|
|
314
|
+
console.log(`[Failban] GeoIP lookup failed for ${ip}: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if country is blocked
|
|
322
|
+
*/
|
|
323
|
+
isCountryBlocked(countryCode) {
|
|
324
|
+
if (!this.options.geo.enabled) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!countryCode) {
|
|
329
|
+
return this.options.geo.blockUnknown;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const upperCode = countryCode.toUpperCase();
|
|
333
|
+
|
|
334
|
+
if (this.options.geo.blockedCountries.length > 0) {
|
|
335
|
+
if (this.options.geo.blockedCountries.includes(upperCode)) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (this.options.geo.allowedCountries.length > 0) {
|
|
341
|
+
return !this.options.geo.allowedCountries.includes(upperCode);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Check if IP is blocked by country restrictions
|
|
349
|
+
*/
|
|
350
|
+
checkCountryBlock(ip) {
|
|
351
|
+
if (!this.options.geo.enabled) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (this.isWhitelisted(ip)) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const countryCode = this.getCountryCode(ip);
|
|
360
|
+
|
|
361
|
+
if (this.isCountryBlocked(countryCode)) {
|
|
362
|
+
return {
|
|
363
|
+
blocked: true,
|
|
364
|
+
reason: 'country_restricted',
|
|
365
|
+
country: countryCode || 'unknown',
|
|
366
|
+
ip
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Check if IP is in whitelist
|
|
375
|
+
*/
|
|
376
|
+
isWhitelisted(ip) {
|
|
377
|
+
return this.options.whitelist.includes(ip);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Check if IP is in blacklist
|
|
382
|
+
*/
|
|
383
|
+
isBlacklisted(ip) {
|
|
384
|
+
return this.options.blacklist.includes(ip);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Check if IP is currently banned
|
|
389
|
+
*/
|
|
390
|
+
isBanned(ip) {
|
|
391
|
+
if (!this.options.enabled) return false;
|
|
392
|
+
if (this.isWhitelisted(ip)) return false;
|
|
393
|
+
if (this.isBlacklisted(ip)) return true;
|
|
394
|
+
|
|
395
|
+
const cachedBan = this.memoryCache.get(ip);
|
|
396
|
+
if (cachedBan) {
|
|
397
|
+
if (cachedBan.expiresAt > Date.now()) {
|
|
398
|
+
return true;
|
|
399
|
+
} else {
|
|
400
|
+
this.memoryCache.delete(ip);
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get ban details for IP
|
|
410
|
+
*/
|
|
411
|
+
async getBan(ip) {
|
|
412
|
+
if (!this.options.enabled) return null;
|
|
413
|
+
if (this.isBlacklisted(ip)) {
|
|
414
|
+
return {
|
|
415
|
+
ip,
|
|
416
|
+
reason: 'blacklisted',
|
|
417
|
+
permanent: true
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const ban = await this.bansResource.get(ip);
|
|
423
|
+
if (!ban) return null;
|
|
424
|
+
|
|
425
|
+
if (new Date(ban.expiresAt).getTime() <= Date.now()) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return ban;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Record a violation
|
|
437
|
+
*/
|
|
438
|
+
async recordViolation(ip, type = 'rate_limit', metadata = {}) {
|
|
439
|
+
if (!this.options.enabled) return;
|
|
440
|
+
if (this.isWhitelisted(ip)) return;
|
|
441
|
+
|
|
442
|
+
const now = new Date().toISOString();
|
|
443
|
+
|
|
444
|
+
this.database.emit?.('security:violation', {
|
|
445
|
+
ip,
|
|
446
|
+
type,
|
|
447
|
+
timestamp: now,
|
|
448
|
+
...metadata
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (this.violationsResource) {
|
|
452
|
+
try {
|
|
453
|
+
await this.violationsResource.insert({
|
|
454
|
+
id: `${ip}_${Date.now()}`,
|
|
455
|
+
ip,
|
|
456
|
+
timestamp: now,
|
|
457
|
+
type,
|
|
458
|
+
path: metadata.path,
|
|
459
|
+
userAgent: metadata.userAgent
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
console.error('[Failban] Failed to persist violation:', err.message);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await this._checkAndBan(ip, type, metadata);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Check violation count and ban if threshold exceeded
|
|
471
|
+
* @private
|
|
472
|
+
*/
|
|
473
|
+
async _checkAndBan(ip, type, metadata) {
|
|
474
|
+
if (this.isBanned(ip)) return;
|
|
475
|
+
|
|
476
|
+
const cutoff = new Date(Date.now() - this.options.violationWindow).toISOString();
|
|
477
|
+
let violationCount = 0;
|
|
478
|
+
|
|
479
|
+
if (this.violationsResource) {
|
|
480
|
+
try {
|
|
481
|
+
const violations = await this.violationsResource.query({
|
|
482
|
+
ip,
|
|
483
|
+
timestamp: { $gte: cutoff }
|
|
484
|
+
});
|
|
485
|
+
violationCount = violations.length;
|
|
486
|
+
} catch (err) {
|
|
487
|
+
console.error('[Failban] Failed to count violations:', err.message);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (violationCount >= this.options.maxViolations) {
|
|
493
|
+
await this.ban(ip, `${violationCount} ${type} violations`, metadata);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Ban an IP
|
|
499
|
+
*/
|
|
500
|
+
async ban(ip, reason, metadata = {}) {
|
|
501
|
+
if (!this.options.enabled) return;
|
|
502
|
+
if (this.isWhitelisted(ip)) {
|
|
503
|
+
console.warn(`[Failban] Cannot ban whitelisted IP: ${ip}`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const now = new Date();
|
|
508
|
+
const expiresAt = new Date(now.getTime() + this.options.banDuration);
|
|
509
|
+
|
|
510
|
+
const banRecord = {
|
|
511
|
+
id: ip,
|
|
512
|
+
ip,
|
|
513
|
+
reason,
|
|
514
|
+
violations: metadata.violationCount || this.options.maxViolations,
|
|
515
|
+
bannedAt: now.toISOString(),
|
|
516
|
+
expiresAt: expiresAt.toISOString(),
|
|
517
|
+
metadata: {
|
|
518
|
+
userAgent: metadata.userAgent,
|
|
519
|
+
path: metadata.path,
|
|
520
|
+
lastViolation: now.toISOString()
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await this.bansResource.insert(banRecord);
|
|
526
|
+
|
|
527
|
+
this.memoryCache.set(ip, {
|
|
528
|
+
expiresAt: expiresAt.getTime(),
|
|
529
|
+
reason,
|
|
530
|
+
violations: banRecord.violations
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
this.database.emit?.('security:banned', {
|
|
534
|
+
ip,
|
|
535
|
+
reason,
|
|
536
|
+
expiresAt: expiresAt.toISOString(),
|
|
537
|
+
duration: this.options.banDuration
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (this.options.verbose) {
|
|
541
|
+
console.log(`[Failban] Banned ${ip} for ${reason} until ${expiresAt.toISOString()}`);
|
|
542
|
+
}
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error('[Failban] Failed to ban IP:', err.message);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Unban an IP
|
|
550
|
+
*/
|
|
551
|
+
async unban(ip) {
|
|
552
|
+
if (!this.options.enabled) return;
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
await this.bansResource.delete(ip);
|
|
556
|
+
this.memoryCache.delete(ip);
|
|
557
|
+
|
|
558
|
+
this.database.emit?.('security:unbanned', {
|
|
559
|
+
ip,
|
|
560
|
+
reason: 'manual',
|
|
561
|
+
unbannedAt: new Date().toISOString()
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (this.options.verbose) {
|
|
565
|
+
console.log(`[Failban] Unbanned ${ip}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return true;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error('[Failban] Failed to unban IP:', err.message);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* List all active bans
|
|
577
|
+
*/
|
|
578
|
+
async listBans() {
|
|
579
|
+
if (!this.options.enabled) return [];
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
const bans = await this.bansResource.list({ limit: 1000 });
|
|
583
|
+
const now = Date.now();
|
|
584
|
+
|
|
585
|
+
return bans.filter(ban => new Date(ban.expiresAt).getTime() > now);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.error('[Failban] Failed to list bans:', err.message);
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Get statistics
|
|
594
|
+
*/
|
|
595
|
+
async getStats() {
|
|
596
|
+
const activeBans = await this.listBans();
|
|
597
|
+
|
|
598
|
+
let totalViolations = 0;
|
|
599
|
+
if (this.violationsResource) {
|
|
600
|
+
try {
|
|
601
|
+
const violations = await this.violationsResource.list({ limit: 10000 });
|
|
602
|
+
totalViolations = violations.length;
|
|
603
|
+
} catch (err) {
|
|
604
|
+
console.error('[Failban] Failed to count violations:', err.message);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
enabled: this.options.enabled,
|
|
610
|
+
activeBans: activeBans.length,
|
|
611
|
+
cachedBans: this.memoryCache.size,
|
|
612
|
+
totalViolations,
|
|
613
|
+
whitelistedIPs: this.options.whitelist.length,
|
|
614
|
+
blacklistedIPs: this.options.blacklist.length,
|
|
615
|
+
geo: {
|
|
616
|
+
enabled: this.options.geo.enabled,
|
|
617
|
+
allowedCountries: this.options.geo.allowedCountries.length,
|
|
618
|
+
blockedCountries: this.options.geo.blockedCountries.length,
|
|
619
|
+
blockUnknown: this.options.geo.blockUnknown
|
|
620
|
+
},
|
|
621
|
+
config: {
|
|
622
|
+
maxViolations: this.options.maxViolations,
|
|
623
|
+
violationWindow: this.options.violationWindow,
|
|
624
|
+
banDuration: this.options.banDuration
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Cleanup
|
|
631
|
+
*/
|
|
632
|
+
async cleanup() {
|
|
633
|
+
if (this.cleanupTimer) {
|
|
634
|
+
clearInterval(this.cleanupTimer);
|
|
635
|
+
this.cleanupTimer = null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this.memoryCache.clear();
|
|
639
|
+
this.geoCache.clear();
|
|
640
|
+
|
|
641
|
+
if (this.geoReader) {
|
|
642
|
+
this.geoReader = null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (this.options.verbose) {
|
|
646
|
+
console.log('[Failban] Cleaned up');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export default FailbanManager;
|