3xui-api-client 2.0.0 → 2.1.1

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.
@@ -1 +1,325 @@
1
-
1
+ /**
2
+ * Security Enhancements for 3xui-api-client
3
+ *
4
+ * Provides:
5
+ * - InputValidator: basic validation/sanitization for URLs, usernames, passwords, and API payloads
6
+ * - SecureHeaders: recommended HTTP headers for axios instance
7
+ * - SecurityMonitor: lightweight in-memory rate limiting and activity log
8
+ * - CredentialSecurity: helpers for safe logging, strength checks, and token generation
9
+ * - ErrorSecurity: safe error logging and sanitization for prod/dev
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+ const { URL } = require('url');
14
+
15
+ // ------------------------------
16
+ // Input validation & sanitization
17
+ // ------------------------------
18
+ class InputValidator {
19
+ static validateURL(url) {
20
+ if (typeof url !== 'string' || url.trim() === '') {
21
+ throw new Error('Invalid URL: empty');
22
+ }
23
+ const trimmed = url.trim();
24
+ try {
25
+ const u = new URL(trimmed);
26
+ if (!['http:', 'https:'].includes(u.protocol)) {
27
+ throw new Error('Invalid URL: protocol must be http or https');
28
+ }
29
+ // normalize: remove trailing slash
30
+ return trimmed.replace(/\/$/, '');
31
+ } catch {
32
+ throw new Error('Invalid URL format');
33
+ }
34
+ }
35
+
36
+ static validateUsername(username) {
37
+ if (typeof username !== 'string' || username.trim() === '') {
38
+ throw new Error('Invalid username: empty');
39
+ }
40
+ const u = username.trim();
41
+ // Allow common admin usernames; restrict control chars
42
+ if (u.length > 100) {
43
+ throw new Error('Invalid username: too long');
44
+ }
45
+ if (/[^\w.@+-]/.test(u)) {
46
+ throw new Error('Invalid username: contains illegal characters');
47
+ }
48
+ return u;
49
+ }
50
+
51
+ static validatePassword(password) {
52
+ if (typeof password !== 'string' || password.length === 0) {
53
+ throw new Error('Invalid password: empty');
54
+ }
55
+ // Do not enforce complexity here (handled by CredentialSecurity). Just trim spaces.
56
+ return password;
57
+ }
58
+
59
+ static _ensureJSONString(value) {
60
+ if (value === null || value === undefined) {
61
+ return undefined;
62
+ }
63
+ if (typeof value === 'string') {
64
+ return value;
65
+ }
66
+ try {
67
+ return JSON.stringify(value);
68
+ } catch {
69
+ throw new Error('Invalid configuration: unable to serialize to JSON');
70
+ }
71
+ }
72
+
73
+ static _validatePort(port) {
74
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
75
+ throw new Error('Invalid port: must be an integer between 1 and 65535');
76
+ }
77
+ return port;
78
+ }
79
+
80
+ static validateInboundConfig(config) {
81
+ if (typeof config !== 'object' || config === null) {
82
+ throw new Error('Invalid inbound config');
83
+ }
84
+ const copy = { ...config };
85
+ if (copy.port !== undefined) {
86
+ this._validatePort(copy.port);
87
+ }
88
+ if (copy.protocol && typeof copy.protocol !== 'string') {
89
+ throw new Error('Invalid protocol');
90
+ }
91
+ // 3x-ui expects settings/streamSettings/sniffing/allocate as JSON strings
92
+ if (copy.settings && typeof copy.settings !== 'string') {
93
+ copy.settings = this._ensureJSONString(copy.settings);
94
+ }
95
+ if (copy.streamSettings && typeof copy.streamSettings !== 'string') {
96
+ copy.streamSettings = this._ensureJSONString(copy.streamSettings);
97
+ }
98
+ if (copy.sniffing && typeof copy.sniffing !== 'string') {
99
+ copy.sniffing = this._ensureJSONString(copy.sniffing);
100
+ }
101
+ if (copy.allocate && typeof copy.allocate !== 'string') {
102
+ copy.allocate = this._ensureJSONString(copy.allocate);
103
+ }
104
+ return copy;
105
+ }
106
+
107
+ static validateClientConfig(config) {
108
+ if (typeof config !== 'object' || config === null) {
109
+ throw new Error('Invalid client config');
110
+ }
111
+ const copy = { ...config };
112
+ if (typeof copy.id !== 'number' && typeof copy.id !== 'string') {
113
+ throw new Error('Invalid client config: id is required');
114
+ }
115
+ if (copy.settings && typeof copy.settings !== 'string') {
116
+ copy.settings = this._ensureJSONString(copy.settings);
117
+ }
118
+ return copy;
119
+ }
120
+ }
121
+
122
+ // ------------------------------
123
+ // Secure headers for axios
124
+ // ------------------------------
125
+ class SecureHeaders {
126
+ static getSecureHeaders(options = {}) {
127
+ const headers = {
128
+ 'Accept': 'application/json, text/plain, */*',
129
+ 'Cache-Control': 'no-store',
130
+ 'Pragma': 'no-cache',
131
+ 'X-Content-Type-Options': 'nosniff',
132
+ 'X-Frame-Options': 'DENY',
133
+ 'X-XSS-Protection': '1; mode=block',
134
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
135
+ 'User-Agent': options.userAgent || '3xui-api-client/2.0.0'
136
+ };
137
+ if (options.enableCSP) {
138
+ headers['Content-Security-Policy'] = 'default-src \'none\'';
139
+ }
140
+ return headers;
141
+ }
142
+ }
143
+
144
+ // ------------------------------
145
+ // Lightweight security monitor
146
+ // ------------------------------
147
+ class SecurityMonitor {
148
+ constructor(options = {}) {
149
+ this.maxRequestsPerMinute = options.maxRequestsPerMinute || 60;
150
+ this.maxLoginAttemptsPerHour = options.maxLoginAttemptsPerHour || 10;
151
+ this.requestLog = new Map(); // key -> timestamps array
152
+ this.loginLog = new Map(); // key -> timestamps array
153
+ this.blockedIPs = new Set();
154
+ this.activities = [];
155
+ }
156
+
157
+ _prune(log, windowMs) {
158
+ const now = Date.now();
159
+ for (const [key, arr] of log.entries()) {
160
+ const pruned = arr.filter(ts => now - ts <= windowMs);
161
+ if (pruned.length === 0) {
162
+ log.delete(key);
163
+ } else {
164
+ log.set(key, pruned);
165
+ }
166
+ }
167
+ }
168
+
169
+ checkRateLimit(identifier, type = 'general') {
170
+ const now = Date.now();
171
+ if (type === 'login') {
172
+ const windowMs = 60 * 60 * 1000; // 1 hour
173
+ const arr = this.loginLog.get(identifier) || [];
174
+ const pruned = arr.filter(ts => now - ts <= windowMs);
175
+ pruned.push(now);
176
+ this.loginLog.set(identifier, pruned);
177
+ this._prune(this.loginLog, windowMs);
178
+ const allowed = pruned.length <= this.maxLoginAttemptsPerHour;
179
+ if (!allowed) {
180
+ this.logSuspiciousActivity('rate_limit_exceeded', { identifier, type });
181
+ }
182
+ return allowed;
183
+ } else {
184
+ const windowMs = 60 * 1000; // 1 minute
185
+ const arr = this.requestLog.get(identifier) || [];
186
+ const pruned = arr.filter(ts => now - ts <= windowMs);
187
+ pruned.push(now);
188
+ this.requestLog.set(identifier, pruned);
189
+ this._prune(this.requestLog, windowMs);
190
+ const allowed = pruned.length <= this.maxRequestsPerMinute;
191
+ if (!allowed) {
192
+ this.logSuspiciousActivity('rate_limit_exceeded', { identifier, type });
193
+ }
194
+ return allowed;
195
+ }
196
+ }
197
+
198
+ logSuspiciousActivity(type, details = {}) {
199
+ const event = {
200
+ id: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'),
201
+ type,
202
+ details,
203
+ severity: type.includes('failed') || type.includes('blocked') ? 'high' : 'medium',
204
+ timestamp: Date.now()
205
+ };
206
+ this.activities.push(event);
207
+ // Keep recent 200
208
+ if (this.activities.length > 200) {
209
+ this.activities.shift();
210
+ }
211
+ return event;
212
+ }
213
+
214
+ blockIP(ip) {
215
+ this.blockedIPs.add(ip);
216
+ }
217
+
218
+ clearBlockedIPs() {
219
+ this.blockedIPs.clear();
220
+ }
221
+
222
+ getStats() {
223
+ return {
224
+ blockedIPs: this.blockedIPs.size,
225
+ totalSuspiciousActivities: this.activities.length,
226
+ recentActivities: [...this.activities].slice(-50),
227
+ activeRateLimits: {
228
+ general: [...this.requestLog.entries()].reduce((acc, [, arr]) => acc + arr.length, 0),
229
+ login: [...this.loginLog.entries()].reduce((acc, [, arr]) => acc + arr.length, 0)
230
+ }
231
+ };
232
+ }
233
+ }
234
+
235
+ // ------------------------------
236
+ // Credential helpers
237
+ // ------------------------------
238
+ class CredentialSecurity {
239
+ static hashForLogging(value) {
240
+ try {
241
+ return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
242
+ } catch {
243
+ return 'redacted';
244
+ }
245
+ }
246
+
247
+ static validateCredentialStrength(credential, type = 'password') {
248
+ const issues = [];
249
+ let strength = 'weak';
250
+
251
+ if (type === 'uuid') {
252
+ const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
253
+ if (!uuidV4.test(credential)) {
254
+ issues.push('Invalid UUID v4 format');
255
+ }
256
+ strength = issues.length ? 'weak' : 'strong';
257
+ } else if (type === 'password') {
258
+ if (typeof credential !== 'string' || credential.length < 8) {
259
+ issues.push('Password too short');
260
+ }
261
+ if (!/[a-z]/.test(credential)) {
262
+ issues.push('Add lowercase letters');
263
+ }
264
+ if (!/[A-Z]/.test(credential)) {
265
+ issues.push('Add uppercase letters');
266
+ }
267
+ if (!/[0-9]/.test(credential)) {
268
+ issues.push('Add numbers');
269
+ }
270
+ const hasSymbols = /[^A-Za-z0-9]/.test(credential);
271
+ strength = credential.length >= 12 && /[a-z]/.test(credential) && /[A-Z]/.test(credential) && /[0-9]/.test(credential) && hasSymbols ? 'strong' : (credential.length >= 10 ? 'medium' : 'weak');
272
+ } else if (type === 'port') {
273
+ const n = Number(credential);
274
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
275
+ issues.push('Port must be 1-65535');
276
+ }
277
+ strength = issues.length ? 'weak' : 'strong';
278
+ }
279
+
280
+ return { isValid: issues.length === 0, issues, strength };
281
+ }
282
+
283
+ static generateSessionToken(bytes = 32) {
284
+ return crypto.randomBytes(bytes).toString('hex');
285
+ }
286
+ }
287
+
288
+ // ------------------------------
289
+ // Error handling and sanitization
290
+ // ------------------------------
291
+ class ErrorSecurity {
292
+ static sanitizeError(error, isDevelopment = false) {
293
+ const message = error?.message || 'Unknown error';
294
+ if (isDevelopment) {
295
+ return new Error(message);
296
+ }
297
+ // Production: remove stack and internal details
298
+ const sanitized = new Error(message);
299
+ return sanitized;
300
+ }
301
+
302
+ static logError(error, context = {}) {
303
+ try {
304
+ const safeContext = { ...context };
305
+ if (safeContext.username) {
306
+ safeContext.username = CredentialSecurity.hashForLogging(safeContext.username);
307
+ }
308
+ if (safeContext.baseURL) {
309
+ safeContext.baseURL = InputValidator.validateURL(String(safeContext.baseURL));
310
+ }
311
+ console.warn('[3xui-api-client] Error:', error?.message || error, safeContext);
312
+ } catch {
313
+ // noop
314
+ }
315
+ }
316
+ }
317
+
318
+ module.exports = {
319
+ InputValidator,
320
+ SecureHeaders,
321
+ SecurityMonitor,
322
+ CredentialSecurity,
323
+ ErrorSecurity
324
+ };
325
+