security: 新增全局限流 + 5xx 错误脱敏 + 兜底错误处理

- 内存级限流器(令牌桶):/api 全局 600/分;写接口单独限流(发帖10、评论20、组局申请20、举报5、上传30、下单20、支付30、充值/提现10、浏览1次/3秒);超限 429+Retry-After
- 5xx 统一脱敏:内部错误仅记服务端日志,对外返回“服务器内部错误”,不再回传 SQL/堆栈/内部原因
- 新增兜底错误处理中间件,避免 Express 默认 HTML 堆栈外泄
- 更新 upload 断言;新增回归护栏 ratelimit-5xx.spec.ts(90 套件/648 用例全绿);冒烟 7/7
- 文档补充限流与错误脱敏说明
这个提交包含在:
xcbyy
2026-10-19 13:50:51 +08:00
父节点 a61051fa96
当前提交 25d8474877
共有 4 个文件被更改,包括 123 次插入 和 15 次删除

查看文件

@@ -74,6 +74,65 @@ app.use((_req: Request, res: Response, next: NextFunction) => {
}
next();
});
// ===== 限流(内存级令牌桶:按 IP / IP+用户 计数,超限返回 429)=====
type RateBucket = { count: number; resetAt: number };
const rateBuckets = new Map<string, RateBucket>();
// 周期性清理过期桶,避免内存无界增长
const rateSweepTimer = setInterval(() => {
const now = Date.now();
for (const [k, v] of rateBuckets) {
if (now > v.resetAt) rateBuckets.delete(k);
}
if (rateBuckets.size > 100000) rateBuckets.clear(); // 兜底保护
}, 60 * 1000);
if (typeof (rateSweepTimer as any).unref === 'function') (rateSweepTimer as any).unref();
function rateLimit(opts: { name: string; windowMs: number; max: number; key?: (req: Request) => string }) {
return (req: Request, res: Response, next: NextFunction): void => {
const id = opts.key ? opts.key(req) : clientIpOf(req);
const k = `${opts.name}:${id}`;
const now = Date.now();
const b = rateBuckets.get(k);
if (!b || now > b.resetAt) {
rateBuckets.set(k, { count: 1, resetAt: now + opts.windowMs });
return next();
}
b.count += 1;
if (b.count > opts.max) {
const retry = Math.max(1, Math.ceil((b.resetAt - now) / 1000));
res.setHeader('Retry-After', String(retry));
res.status(429).json({ code: 42900, message: `操作过于频繁,请 ${retry} 秒后再试` });
return;
}
return next();
};
}
// 全局限流(宽松,主要抵御暴力刷接口/爬取)
app.use('/api', rateLimit({ name: 'global', windowMs: 60 * 1000, max: 600 }));
// 写接口限流键:优先按登录用户,未登录退化到 IP
function rateWriteKey(req: Request): string {
try {
const uid = resolveRequestUserId(req);
if (uid) return `u${uid}`;
} catch (_) {}
return clientIpOf(req);
}
// 5xx 统一脱敏:内部错误只记服务端日志,对外返回通用提示,避免泄露 SQL/堆栈/内部细节
app.use((req: Request, res: Response, next: NextFunction) => {
const originalJson = res.json.bind(res);
(res as any).json = (body: any) => {
if (res.statusCode >= 500 && body && typeof body === 'object' && typeof body.message === 'string') {
logServerError(`http${res.statusCode} ${req.method} ${req.originalUrl}`, new Error(body.message));
body = { ...body, message: '服务器内部错误,请稍后重试' };
}
return originalJson(body);
};
next();
});
app.use(
express.json({
// 保留原始 HTTP 报文,供微信支付 V3 回调验签使用
@@ -578,8 +637,8 @@ const handleFileUpload = (req: Request, res: Response) => {
});
};
app.post('/api/upload', handleFileUpload);
app.post('/api/user/upload', handleFileUpload);
app.post('/api/upload', rateLimit({ name: 'upload', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload);
app.post('/api/user/upload', rateLimit({ name: 'upload_user', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload);
// 微信官方内容安全文本检测接口 (对接 security.msgSecCheck)
app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => {
@@ -1806,7 +1865,7 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
});
// 创建新订单 (落盘 MySQL)
app.post('/api/orders', async (req: Request, res: Response) => {
app.post('/api/orders', rateLimit({ name: 'order_create', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const buyerId = resolveSecureUserId(
req,
@@ -2590,7 +2649,7 @@ app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => {
});
// 微信小程序 JSAPI 统一下单预支付接口 (支持真实商户号配置与沙盒收银台无缝切换)
app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => {
app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const {
orderId,
@@ -2998,7 +3057,7 @@ app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
});
// 举报不良陪玩 (涉黄/涉赌/线下欺诈等,进入平台风控处置池)
app.post('/api/reports', async (req: Request, res: Response) => {
app.post('/api/reports', rateLimit({ name: 'report_create', windowMs: 60 * 1000, max: 5, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const { reporterId, targetType, targetId, targetName, reason, description, evidence } = req.body || {};
if (!targetId || !reason) {
@@ -3611,7 +3670,7 @@ app.post('/api/users/:id/settings', (req: Request, res: Response) => {
// 7.2.2 钱包充值接口 (真实落盘 balance 与 finance_records)
app.post('/api/users/:id/recharge', async (req: Request, res: Response) => {
app.post('/api/users/:id/recharge', rateLimit({ name: 'recharge', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
// 资金安全:充值身份以服务端会话 token 为准,严禁为他人的钱包充值/刷余额
const userId = resolveSecureUserId(req, Number(req.params.id), '充值余额');
@@ -3658,7 +3717,7 @@ app.post('/api/users/:id/recharge', async (req: Request, res: Response) => {
});
// 7.2.3 佣金提现申请接口 (真实扣减 commission 并落盘 withdrawals 与 finance_records)
app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => {
app.post('/api/users/:id/withdraw', rateLimit({ name: 'withdraw', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const pathUserId = Number(req.params.id);
// 资金安全:身份以服务端会话 token 为准,生产环境必须登录,严禁以他人身份提现
@@ -4007,7 +4066,7 @@ app.get('/api/admin/message/templates', async (_req: Request, res: Response) =>
}
});
app.post('/api/posts', async (req: Request, res: Response) => {
app.post('/api/posts', rateLimit({ name: 'post_create', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const { content, userId } = req.body || {};
const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发布动态');
@@ -4033,7 +4092,7 @@ app.post('/api/posts', async (req: Request, res: Response) => {
}
});
app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
app.post('/api/posts/:id/like', rateLimit({ name: 'post_like', windowMs: 60 * 1000, max: 60, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const userId = resolveSecureUserId(
req,
@@ -4054,7 +4113,7 @@ app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
}
});
app.post('/api/posts/:id/view', async (req: Request, res: Response) => {
app.post('/api/posts/:id/view', rateLimit({ name: 'post_view', windowMs: 3000, max: 1, key: (req) => `${clientIpOf(req)}:${req.params.id}` }), async (req: Request, res: Response) => {
try {
const result = await repo.viewPost(String(req.params.id));
res.json({
@@ -4241,7 +4300,7 @@ app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
}
});
app.post('/api/posts/:id/comments', async (req: Request, res: Response) => {
app.post('/api/posts/:id/comments', rateLimit({ name: 'post_comment', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const postId = String(req.params.id);
const { content, userId, author, avatar } = req.body || {};
@@ -4279,7 +4338,7 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => {
});
// 7.7 友圈搭子互动上车约伴申请接口
app.post('/api/posts/:id/apply', async (req: Request, res: Response) => {
app.post('/api/posts/:id/apply', rateLimit({ name: 'post_apply', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const postId = String(req.params.id);
const { applicantId, applicantName, applicantAvatar } = req.body || {};
@@ -4575,6 +4634,17 @@ function startOrderTimeoutScheduler(): void {
console.log(`[Peiwan Scheduler] 订单超时扫描已启动,间隔 ${Math.round(intervalMs / 1000)}s`);
}
// 兜底错误处理:统一返回 JSON(避免 Express 默认 HTML 堆栈泄露);5xx 只记日志、对外通用提示
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = Number(err && err.status) || 500;
if (status >= 500) {
logServerError('unhandledError', err);
res.status(500).json({ code: 50000, message: '服务器内部错误,请稍后重试' });
return;
}
res.status(status).json({ code: 40000, message: (err && err.message) || '请求不合法' });
});
async function bootstrap() {
try {
await initDatabase();

查看文件

@@ -0,0 +1,34 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第七十四批回归闸门:5xx 错误脱敏 + 全局限流。
*/
describe('第七十四批限流与错误脱敏闸门', () => {
const root = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
it('提供内存级限流器并应用于 /api 全局', () => {
expect(indexCode).toContain('function rateLimit(opts:');
expect(indexCode).toContain("app.use('/api', rateLimit({ name: 'global'");
expect(indexCode).toContain('操作过于频繁');
expect(indexCode).toContain('Retry-After');
});
it('高频写接口单独限流(发帖/上传/下单/支付/举报/提现等)', () => {
for (const name of ['post_create', 'upload', 'order_create', 'prepay', 'report_create', 'withdraw', 'recharge', 'post_view']) {
expect(indexCode).toContain(`name: '${name}'`);
}
});
it('5xx 响应统一脱敏且仅记录服务端日志', () => {
expect(indexCode).toContain('服务器内部错误,请稍后重试');
expect(indexCode).toContain('res.statusCode >= 500');
expect(indexCode).toContain('logServerError(`http${res.statusCode}');
});
it('提供兜底错误处理,避免默认 HTML 堆栈泄露', () => {
expect(indexCode).toContain('logServerError(\'unhandledError\'');
});
});

查看文件

@@ -13,8 +13,8 @@ describe('FileUpload Service (TDD 文件上传与静态对象存储服务门禁)
it('server/src/index.ts 必须挂载 /uploads 静态托管路由与 /api/upload 接口', () => {
const content = fs.readFileSync(serverIndexPath, 'utf-8');
expect(content).toContain("app.use('/uploads', express.static(uploadDir));");
expect(content).toContain("app.post('/api/upload', handleFileUpload);");
expect(content).toContain("app.post('/api/user/upload', handleFileUpload);");
expect(content).toContain("app.post('/api/upload', rateLimit({ name: 'upload'");
expect(content).toContain('handleFileUpload);');
});
it('文件上传配置必须限制 10MB 与合法图片格式过滤', () => {