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 - 文档补充限流与错误脱敏说明
这个提交包含在:
@@ -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 与合法图片格式过滤', () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户