thyscript 1.0.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/bin/thy-node.js +332 -0
- package/bin/thy.js +828 -0
- package/bin/thys.js +25 -0
- package/examples/calculator.thy +274 -0
- package/examples/calculator.thy.js +628 -0
- package/examples/hello.thy +34 -0
- package/examples/login.thy +382 -0
- package/examples/login.thy.js +710 -0
- package/package.json +54 -0
- package/scripts/_class.js +18 -0
- package/scripts/_class2.js +11 -0
- package/scripts/_class3.js +21 -0
- package/scripts/_dstruct.js +20 -0
- package/scripts/_dstruct2.js +24 -0
- package/scripts/_scaffold.js +16 -0
- package/scripts/_scaffold2.js +16 -0
- package/scripts/_scaffold3.js +26 -0
- package/scripts/_tpl_compile.js +13 -0
- package/scripts/_tpl_test.js +9 -0
- package/scripts/_typeof.js +26 -0
- package/scripts/_typeof2.js +25 -0
- package/scripts/patch_genmethod.js +44 -0
- package/scripts/smoke_test.js +167 -0
- package/scripts/smoke_test2.js +151 -0
- package/scripts/test_control_panel.js +58 -0
- package/src/compiler/CodeGenerator.js +1449 -0
- package/src/compiler/JITCompiler.js +325 -0
- package/src/database/DatabaseEngine.js +931 -0
- package/src/errors/ErrorDetector.js +305 -0
- package/src/errors/ErrorMessages.js +497 -0
- package/src/index.js +169 -0
- package/src/lexer/Lexer.js +342 -0
- package/src/lexer/TokenTypes.js +1234 -0
- package/src/memory/MemoryManager.js +283 -0
- package/src/parser/AST.js +606 -0
- package/src/parser/Parser.js +1942 -0
- package/src/runtime/ThyRuntime.js +265 -0
- package/src/runtime/stdlib/ArrayLib.js +207 -0
- package/src/runtime/stdlib/FileLib.js +168 -0
- package/src/runtime/stdlib/MathLib.js +411 -0
- package/src/runtime/stdlib/StringLib.js +387 -0
- package/src/server/ControlPanel.js +1188 -0
- package/src/server/HTMLCSSEngine.js +866 -0
- package/src/server/SecurityEngine.js +714 -0
- package/src/server/ServerEngine.js +738 -0
- package/tests/test1_math.thy +82 -0
- package/tests/test2_oop.thy +133 -0
- package/tests/test3_server.thy +143 -0
- package/tests/test4_functions.thy +159 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// Optional dependencies with graceful fallback implementations using native crypto
|
|
6
|
+
let bcrypt, jwt;
|
|
7
|
+
try {
|
|
8
|
+
bcrypt = require('bcrypt');
|
|
9
|
+
} catch (e) {
|
|
10
|
+
bcrypt = {
|
|
11
|
+
hash: async (password, saltRounds = 10) => {
|
|
12
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
13
|
+
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
14
|
+
return `pbkdf2$${salt}$${hash}`;
|
|
15
|
+
},
|
|
16
|
+
compare: async (password, storedHash) => {
|
|
17
|
+
if (!storedHash) return false;
|
|
18
|
+
if (storedHash.startsWith('pbkdf2$')) {
|
|
19
|
+
const parts = storedHash.split('$');
|
|
20
|
+
const salt = parts[1];
|
|
21
|
+
const hash = parts[2];
|
|
22
|
+
const attempt = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
23
|
+
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(attempt));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
jwt = require('jsonwebtoken');
|
|
31
|
+
} catch (e) {
|
|
32
|
+
jwt = {
|
|
33
|
+
sign: (payload, secret, options = {}) => {
|
|
34
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
35
|
+
const body = Buffer.from(JSON.stringify({ ...payload, exp: options.expiresIn ? Math.floor(Date.now() / 1000) + 86400 : undefined })).toString('base64url');
|
|
36
|
+
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
|
37
|
+
return `${header}.${body}.${sig}`;
|
|
38
|
+
},
|
|
39
|
+
verify: (token, secret) => {
|
|
40
|
+
const parts = token.split('.');
|
|
41
|
+
if (parts.length !== 3) throw new Error('Invalid token structure');
|
|
42
|
+
const expectedSig = crypto.createHmac('sha256', secret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
|
|
43
|
+
if (parts[2] !== expectedSig) throw new Error('Invalid signature');
|
|
44
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
45
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
|
|
46
|
+
return payload;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ThyScript SecurityEngine
|
|
53
|
+
* Comprehensive security utilities for ThyScript servers.
|
|
54
|
+
*/
|
|
55
|
+
class SecurityEngine {
|
|
56
|
+
constructor() {
|
|
57
|
+
// CSRF token store: token -> { createdAt, used }
|
|
58
|
+
this._csrfStore = new Map();
|
|
59
|
+
this._csrfTTL = 3600000; // 1 hour
|
|
60
|
+
|
|
61
|
+
// Rate limit store: key -> { count, resetAt }
|
|
62
|
+
this._rateLimitStore = new Map();
|
|
63
|
+
|
|
64
|
+
// Blocked IPs
|
|
65
|
+
this._blockedIPs = new Set();
|
|
66
|
+
|
|
67
|
+
// Honeypot log
|
|
68
|
+
this._honeypotLog = [];
|
|
69
|
+
|
|
70
|
+
// XSS patterns
|
|
71
|
+
this._xssPatterns = [
|
|
72
|
+
{ pattern: /<script[\s\S]*?>[\s\S]*?<\/script>/gi, name: 'script-tag' },
|
|
73
|
+
{ pattern: /javascript\s*:/gi, name: 'js-protocol' },
|
|
74
|
+
{ pattern: /on\w+\s*=\s*["'][^"']*["']/gi, name: 'inline-event' },
|
|
75
|
+
{ pattern: /<iframe[\s\S]*?>/gi, name: 'iframe' },
|
|
76
|
+
{ pattern: /<object[\s\S]*?>/gi, name: 'object' },
|
|
77
|
+
{ pattern: /<embed[\s\S]*?>/gi, name: 'embed' },
|
|
78
|
+
{ pattern: /eval\s*\(/gi, name: 'eval' },
|
|
79
|
+
{ pattern: /expression\s*\(/gi, name: 'css-expression' },
|
|
80
|
+
{ pattern: /vbscript\s*:/gi, name: 'vbscript' },
|
|
81
|
+
{ pattern: /<link[\s\S]*?>/gi, name: 'link-tag' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// SQL injection patterns
|
|
85
|
+
this._sqlPatterns = [
|
|
86
|
+
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|TRUNCATE|CAST|CONVERT|DECLARE|FETCH|CURSOR|KILL|GRANT|REVOKE)\b)/gi,
|
|
87
|
+
/('|")\s*;\s*--/gi,
|
|
88
|
+
/\/\*[\s\S]*?\*\//g,
|
|
89
|
+
/\bOR\b\s+\d+\s*=\s*\d+/gi,
|
|
90
|
+
/\bAND\b\s+\d+\s*=\s*\d+/gi,
|
|
91
|
+
/\b(OR|AND)\b\s+['"]?\w+['"]?\s*=\s*['"]?\w+['"]?/gi,
|
|
92
|
+
/;\s*(DROP|DELETE|INSERT|UPDATE|CREATE)\s/gi,
|
|
93
|
+
/'\s*--/g,
|
|
94
|
+
/xp_cmdshell/gi,
|
|
95
|
+
/information_schema/gi,
|
|
96
|
+
/sys\.(tables|columns|objects)/gi,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Code injection patterns
|
|
100
|
+
this._codeInjectionPatterns = [
|
|
101
|
+
/\$\{[\s\S]*?\}/g, // Template literals
|
|
102
|
+
/`[\s\S]*?`/g, // Backtick strings
|
|
103
|
+
/eval\s*\(/gi,
|
|
104
|
+
/Function\s*\(/gi,
|
|
105
|
+
/new\s+Function/gi,
|
|
106
|
+
/setTimeout\s*\(/gi,
|
|
107
|
+
/setInterval\s*\(/gi,
|
|
108
|
+
/require\s*\(/gi,
|
|
109
|
+
/child_process/gi,
|
|
110
|
+
/process\.exit/gi,
|
|
111
|
+
/os\.system/gi,
|
|
112
|
+
/__proto__/gi,
|
|
113
|
+
/constructor\s*\[/gi,
|
|
114
|
+
/prototype\s*\[/gi,
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────
|
|
119
|
+
// Password Hashing
|
|
120
|
+
// ─────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Hashes a password using bcrypt.
|
|
124
|
+
* @param {string} password
|
|
125
|
+
* @param {number} [saltRounds=12]
|
|
126
|
+
* @returns {Promise<string>}
|
|
127
|
+
*/
|
|
128
|
+
async hashPassword(password, saltRounds = 12) {
|
|
129
|
+
if (!password || typeof password !== 'string') {
|
|
130
|
+
throw new Error('Password must be a non-empty string');
|
|
131
|
+
}
|
|
132
|
+
return bcrypt.hash(password, saltRounds);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Verifies a password against a bcrypt hash.
|
|
137
|
+
* @param {string} password
|
|
138
|
+
* @param {string} hash
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
async verifyPassword(password, hash) {
|
|
142
|
+
if (!password || !hash) return false;
|
|
143
|
+
return bcrypt.compare(password, hash);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─────────────────────────────────────────────
|
|
147
|
+
// JWT
|
|
148
|
+
// ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generates a signed JWT.
|
|
152
|
+
* @param {object} payload
|
|
153
|
+
* @param {string} secret
|
|
154
|
+
* @param {object} [options] - jsonwebtoken sign options (e.g. { expiresIn: '1h' })
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
generateToken(payload, secret, options = { expiresIn: '24h' }) {
|
|
158
|
+
if (!payload || !secret) throw new Error('payload and secret are required');
|
|
159
|
+
return jwt.sign(payload, secret, options);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Verifies and decodes a JWT.
|
|
164
|
+
* @param {string} token
|
|
165
|
+
* @param {string} secret
|
|
166
|
+
* @returns {{ valid: boolean, payload: object|null, error: string|null }}
|
|
167
|
+
*/
|
|
168
|
+
verifyToken(token, secret) {
|
|
169
|
+
try {
|
|
170
|
+
const payload = jwt.verify(token, secret);
|
|
171
|
+
return { valid: true, payload, error: null };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return { valid: false, payload: null, error: err.message };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─────────────────────────────────────────────
|
|
178
|
+
// AES-256-GCM Encryption
|
|
179
|
+
// ─────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Encrypts data with AES-256-GCM.
|
|
183
|
+
* @param {string|Buffer} data
|
|
184
|
+
* @param {string|Buffer} key - 32-byte key (hex string or Buffer)
|
|
185
|
+
* @returns {{ iv: string, tag: string, ciphertext: string }}
|
|
186
|
+
*/
|
|
187
|
+
encrypt(data, key) {
|
|
188
|
+
const keyBuf = this._normalizeKey(key);
|
|
189
|
+
const iv = crypto.randomBytes(16);
|
|
190
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuf, iv);
|
|
191
|
+
const dataStr = typeof data === 'object' ? JSON.stringify(data) : String(data);
|
|
192
|
+
const encrypted = Buffer.concat([cipher.update(dataStr, 'utf8'), cipher.final()]);
|
|
193
|
+
const tag = cipher.getAuthTag();
|
|
194
|
+
return {
|
|
195
|
+
iv: iv.toString('hex'),
|
|
196
|
+
tag: tag.toString('hex'),
|
|
197
|
+
ciphertext: encrypted.toString('hex'),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decrypts AES-256-GCM encrypted data.
|
|
203
|
+
* @param {{ iv: string, tag: string, ciphertext: string }} encrypted
|
|
204
|
+
* @param {string|Buffer} key
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
decrypt(encrypted, key) {
|
|
208
|
+
const keyBuf = this._normalizeKey(key);
|
|
209
|
+
const iv = Buffer.from(encrypted.iv, 'hex');
|
|
210
|
+
const tag = Buffer.from(encrypted.tag, 'hex');
|
|
211
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, 'hex');
|
|
212
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuf, iv);
|
|
213
|
+
decipher.setAuthTag(tag);
|
|
214
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
215
|
+
return decrypted.toString('utf8');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_normalizeKey(key) {
|
|
219
|
+
if (Buffer.isBuffer(key)) {
|
|
220
|
+
if (key.length !== 32) throw new Error('AES-256-GCM requires a 32-byte key');
|
|
221
|
+
return key;
|
|
222
|
+
}
|
|
223
|
+
if (typeof key === 'string') {
|
|
224
|
+
// Treat as hex string if 64 chars, otherwise derive via SHA-256
|
|
225
|
+
if (key.length === 64) return Buffer.from(key, 'hex');
|
|
226
|
+
return crypto.createHash('sha256').update(key).digest();
|
|
227
|
+
}
|
|
228
|
+
throw new Error('Key must be a Buffer or string');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─────────────────────────────────────────────
|
|
232
|
+
// Hashing
|
|
233
|
+
// ─────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Returns SHA-256 hash of data as hex string.
|
|
237
|
+
* @param {string|Buffer} data
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
hashSHA256(data) {
|
|
241
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns MD5 hash of data as hex string.
|
|
246
|
+
* @param {string|Buffer} data
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
hashMD5(data) {
|
|
250
|
+
return crypto.createHash('md5').update(data).digest('hex');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Returns HMAC-SHA256 signature.
|
|
255
|
+
* @param {string} data
|
|
256
|
+
* @param {string} key
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
hmacSHA256(data, key) {
|
|
260
|
+
return crypto.createHmac('sha256', key).update(data).digest('hex');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─────────────────────────────────────────────
|
|
264
|
+
// Key & UUID Generation
|
|
265
|
+
// ─────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Generates a cryptographically secure random key.
|
|
269
|
+
* @param {number} [length=32] - Length in bytes.
|
|
270
|
+
* @param {'hex'|'base64'|'buffer'} [encoding='hex']
|
|
271
|
+
* @returns {string|Buffer}
|
|
272
|
+
*/
|
|
273
|
+
generateKey(length = 32, encoding = 'hex') {
|
|
274
|
+
const buf = crypto.randomBytes(length);
|
|
275
|
+
if (encoding === 'buffer') return buf;
|
|
276
|
+
return buf.toString(encoding);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Generates a UUID v4.
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
generateUUID() {
|
|
284
|
+
return crypto.randomUUID
|
|
285
|
+
? crypto.randomUUID()
|
|
286
|
+
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
287
|
+
const r = (crypto.randomBytes(1)[0] & 0xff) % 16;
|
|
288
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
289
|
+
return v.toString(16);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─────────────────────────────────────────────
|
|
294
|
+
// Input Sanitization & Detection
|
|
295
|
+
// ─────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Sanitizes a string to prevent XSS.
|
|
299
|
+
* @param {string} input
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
sanitizeInput(input) {
|
|
303
|
+
if (typeof input !== 'string') return input;
|
|
304
|
+
let result = input;
|
|
305
|
+
// Remove dangerous patterns
|
|
306
|
+
for (const { pattern } of this._xssPatterns) {
|
|
307
|
+
result = result.replace(pattern, '');
|
|
308
|
+
}
|
|
309
|
+
// HTML entity encode
|
|
310
|
+
result = result
|
|
311
|
+
.replace(/&/g, '&')
|
|
312
|
+
.replace(/</g, '<')
|
|
313
|
+
.replace(/>/g, '>')
|
|
314
|
+
.replace(/"/g, '"')
|
|
315
|
+
.replace(/'/g, ''')
|
|
316
|
+
.replace(/\//g, '/');
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Detects SQL injection patterns in input.
|
|
322
|
+
* @param {string} input
|
|
323
|
+
* @returns {{ detected: boolean, patterns: string[] }}
|
|
324
|
+
*/
|
|
325
|
+
detectSQLInjection(input) {
|
|
326
|
+
if (typeof input !== 'string') return { detected: false, patterns: [] };
|
|
327
|
+
const found = [];
|
|
328
|
+
for (const pattern of this._sqlPatterns) {
|
|
329
|
+
pattern.lastIndex = 0;
|
|
330
|
+
if (pattern.test(input)) found.push(pattern.toString());
|
|
331
|
+
}
|
|
332
|
+
return { detected: found.length > 0, patterns: found };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Detects code injection patterns in input.
|
|
337
|
+
* @param {string} input
|
|
338
|
+
* @returns {{ detected: boolean, patterns: string[] }}
|
|
339
|
+
*/
|
|
340
|
+
detectCodeInjection(input) {
|
|
341
|
+
if (typeof input !== 'string') return { detected: false, patterns: [] };
|
|
342
|
+
const found = [];
|
|
343
|
+
for (const pattern of this._codeInjectionPatterns) {
|
|
344
|
+
pattern.lastIndex = 0;
|
|
345
|
+
if (pattern.test(input)) found.push(pattern.toString());
|
|
346
|
+
}
|
|
347
|
+
return { detected: found.length > 0, patterns: found };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─────────────────────────────────────────────
|
|
351
|
+
// Validation
|
|
352
|
+
// ─────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Validates an email address.
|
|
356
|
+
* @param {string} email
|
|
357
|
+
* @returns {boolean}
|
|
358
|
+
*/
|
|
359
|
+
validateEmail(email) {
|
|
360
|
+
if (typeof email !== 'string') return false;
|
|
361
|
+
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
|
|
362
|
+
return emailRegex.test(email.trim()) && email.length <= 254;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Validates a URL.
|
|
367
|
+
* @param {string} url
|
|
368
|
+
* @param {string[]} [allowedProtocols=['http:', 'https:']]
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
validateURL(url, allowedProtocols = ['http:', 'https:']) {
|
|
372
|
+
if (typeof url !== 'string') return false;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = new URL(url);
|
|
375
|
+
return allowedProtocols.includes(parsed.protocol);
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─────────────────────────────────────────────
|
|
382
|
+
// Rate Limiting
|
|
383
|
+
// ─────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Checks and enforces rate limiting.
|
|
387
|
+
* @param {string} key - Identifier (e.g. IP, user ID).
|
|
388
|
+
* @param {number} maxRequests - Max requests per window.
|
|
389
|
+
* @param {number} windowMs - Window in milliseconds.
|
|
390
|
+
* @returns {{ allowed: boolean, remaining: number, resetAt: number }}
|
|
391
|
+
*/
|
|
392
|
+
rateLimit(key, maxRequests, windowMs) {
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
const entry = this._rateLimitStore.get(key);
|
|
395
|
+
|
|
396
|
+
if (!entry || now > entry.resetAt) {
|
|
397
|
+
const newEntry = { count: 1, resetAt: now + windowMs };
|
|
398
|
+
this._rateLimitStore.set(key, newEntry);
|
|
399
|
+
return { allowed: true, remaining: maxRequests - 1, resetAt: newEntry.resetAt };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (entry.count >= maxRequests) {
|
|
403
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
entry.count++;
|
|
407
|
+
return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Returns an Express middleware that enforces rate limiting.
|
|
412
|
+
* @param {number} maxRequests
|
|
413
|
+
* @param {number} windowMs
|
|
414
|
+
* @param {function} [keyFn] - function(req) -> key. Defaults to IP.
|
|
415
|
+
*/
|
|
416
|
+
rateLimitMiddleware(maxRequests, windowMs, keyFn) {
|
|
417
|
+
return (req, res, next) => {
|
|
418
|
+
const key = keyFn ? keyFn(req) : (req.ip || req.connection.remoteAddress);
|
|
419
|
+
const result = this.rateLimit(key, maxRequests, windowMs);
|
|
420
|
+
res.setHeader('X-RateLimit-Limit', maxRequests);
|
|
421
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
|
422
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
|
|
423
|
+
if (!result.allowed) {
|
|
424
|
+
res.status(429).json({
|
|
425
|
+
error: 'Too Many Requests',
|
|
426
|
+
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
next();
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─────────────────────────────────────────────
|
|
435
|
+
// CSRF
|
|
436
|
+
// ─────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Creates a CSRF token and stores it.
|
|
440
|
+
* @returns {string} CSRF token.
|
|
441
|
+
*/
|
|
442
|
+
createCSRFToken() {
|
|
443
|
+
const token = this.generateKey(32, 'hex');
|
|
444
|
+
this._csrfStore.set(token, { createdAt: Date.now(), used: false });
|
|
445
|
+
// Clean up expired tokens periodically
|
|
446
|
+
if (this._csrfStore.size % 100 === 0) this._cleanCSRFStore();
|
|
447
|
+
return token;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Verifies a CSRF token (single-use).
|
|
452
|
+
* @param {string} token
|
|
453
|
+
* @returns {boolean}
|
|
454
|
+
*/
|
|
455
|
+
verifyCSRFToken(token) {
|
|
456
|
+
const entry = this._csrfStore.get(token);
|
|
457
|
+
if (!entry) return false;
|
|
458
|
+
if (entry.used) return false;
|
|
459
|
+
if (Date.now() - entry.createdAt > this._csrfTTL) {
|
|
460
|
+
this._csrfStore.delete(token);
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
entry.used = true;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_cleanCSRFStore() {
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
for (const [token, entry] of this._csrfStore) {
|
|
470
|
+
if (entry.used || now - entry.createdAt > this._csrfTTL) {
|
|
471
|
+
this._csrfStore.delete(token);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Returns an Express middleware that validates CSRF tokens.
|
|
478
|
+
* Expects token in header 'X-CSRF-Token' or body field '_csrf'.
|
|
479
|
+
* @param {string[]} [skipMethods=['GET','HEAD','OPTIONS']]
|
|
480
|
+
*/
|
|
481
|
+
csrfMiddleware(skipMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
482
|
+
return (req, res, next) => {
|
|
483
|
+
if (skipMethods.includes(req.method.toUpperCase())) return next();
|
|
484
|
+
const token = req.headers['x-csrf-token'] || (req.body && req.body._csrf);
|
|
485
|
+
if (!token || !this.verifyCSRFToken(token)) {
|
|
486
|
+
res.status(403).json({ error: 'Forbidden', message: 'Invalid or missing CSRF token.' });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
next();
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─────────────────────────────────────────────
|
|
494
|
+
// IP Blocking
|
|
495
|
+
// ─────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Blocks an IP address.
|
|
499
|
+
* @param {string} ip
|
|
500
|
+
* @returns {SecurityEngine}
|
|
501
|
+
*/
|
|
502
|
+
blockIP(ip) {
|
|
503
|
+
this._blockedIPs.add(ip);
|
|
504
|
+
return this;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Unblocks an IP address.
|
|
509
|
+
* @param {string} ip
|
|
510
|
+
* @returns {SecurityEngine}
|
|
511
|
+
*/
|
|
512
|
+
unblockIP(ip) {
|
|
513
|
+
this._blockedIPs.delete(ip);
|
|
514
|
+
return this;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Checks if an IP is blocked.
|
|
519
|
+
* @param {string} ip
|
|
520
|
+
* @returns {boolean}
|
|
521
|
+
*/
|
|
522
|
+
isBlockedIP(ip) {
|
|
523
|
+
return this._blockedIPs.has(ip);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Returns an Express middleware that blocks listed IPs.
|
|
528
|
+
*/
|
|
529
|
+
blockIPMiddleware() {
|
|
530
|
+
return (req, res, next) => {
|
|
531
|
+
const ip = req.ip || req.connection.remoteAddress || '';
|
|
532
|
+
if (this.isBlockedIP(ip)) {
|
|
533
|
+
res.status(403).json({ error: 'Forbidden', message: 'Access denied.' });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
next();
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ─────────────────────────────────────────────
|
|
541
|
+
// Honeypot
|
|
542
|
+
// ─────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Returns Express middleware that redirects injection attempts to a fake handler.
|
|
546
|
+
* @param {function} fakeServerHandler - Express handler (req, res) for suspicious requests.
|
|
547
|
+
* @param {object} [options]
|
|
548
|
+
* @param {boolean} [options.detectSQL=true]
|
|
549
|
+
* @param {boolean} [options.detectCode=true]
|
|
550
|
+
* @param {boolean} [options.detectXSS=true]
|
|
551
|
+
* @returns {function}
|
|
552
|
+
*/
|
|
553
|
+
honeypot(fakeServerHandler, options = { detectSQL: true, detectCode: true, detectXSS: true }) {
|
|
554
|
+
return (req, res, next) => {
|
|
555
|
+
const combined = [
|
|
556
|
+
JSON.stringify(req.query),
|
|
557
|
+
JSON.stringify(req.body || {}),
|
|
558
|
+
req.url,
|
|
559
|
+
].join(' ');
|
|
560
|
+
|
|
561
|
+
let suspicious = false;
|
|
562
|
+
|
|
563
|
+
if (options.detectSQL !== false) {
|
|
564
|
+
const { detected } = this.detectSQLInjection(combined);
|
|
565
|
+
if (detected) suspicious = true;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!suspicious && options.detectCode !== false) {
|
|
569
|
+
const { detected } = this.detectCodeInjection(combined);
|
|
570
|
+
if (detected) suspicious = true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!suspicious && options.detectXSS !== false) {
|
|
574
|
+
const xssFound = this._xssPatterns.some(({ pattern }) => {
|
|
575
|
+
pattern.lastIndex = 0;
|
|
576
|
+
return pattern.test(combined);
|
|
577
|
+
});
|
|
578
|
+
if (xssFound) suspicious = true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (suspicious) {
|
|
582
|
+
const ip = req.ip || req.connection.remoteAddress;
|
|
583
|
+
const logEntry = {
|
|
584
|
+
timestamp: new Date().toISOString(),
|
|
585
|
+
ip,
|
|
586
|
+
method: req.method,
|
|
587
|
+
url: req.url,
|
|
588
|
+
query: req.query,
|
|
589
|
+
body: req.body,
|
|
590
|
+
};
|
|
591
|
+
this._honeypotLog.push(logEntry);
|
|
592
|
+
console.warn('[SecurityEngine] Honeypot triggered:', logEntry);
|
|
593
|
+
this.blockIP(ip);
|
|
594
|
+
if (typeof fakeServerHandler === 'function') {
|
|
595
|
+
return fakeServerHandler(req, res);
|
|
596
|
+
}
|
|
597
|
+
return res.status(200).send('OK'); // Fake success
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
next();
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Returns the honeypot activity log.
|
|
606
|
+
* @returns {object[]}
|
|
607
|
+
*/
|
|
608
|
+
getHoneypotLog() {
|
|
609
|
+
return [...this._honeypotLog];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ─────────────────────────────────────────────
|
|
613
|
+
// Content Security Policy
|
|
614
|
+
// ─────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Returns an Express middleware that sets CSP headers.
|
|
618
|
+
* @param {object} [options] - CSP directives.
|
|
619
|
+
* @returns {function}
|
|
620
|
+
*/
|
|
621
|
+
contentSecurityPolicy(options = {}) {
|
|
622
|
+
const directives = Object.assign({
|
|
623
|
+
'default-src': ["'self'"],
|
|
624
|
+
'script-src': ["'self'"],
|
|
625
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
626
|
+
'img-src': ["'self'", 'data:', 'https:'],
|
|
627
|
+
'font-src': ["'self'"],
|
|
628
|
+
'connect-src': ["'self'"],
|
|
629
|
+
'media-src': ["'self'"],
|
|
630
|
+
'object-src': ["'none'"],
|
|
631
|
+
'frame-ancestors': ["'none'"],
|
|
632
|
+
'base-uri': ["'self'"],
|
|
633
|
+
'form-action': ["'self'"],
|
|
634
|
+
}, options);
|
|
635
|
+
|
|
636
|
+
const cspString = Object.entries(directives)
|
|
637
|
+
.map(([key, val]) => `${key} ${Array.isArray(val) ? val.join(' ') : val}`)
|
|
638
|
+
.join('; ');
|
|
639
|
+
|
|
640
|
+
return (req, res, next) => {
|
|
641
|
+
res.setHeader('Content-Security-Policy', cspString);
|
|
642
|
+
next();
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─────────────────────────────────────────────
|
|
647
|
+
// Helmet-like Security Headers
|
|
648
|
+
// ─────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Returns an Express middleware that sets comprehensive security headers.
|
|
652
|
+
* @param {object} [options]
|
|
653
|
+
* @returns {function}
|
|
654
|
+
*/
|
|
655
|
+
helmet(options = {}) {
|
|
656
|
+
return (req, res, next) => {
|
|
657
|
+
// Prevent MIME sniffing
|
|
658
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
659
|
+
// Clickjacking protection
|
|
660
|
+
res.setHeader('X-Frame-Options', options.frameOptions || 'DENY');
|
|
661
|
+
// XSS filter (legacy browsers)
|
|
662
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
663
|
+
// HSTS
|
|
664
|
+
if (options.hsts !== false) {
|
|
665
|
+
res.setHeader(
|
|
666
|
+
'Strict-Transport-Security',
|
|
667
|
+
options.hstsHeader || 'max-age=31536000; includeSubDomains; preload'
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
// Referrer policy
|
|
671
|
+
res.setHeader('Referrer-Policy', options.referrerPolicy || 'strict-origin-when-cross-origin');
|
|
672
|
+
// Permissions policy
|
|
673
|
+
res.setHeader(
|
|
674
|
+
'Permissions-Policy',
|
|
675
|
+
options.permissionsPolicy || 'geolocation=(), microphone=(), camera=()'
|
|
676
|
+
);
|
|
677
|
+
// Remove X-Powered-By
|
|
678
|
+
res.removeHeader('X-Powered-By');
|
|
679
|
+
// DNS prefetch control
|
|
680
|
+
res.setHeader('X-DNS-Prefetch-Control', 'off');
|
|
681
|
+
// Expect-CT
|
|
682
|
+
if (options.expectCT) {
|
|
683
|
+
res.setHeader('Expect-CT', 'max-age=86400, enforce');
|
|
684
|
+
}
|
|
685
|
+
// Cache control for sensitive routes
|
|
686
|
+
if (options.noCache) {
|
|
687
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
688
|
+
res.setHeader('Pragma', 'no-cache');
|
|
689
|
+
res.setHeader('Expires', '0');
|
|
690
|
+
}
|
|
691
|
+
next();
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─────────────────────────────────────────────
|
|
696
|
+
// Constant-time comparison (prevent timing attacks)
|
|
697
|
+
// ─────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Compares two strings in constant time to prevent timing attacks.
|
|
701
|
+
* @param {string} a
|
|
702
|
+
* @param {string} b
|
|
703
|
+
* @returns {boolean}
|
|
704
|
+
*/
|
|
705
|
+
constantTimeEqual(a, b) {
|
|
706
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
707
|
+
const bufA = Buffer.from(a);
|
|
708
|
+
const bufB = Buffer.from(b);
|
|
709
|
+
if (bufA.length !== bufB.length) return false;
|
|
710
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
module.exports = SecurityEngine;
|