servcraft 0.1.0 → 0.1.3
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/.claude/settings.local.json +30 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/LICENSE +21 -0
- package/README.md +1102 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Middleware
|
|
3
|
+
* Provides request-level security protections
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
FastifyRequest,
|
|
7
|
+
FastifyReply,
|
|
8
|
+
FastifyInstance,
|
|
9
|
+
HookHandlerDoneFunction,
|
|
10
|
+
} from 'fastify';
|
|
11
|
+
import { randomBytes } from 'crypto';
|
|
12
|
+
import {
|
|
13
|
+
sanitizeObject,
|
|
14
|
+
containsDangerousContent,
|
|
15
|
+
type SanitizeMiddlewareOptions,
|
|
16
|
+
} from './sanitize.js';
|
|
17
|
+
import { logger } from '../../core/logger.js';
|
|
18
|
+
|
|
19
|
+
// CSRF token storage (in production, use Redis)
|
|
20
|
+
const csrfTokens = new Map<string, { token: string; expires: number }>();
|
|
21
|
+
const CSRF_TOKEN_TTL = 3600000; // 1 hour
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Input Sanitization Middleware
|
|
25
|
+
* Sanitizes request body, query, and params
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeInput(
|
|
28
|
+
options: SanitizeMiddlewareOptions = {}
|
|
29
|
+
): (request: FastifyRequest, _reply: FastifyReply) => Promise<void> {
|
|
30
|
+
return async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
|
31
|
+
const { skipFields = [], onlyFields = [] } = options;
|
|
32
|
+
|
|
33
|
+
// Sanitize body
|
|
34
|
+
if (request.body && typeof request.body === 'object') {
|
|
35
|
+
const body = request.body as Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
// Check for dangerous content and log warning
|
|
38
|
+
const bodyStr = JSON.stringify(body);
|
|
39
|
+
if (containsDangerousContent(bodyStr)) {
|
|
40
|
+
logger.warn(
|
|
41
|
+
{
|
|
42
|
+
ip: request.ip,
|
|
43
|
+
path: request.url,
|
|
44
|
+
method: request.method,
|
|
45
|
+
},
|
|
46
|
+
'Potentially dangerous content detected in request body'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Apply field filters
|
|
51
|
+
if (onlyFields.length > 0) {
|
|
52
|
+
for (const field of onlyFields) {
|
|
53
|
+
if (body[field] && typeof body[field] === 'string') {
|
|
54
|
+
body[field] = sanitizeObject({ [field]: body[field] }, options)[field];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
const filtered = { ...body };
|
|
59
|
+
for (const field of skipFields) {
|
|
60
|
+
delete filtered[field];
|
|
61
|
+
}
|
|
62
|
+
const sanitized = sanitizeObject(filtered, options);
|
|
63
|
+
for (const field of skipFields) {
|
|
64
|
+
sanitized[field] = body[field];
|
|
65
|
+
}
|
|
66
|
+
request.body = sanitized;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Sanitize query params
|
|
71
|
+
if (request.query && typeof request.query === 'object') {
|
|
72
|
+
request.query = sanitizeObject(request.query as Record<string, unknown>, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Sanitize URL params
|
|
76
|
+
if (request.params && typeof request.params === 'object') {
|
|
77
|
+
request.params = sanitizeObject(request.params as Record<string, unknown>, options);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* CSRF Protection Middleware
|
|
84
|
+
* Validates CSRF tokens for state-changing requests
|
|
85
|
+
*/
|
|
86
|
+
export function csrfProtection(): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
|
|
87
|
+
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
88
|
+
// Skip for safe methods
|
|
89
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Skip for API requests with valid JWT (they have their own protection)
|
|
94
|
+
const authHeader = request.headers.authorization;
|
|
95
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get CSRF token from header or body
|
|
100
|
+
const tokenFromHeader = request.headers['x-csrf-token'] as string;
|
|
101
|
+
const tokenFromBody = (request.body as Record<string, unknown>)?._csrf as string;
|
|
102
|
+
const token = tokenFromHeader || tokenFromBody;
|
|
103
|
+
|
|
104
|
+
// Get session ID (from cookie or header)
|
|
105
|
+
const sessionId = (request.headers['x-session-id'] as string) || request.ip;
|
|
106
|
+
|
|
107
|
+
if (!token) {
|
|
108
|
+
logger.warn({ ip: request.ip, path: request.url }, 'CSRF token missing');
|
|
109
|
+
return reply.status(403).send({
|
|
110
|
+
success: false,
|
|
111
|
+
message: 'CSRF token missing',
|
|
112
|
+
code: 'CSRF_TOKEN_MISSING',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Validate token
|
|
117
|
+
const storedToken = csrfTokens.get(sessionId);
|
|
118
|
+
if (!storedToken || storedToken.token !== token || storedToken.expires < Date.now()) {
|
|
119
|
+
logger.warn({ ip: request.ip, path: request.url }, 'Invalid CSRF token');
|
|
120
|
+
return reply.status(403).send({
|
|
121
|
+
success: false,
|
|
122
|
+
message: 'Invalid CSRF token',
|
|
123
|
+
code: 'CSRF_TOKEN_INVALID',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Token is valid, generate a new one for next request (token rotation)
|
|
128
|
+
const newToken = generateCsrfToken(sessionId);
|
|
129
|
+
reply.header('X-CSRF-Token', newToken);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate a new CSRF token
|
|
135
|
+
*/
|
|
136
|
+
export function generateCsrfToken(sessionId: string): string {
|
|
137
|
+
const token = randomBytes(32).toString('hex');
|
|
138
|
+
csrfTokens.set(sessionId, {
|
|
139
|
+
token,
|
|
140
|
+
expires: Date.now() + CSRF_TOKEN_TTL,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Cleanup expired tokens periodically
|
|
144
|
+
if (csrfTokens.size > 10000) {
|
|
145
|
+
cleanupExpiredCsrfTokens();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return token;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Cleanup expired CSRF tokens
|
|
153
|
+
*/
|
|
154
|
+
function cleanupExpiredCsrfTokens(): void {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
for (const [key, value] of csrfTokens.entries()) {
|
|
157
|
+
if (value.expires < now) {
|
|
158
|
+
csrfTokens.delete(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* HTTP Parameter Pollution Protection
|
|
165
|
+
* Ensures query parameters are not arrays when they shouldn't be
|
|
166
|
+
*/
|
|
167
|
+
export function hppProtection(
|
|
168
|
+
allowedArrayParams: string[] = []
|
|
169
|
+
): (request: FastifyRequest, _reply: FastifyReply) => Promise<void> {
|
|
170
|
+
return async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
|
171
|
+
if (request.query && typeof request.query === 'object') {
|
|
172
|
+
const query = request.query as Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
for (const [key, value] of Object.entries(query)) {
|
|
175
|
+
if (Array.isArray(value) && !allowedArrayParams.includes(key)) {
|
|
176
|
+
// Take the last value (most common behavior)
|
|
177
|
+
query[key] = value[value.length - 1];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Security Headers Middleware
|
|
186
|
+
* Adds additional security headers beyond Helmet
|
|
187
|
+
*/
|
|
188
|
+
export function securityHeaders(): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
|
|
189
|
+
return async (_request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
190
|
+
// Prevent browsers from MIME-sniffing
|
|
191
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
192
|
+
|
|
193
|
+
// Prevent clickjacking
|
|
194
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
195
|
+
|
|
196
|
+
// Enable XSS filter in browsers
|
|
197
|
+
reply.header('X-XSS-Protection', '1; mode=block');
|
|
198
|
+
|
|
199
|
+
// Control referrer information
|
|
200
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
201
|
+
|
|
202
|
+
// Permissions Policy (formerly Feature-Policy)
|
|
203
|
+
reply.header(
|
|
204
|
+
'Permissions-Policy',
|
|
205
|
+
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Prevent caching of sensitive data
|
|
209
|
+
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
210
|
+
reply.header('Pragma', 'no-cache');
|
|
211
|
+
reply.header('Expires', '0');
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Request Size Limit Middleware
|
|
217
|
+
* Additional protection against large payload attacks
|
|
218
|
+
*/
|
|
219
|
+
export function requestSizeLimit(
|
|
220
|
+
maxSizeBytes: number = 10 * 1024 * 1024
|
|
221
|
+
): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
|
|
222
|
+
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
223
|
+
const contentLength = parseInt(request.headers['content-length'] || '0', 10);
|
|
224
|
+
|
|
225
|
+
if (contentLength > maxSizeBytes) {
|
|
226
|
+
logger.warn(
|
|
227
|
+
{
|
|
228
|
+
ip: request.ip,
|
|
229
|
+
contentLength,
|
|
230
|
+
maxSize: maxSizeBytes,
|
|
231
|
+
},
|
|
232
|
+
'Request payload too large'
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return reply.status(413).send({
|
|
236
|
+
success: false,
|
|
237
|
+
message: 'Payload too large',
|
|
238
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Suspicious Activity Detection
|
|
246
|
+
* Logs and optionally blocks suspicious patterns
|
|
247
|
+
*/
|
|
248
|
+
export function suspiciousActivityDetection(
|
|
249
|
+
options: { blockSuspicious?: boolean } = {}
|
|
250
|
+
): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
|
|
251
|
+
const { blockSuspicious = false } = options;
|
|
252
|
+
|
|
253
|
+
// Suspicious patterns
|
|
254
|
+
const suspiciousPatterns = [
|
|
255
|
+
/\.\.\//g, // Path traversal
|
|
256
|
+
/<script/gi, // Script injection
|
|
257
|
+
/union\s+select/gi, // SQL injection
|
|
258
|
+
/\$\{.*\}/g, // Template injection
|
|
259
|
+
/{{.*}}/g, // Template injection
|
|
260
|
+
/\bexec\s*\(/gi, // Code execution
|
|
261
|
+
/\beval\s*\(/gi, // Code execution
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
265
|
+
const requestString = JSON.stringify({
|
|
266
|
+
url: request.url,
|
|
267
|
+
query: request.query,
|
|
268
|
+
body: request.body,
|
|
269
|
+
params: request.params,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
for (const pattern of suspiciousPatterns) {
|
|
273
|
+
if (pattern.test(requestString)) {
|
|
274
|
+
logger.warn(
|
|
275
|
+
{
|
|
276
|
+
ip: request.ip,
|
|
277
|
+
path: request.url,
|
|
278
|
+
method: request.method,
|
|
279
|
+
pattern: pattern.source,
|
|
280
|
+
},
|
|
281
|
+
'Suspicious activity detected'
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
if (blockSuspicious) {
|
|
285
|
+
return reply.status(400).send({
|
|
286
|
+
success: false,
|
|
287
|
+
message: 'Request blocked due to suspicious content',
|
|
288
|
+
code: 'SUSPICIOUS_REQUEST',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Register all security middlewares
|
|
300
|
+
*/
|
|
301
|
+
export async function registerSecurityMiddlewares(
|
|
302
|
+
app: FastifyInstance,
|
|
303
|
+
options: {
|
|
304
|
+
sanitize?: boolean | SanitizeMiddlewareOptions;
|
|
305
|
+
csrf?: boolean;
|
|
306
|
+
hpp?: boolean | string[];
|
|
307
|
+
headers?: boolean;
|
|
308
|
+
sizeLimit?: boolean | number;
|
|
309
|
+
suspicionDetection?: boolean | { blockSuspicious: boolean };
|
|
310
|
+
} = {}
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
const {
|
|
313
|
+
sanitize = true,
|
|
314
|
+
csrf = false, // Disabled by default for API-first apps
|
|
315
|
+
hpp = true,
|
|
316
|
+
headers = true,
|
|
317
|
+
sizeLimit = true,
|
|
318
|
+
suspicionDetection = true,
|
|
319
|
+
} = options;
|
|
320
|
+
|
|
321
|
+
// Security headers (run first)
|
|
322
|
+
if (headers) {
|
|
323
|
+
app.addHook(
|
|
324
|
+
'onRequest',
|
|
325
|
+
securityHeaders() as (
|
|
326
|
+
request: FastifyRequest,
|
|
327
|
+
reply: FastifyReply,
|
|
328
|
+
done: HookHandlerDoneFunction
|
|
329
|
+
) => void
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Request size limit
|
|
334
|
+
if (sizeLimit) {
|
|
335
|
+
const maxSize = typeof sizeLimit === 'number' ? sizeLimit : 10 * 1024 * 1024;
|
|
336
|
+
app.addHook(
|
|
337
|
+
'preHandler',
|
|
338
|
+
requestSizeLimit(maxSize) as (
|
|
339
|
+
request: FastifyRequest,
|
|
340
|
+
reply: FastifyReply,
|
|
341
|
+
done: HookHandlerDoneFunction
|
|
342
|
+
) => void
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// HPP protection
|
|
347
|
+
if (hpp) {
|
|
348
|
+
const allowedArrays = Array.isArray(hpp) ? hpp : [];
|
|
349
|
+
app.addHook(
|
|
350
|
+
'preHandler',
|
|
351
|
+
hppProtection(allowedArrays) as (
|
|
352
|
+
request: FastifyRequest,
|
|
353
|
+
reply: FastifyReply,
|
|
354
|
+
done: HookHandlerDoneFunction
|
|
355
|
+
) => void
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Input sanitization
|
|
360
|
+
if (sanitize) {
|
|
361
|
+
const sanitizeOpts = typeof sanitize === 'object' ? sanitize : {};
|
|
362
|
+
app.addHook(
|
|
363
|
+
'preHandler',
|
|
364
|
+
sanitizeInput(sanitizeOpts) as (
|
|
365
|
+
request: FastifyRequest,
|
|
366
|
+
reply: FastifyReply,
|
|
367
|
+
done: HookHandlerDoneFunction
|
|
368
|
+
) => void
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Suspicious activity detection
|
|
373
|
+
if (suspicionDetection) {
|
|
374
|
+
const detectionOpts = typeof suspicionDetection === 'object' ? suspicionDetection : {};
|
|
375
|
+
app.addHook(
|
|
376
|
+
'preHandler',
|
|
377
|
+
suspiciousActivityDetection(detectionOpts) as (
|
|
378
|
+
request: FastifyRequest,
|
|
379
|
+
reply: FastifyReply,
|
|
380
|
+
done: HookHandlerDoneFunction
|
|
381
|
+
) => void
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// CSRF protection (only for web apps, not APIs)
|
|
386
|
+
if (csrf) {
|
|
387
|
+
app.addHook(
|
|
388
|
+
'preHandler',
|
|
389
|
+
csrfProtection() as (
|
|
390
|
+
request: FastifyRequest,
|
|
391
|
+
reply: FastifyReply,
|
|
392
|
+
done: HookHandlerDoneFunction
|
|
393
|
+
) => void
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
logger.info('Security middlewares registered');
|
|
398
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Repository
|
|
3
|
+
* Prisma-based persistence for sessions (optional backup storage)
|
|
4
|
+
*/
|
|
5
|
+
import type { PrismaClient, Session as PrismaSession } from '@prisma/client';
|
|
6
|
+
import type { Session, CreateSessionData } from './types.js';
|
|
7
|
+
|
|
8
|
+
export class SessionRepository {
|
|
9
|
+
constructor(private prisma: PrismaClient) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a new session in database
|
|
13
|
+
*/
|
|
14
|
+
async create(data: CreateSessionData & { id: string; expiresAt: Date }): Promise<Session> {
|
|
15
|
+
const session = await this.prisma.session.create({
|
|
16
|
+
data: {
|
|
17
|
+
id: data.id,
|
|
18
|
+
userId: data.userId,
|
|
19
|
+
userAgent: data.userAgent,
|
|
20
|
+
ipAddress: data.ipAddress,
|
|
21
|
+
expiresAt: data.expiresAt,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return this.mapFromPrisma(session);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find session by ID
|
|
30
|
+
*/
|
|
31
|
+
async findById(id: string): Promise<Session | null> {
|
|
32
|
+
const session = await this.prisma.session.findUnique({
|
|
33
|
+
where: { id },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!session) return null;
|
|
37
|
+
|
|
38
|
+
// Check if expired
|
|
39
|
+
if (session.expiresAt < new Date()) {
|
|
40
|
+
await this.delete(id);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return this.mapFromPrisma(session);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Find all sessions for a user
|
|
49
|
+
*/
|
|
50
|
+
async findByUserId(userId: string): Promise<Session[]> {
|
|
51
|
+
const sessions = await this.prisma.session.findMany({
|
|
52
|
+
where: {
|
|
53
|
+
userId,
|
|
54
|
+
expiresAt: { gt: new Date() },
|
|
55
|
+
},
|
|
56
|
+
orderBy: { createdAt: 'desc' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return sessions.map((s) => this.mapFromPrisma(s));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Update session expiration
|
|
64
|
+
*/
|
|
65
|
+
async updateExpiration(id: string, expiresAt: Date): Promise<Session | null> {
|
|
66
|
+
try {
|
|
67
|
+
const session = await this.prisma.session.update({
|
|
68
|
+
where: { id },
|
|
69
|
+
data: { expiresAt },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return this.mapFromPrisma(session);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Delete session by ID
|
|
80
|
+
*/
|
|
81
|
+
async delete(id: string): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
await this.prisma.session.delete({
|
|
84
|
+
where: { id },
|
|
85
|
+
});
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete all sessions for a user
|
|
94
|
+
*/
|
|
95
|
+
async deleteByUserId(userId: string): Promise<number> {
|
|
96
|
+
const result = await this.prisma.session.deleteMany({
|
|
97
|
+
where: { userId },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return result.count;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Delete expired sessions
|
|
105
|
+
*/
|
|
106
|
+
async deleteExpired(): Promise<number> {
|
|
107
|
+
const result = await this.prisma.session.deleteMany({
|
|
108
|
+
where: {
|
|
109
|
+
expiresAt: { lt: new Date() },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return result.count;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Count active sessions
|
|
118
|
+
*/
|
|
119
|
+
async countActive(): Promise<number> {
|
|
120
|
+
return this.prisma.session.count({
|
|
121
|
+
where: {
|
|
122
|
+
expiresAt: { gt: new Date() },
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Count sessions per user
|
|
129
|
+
*/
|
|
130
|
+
async countByUser(userId: string): Promise<number> {
|
|
131
|
+
return this.prisma.session.count({
|
|
132
|
+
where: {
|
|
133
|
+
userId,
|
|
134
|
+
expiresAt: { gt: new Date() },
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clear all sessions (for testing)
|
|
141
|
+
*/
|
|
142
|
+
async clear(): Promise<void> {
|
|
143
|
+
await this.prisma.session.deleteMany();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Map Prisma model to domain type
|
|
148
|
+
*/
|
|
149
|
+
private mapFromPrisma(session: PrismaSession): Session {
|
|
150
|
+
return {
|
|
151
|
+
id: session.id,
|
|
152
|
+
userId: session.userId,
|
|
153
|
+
userAgent: session.userAgent ?? undefined,
|
|
154
|
+
ipAddress: session.ipAddress ?? undefined,
|
|
155
|
+
expiresAt: session.expiresAt,
|
|
156
|
+
createdAt: session.createdAt,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|