/** * Content Security Service * Provides unified methods for text and image content security checking * via WeChat's msgSecCheck and imgSecCheck APIs. * * Features: * - checkTextContent(openid, content, scene): Text content audit * - checkImageContent(imageBuffer): Image content audit * - 3-second timeout for each API call * - Rate limiting queue (5000 requests/minute for msgSecCheck) */ const https = require('https'); const AuditLogger = require('./auditLogger'); // ============================================================ // Configuration // ============================================================ const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check'; const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check'; const API_TIMEOUT_MS = 3000; // 3 seconds timeout const RATE_LIMIT_PER_MINUTE = 5000; const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute // Scene mapping for msgSecCheck const SCENE_MAP = { NICKNAME: 1, CHAT: 2, SIGNATURE: 3, DESCRIPTION: 4, }; class ContentSecurityService { /** * @param {object} options * @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance * @param {object} [options.logger] - Optional custom logger */ constructor(options = {}) { this.tokenManager = options.tokenManager; this.logger = options.logger || new AuditLogger(); // Rate limiting this._requestTimestamps = []; this._requestQueue = []; this._isProcessingQueue = false; } /** * Check text content for security violations using msgSecCheck. * @param {string} openid - User's openid * @param {string} content - Text content to check * @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description) * @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>} */ async checkTextContent(openid, content, scene) { const startTime = Date.now(); // Validate scene value if (!Object.values(SCENE_MAP).includes(scene)) { const result = { pass: false, errcode: -1, errmsg: 'Invalid scene value', suggest: 'risky', label: 100, }; this.logger.logAudit({ userId: openid, contentType: 'text', contentSummary: this._sanitizeContent(content), scene, result: 'rejected', reason: 'invalid_scene', duration: Date.now() - startTime, }); return result; } // Check if token manager is available if (!this.tokenManager || !this.tokenManager.isAvailable()) { const result = { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', suggest: 'risky', label: 100, }; this.logger.logAudit({ userId: openid, contentType: 'text', contentSummary: this._sanitizeContent(content), scene, result: 'rejected', reason: 'service_unavailable', duration: Date.now() - startTime, }); return result; } // Enforce rate limiting await this._enforceRateLimit(); try { const token = await this.tokenManager.getAccessToken(); if (!token) { const result = { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', suggest: 'risky', label: 100, }; this.logger.logAudit({ userId: openid, contentType: 'text', contentSummary: this._sanitizeContent(content), scene, result: 'rejected', reason: 'token_unavailable', duration: Date.now() - startTime, }); return result; } const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`; const postData = JSON.stringify({ openid, scene, version: 2, content, }); const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck'); const duration = Date.now() - startTime; const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass'; const result = { pass, errcode: apiResult.errcode || 0, errmsg: apiResult.errmsg || '', suggest: apiResult.result ? apiResult.result.suggest : 'risky', label: apiResult.result ? apiResult.result.label : 100, }; this.logger.logAudit({ userId: openid, contentType: 'text', contentSummary: this._sanitizeContent(content), scene, result: pass ? 'pass' : 'reject', reason: pass ? 'content_safe' : `label_${result.label}`, duration, }); return result; } catch (err) { const duration = Date.now() - startTime; console.error('[ContentSecurity] msgSecCheck error:', err.message); this.logger.logAudit({ userId: openid, contentType: 'text', contentSummary: this._sanitizeContent(content), scene, result: 'error', reason: err.message, duration, }); // On error, reject the content (fail-closed: safety over availability) return { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', suggest: 'risky', label: 100, }; } } /** * Check image content for security violations using imgSecCheck. * @param {Buffer} imageBuffer - Image data buffer * @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>} */ async checkImageContent(imageBuffer) { const startTime = Date.now(); // Check image size (max 1MB) if (imageBuffer.length > 1024 * 1024) { const result = { pass: false, errcode: -1, errmsg: '图片大小不能超过1MB', }; this.logger.logAudit({ userId: 'system', contentType: 'image', contentSummary: `image_${imageBuffer.length}bytes`, scene: 0, result: 'rejected', reason: 'image_too_large', duration: Date.now() - startTime, }); return result; } // Check if token manager is available if (!this.tokenManager || !this.tokenManager.isAvailable()) { const result = { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', }; this.logger.logAudit({ userId: 'system', contentType: 'image', contentSummary: `image_${imageBuffer.length}bytes`, scene: 0, result: 'rejected', reason: 'service_unavailable', duration: Date.now() - startTime, }); return result; } // Enforce rate limiting await this._enforceRateLimit(); try { const token = await this.tokenManager.getAccessToken(); if (!token) { const result = { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', }; this.logger.logAudit({ userId: 'system', contentType: 'image', contentSummary: `image_${imageBuffer.length}bytes`, scene: 0, result: 'rejected', reason: 'token_unavailable', duration: Date.now() - startTime, }); return result; } const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`; // Build multipart form data for imgSecCheck const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'; const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`; const suffix = `\r\n--${boundary}--\r\n`; const buffer = Buffer.concat([ Buffer.from(prefix), imageBuffer, Buffer.from(suffix), ]); const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck'); const duration = Date.now() - startTime; const pass = apiResult.errcode === 0; this.logger.logAudit({ userId: 'system', contentType: 'image', contentSummary: `image_${imageBuffer.length}bytes`, scene: 0, result: pass ? 'pass' : 'reject', reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`, duration, }); return { pass, errcode: apiResult.errcode || 0, errmsg: pass ? 'ok' : '图片内容违规,请更换', }; } catch (err) { const duration = Date.now() - startTime; console.error('[ContentSecurity] imgSecCheck error:', err.message); this.logger.logAudit({ userId: 'system', contentType: 'image', contentSummary: `image_${imageBuffer.length}bytes`, scene: 0, result: 'error', reason: err.message, duration, }); // Fail-closed return { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', }; } } /** * Call WeChat API with JSON POST data. * @param {string} url * @param {string} postData * @param {string} apiName - For logging * @returns {Promise} */ _callWechatAPI(url, postData, apiName) { return new Promise((resolve, reject) => { const parsedUrl = new URL(url); const options = { hostname: parsedUrl.hostname, path: parsedUrl.pathname + parsedUrl.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData), }, timeout: API_TIMEOUT_MS, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const result = JSON.parse(data); resolve(result); } catch (e) { reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`)); } }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`)); }); req.on('error', (err) => { reject(err); }); req.write(postData); req.end(); }); } /** * Call WeChat API with multipart form data (for image upload). * @param {string} url * @param {Buffer} buffer * @param {string} boundary * @param {string} apiName - For logging * @returns {Promise} */ _callWechatAPIWithBuffer(url, buffer, boundary, apiName) { return new Promise((resolve, reject) => { const parsedUrl = new URL(url); const options = { hostname: parsedUrl.hostname, path: parsedUrl.pathname + parsedUrl.search, method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': buffer.length, }, timeout: API_TIMEOUT_MS, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const result = JSON.parse(data); resolve(result); } catch (e) { reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`)); } }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`)); }); req.on('error', (err) => { reject(err); }); req.write(buffer); req.end(); }); } /** * Enforce rate limiting: queue requests if exceeding 5000/min. * @returns {Promise} */ async _enforceRateLimit() { const now = Date.now(); // Clean old timestamps outside the current window this._requestTimestamps = this._requestTimestamps.filter( (ts) => now - ts < RATE_LIMIT_WINDOW_MS ); if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) { this._requestTimestamps.push(now); return; } // Rate limited - wait until a slot opens return new Promise((resolve) => { this._requestQueue.push(resolve); this._processQueue(); }); } /** * Process queued requests as rate limit slots become available. */ _processQueue() { if (this._isProcessingQueue || this._requestQueue.length === 0) return; this._isProcessingQueue = true; const processNext = () => { const now = Date.now(); this._requestTimestamps = this._requestTimestamps.filter( (ts) => now - ts < RATE_LIMIT_WINDOW_MS ); if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) { this._requestTimestamps.push(now); const nextResolve = this._requestQueue.shift(); nextResolve(); processNext(); } else if (this._requestQueue.length > 0) { // Wait for the oldest timestamp to expire const oldestTs = this._requestTimestamps[0]; const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100; setTimeout(processNext, waitTime); } else { this._isProcessingQueue = false; } }; processNext(); } /** * Sanitize content for logging: keep only first 3 and last 3 chars, * replace middle with ***. * @param {string} content * @returns {string} */ _sanitizeContent(content) { if (!content || typeof content !== 'string') return ''; if (content.length <= 6) return content; return content.substring(0, 3) + '***' + content.substring(content.length - 3); } } // Export scene mapping for external use ContentSecurityService.SCENE_MAP = SCENE_MAP; module.exports = ContentSecurityService;