Files
tankwar_proj/content-security-service/services/contentSecurityService.js
jakciehan d263c7bf48 Merge feature/add_skin into master: resolve all conflicts
- 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
2026-05-12 07:05:20 +08:00

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;