d263c7bf48
- GameGlobal.js: keep upstream SERVER_URL with /ws suffix - en.js/zh.js: merge both settings.nickname and settings.profile keys - SettingsScene.js: keep both nickname row and profile button - server/index.js: merge express app + content security proxy with noServer WebSocket mode and path validation - Add .gitignore for node_modules and .codebuddy
475 lines
14 KiB
JavaScript
475 lines
14 KiB
JavaScript
/**
|
|
* 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<object>}
|
|
*/
|
|
_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<object>}
|
|
*/
|
|
_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<void>}
|
|
*/
|
|
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; |