UNPKG

3xui-api-client

Version:

A Node.js client library for 3x-ui panel API with built-in credential generation, session management, and web integration support

326 lines (300 loc) 11.5 kB
/** * Security Enhancements for 3xui-api-client * * Provides: * - InputValidator: basic validation/sanitization for URLs, usernames, passwords, and API payloads * - SecureHeaders: recommended HTTP headers for axios instance * - SecurityMonitor: lightweight in-memory rate limiting and activity log * - CredentialSecurity: helpers for safe logging, strength checks, and token generation * - ErrorSecurity: safe error logging and sanitization for prod/dev */ const crypto = require('crypto'); const { URL } = require('url'); // ------------------------------ // Input validation & sanitization // ------------------------------ class InputValidator { static validateURL(url) { if (typeof url !== 'string' || url.trim() === '') { throw new Error('Invalid URL: empty'); } const trimmed = url.trim(); try { const u = new URL(trimmed); if (!['http:', 'https:'].includes(u.protocol)) { throw new Error('Invalid URL: protocol must be http or https'); } // normalize: remove trailing slash return trimmed.replace(/\/$/, ''); } catch { throw new Error('Invalid URL format'); } } static validateUsername(username) { if (typeof username !== 'string' || username.trim() === '') { throw new Error('Invalid username: empty'); } const u = username.trim(); // Allow common admin usernames; restrict control chars if (u.length > 100) { throw new Error('Invalid username: too long'); } if (/[^\w.@+-]/.test(u)) { throw new Error('Invalid username: contains illegal characters'); } return u; } static validatePassword(password) { if (typeof password !== 'string' || password.length === 0) { throw new Error('Invalid password: empty'); } // Do not enforce complexity here (handled by CredentialSecurity). Just trim spaces. return password; } static _ensureJSONString(value) { if (value === null || value === undefined) { return undefined; } if (typeof value === 'string') { return value; } try { return JSON.stringify(value); } catch { throw new Error('Invalid configuration: unable to serialize to JSON'); } } static _validatePort(port) { if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('Invalid port: must be an integer between 1 and 65535'); } return port; } static validateInboundConfig(config) { if (typeof config !== 'object' || config === null) { throw new Error('Invalid inbound config'); } const copy = { ...config }; if (copy.port !== undefined) { this._validatePort(copy.port); } if (copy.protocol && typeof copy.protocol !== 'string') { throw new Error('Invalid protocol'); } // 3x-ui expects settings/streamSettings/sniffing/allocate as JSON strings if (copy.settings && typeof copy.settings !== 'string') { copy.settings = this._ensureJSONString(copy.settings); } if (copy.streamSettings && typeof copy.streamSettings !== 'string') { copy.streamSettings = this._ensureJSONString(copy.streamSettings); } if (copy.sniffing && typeof copy.sniffing !== 'string') { copy.sniffing = this._ensureJSONString(copy.sniffing); } if (copy.allocate && typeof copy.allocate !== 'string') { copy.allocate = this._ensureJSONString(copy.allocate); } return copy; } static validateClientConfig(config) { if (typeof config !== 'object' || config === null) { throw new Error('Invalid client config'); } const copy = { ...config }; if (typeof copy.id !== 'number' && typeof copy.id !== 'string') { throw new Error('Invalid client config: id is required'); } if (copy.settings && typeof copy.settings !== 'string') { copy.settings = this._ensureJSONString(copy.settings); } return copy; } } // ------------------------------ // Secure headers for axios // ------------------------------ class SecureHeaders { static getSecureHeaders(options = {}) { const headers = { 'Accept': 'application/json, text/plain, */*', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'User-Agent': options.userAgent || '3xui-api-client/2.0.0' }; if (options.enableCSP) { headers['Content-Security-Policy'] = 'default-src \'none\''; } return headers; } } // ------------------------------ // Lightweight security monitor // ------------------------------ class SecurityMonitor { constructor(options = {}) { this.maxRequestsPerMinute = options.maxRequestsPerMinute || 60; this.maxLoginAttemptsPerHour = options.maxLoginAttemptsPerHour || 10; this.requestLog = new Map(); // key -> timestamps array this.loginLog = new Map(); // key -> timestamps array this.blockedIPs = new Set(); this.activities = []; } _prune(log, windowMs) { const now = Date.now(); for (const [key, arr] of log.entries()) { const pruned = arr.filter(ts => now - ts <= windowMs); if (pruned.length === 0) { log.delete(key); } else { log.set(key, pruned); } } } checkRateLimit(identifier, type = 'general') { const now = Date.now(); if (type === 'login') { const windowMs = 60 * 60 * 1000; // 1 hour const arr = this.loginLog.get(identifier) || []; const pruned = arr.filter(ts => now - ts <= windowMs); pruned.push(now); this.loginLog.set(identifier, pruned); this._prune(this.loginLog, windowMs); const allowed = pruned.length <= this.maxLoginAttemptsPerHour; if (!allowed) { this.logSuspiciousActivity('rate_limit_exceeded', { identifier, type }); } return allowed; } else { const windowMs = 60 * 1000; // 1 minute const arr = this.requestLog.get(identifier) || []; const pruned = arr.filter(ts => now - ts <= windowMs); pruned.push(now); this.requestLog.set(identifier, pruned); this._prune(this.requestLog, windowMs); const allowed = pruned.length <= this.maxRequestsPerMinute; if (!allowed) { this.logSuspiciousActivity('rate_limit_exceeded', { identifier, type }); } return allowed; } } logSuspiciousActivity(type, details = {}) { const event = { id: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'), type, details, severity: type.includes('failed') || type.includes('blocked') ? 'high' : 'medium', timestamp: Date.now() }; this.activities.push(event); // Keep recent 200 if (this.activities.length > 200) { this.activities.shift(); } return event; } blockIP(ip) { this.blockedIPs.add(ip); } clearBlockedIPs() { this.blockedIPs.clear(); } getStats() { return { blockedIPs: this.blockedIPs.size, totalSuspiciousActivities: this.activities.length, recentActivities: [...this.activities].slice(-50), activeRateLimits: { general: [...this.requestLog.entries()].reduce((acc, [, arr]) => acc + arr.length, 0), login: [...this.loginLog.entries()].reduce((acc, [, arr]) => acc + arr.length, 0) } }; } } // ------------------------------ // Credential helpers // ------------------------------ class CredentialSecurity { static hashForLogging(value) { try { return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12); } catch { return 'redacted'; } } static validateCredentialStrength(credential, type = 'password') { const issues = []; let strength = 'weak'; if (type === 'uuid') { 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; if (!uuidV4.test(credential)) { issues.push('Invalid UUID v4 format'); } strength = issues.length ? 'weak' : 'strong'; } else if (type === 'password') { if (typeof credential !== 'string' || credential.length < 8) { issues.push('Password too short'); } if (!/[a-z]/.test(credential)) { issues.push('Add lowercase letters'); } if (!/[A-Z]/.test(credential)) { issues.push('Add uppercase letters'); } if (!/[0-9]/.test(credential)) { issues.push('Add numbers'); } const hasSymbols = /[^A-Za-z0-9]/.test(credential); strength = credential.length >= 12 && /[a-z]/.test(credential) && /[A-Z]/.test(credential) && /[0-9]/.test(credential) && hasSymbols ? 'strong' : (credential.length >= 10 ? 'medium' : 'weak'); } else if (type === 'port') { const n = Number(credential); if (!Number.isInteger(n) || n < 1 || n > 65535) { issues.push('Port must be 1-65535'); } strength = issues.length ? 'weak' : 'strong'; } return { isValid: issues.length === 0, issues, strength }; } static generateSessionToken(bytes = 32) { return crypto.randomBytes(bytes).toString('hex'); } } // ------------------------------ // Error handling and sanitization // ------------------------------ class ErrorSecurity { static sanitizeError(error, isDevelopment = false) { const message = error?.message || 'Unknown error'; if (isDevelopment) { return new Error(message); } // Production: remove stack and internal details const sanitized = new Error(message); return sanitized; } static logError(error, context = {}) { try { const safeContext = { ...context }; if (safeContext.username) { safeContext.username = CredentialSecurity.hashForLogging(safeContext.username); } if (safeContext.baseURL) { safeContext.baseURL = InputValidator.validateURL(String(safeContext.baseURL)); } console.warn('[3xui-api-client] Error:', error?.message || error, safeContext); } catch { // noop } } } module.exports = { InputValidator, SecureHeaders, SecurityMonitor, CredentialSecurity, ErrorSecurity };