From 25d8474877e52001fd2839f2cb69a766d40bbc44 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Mon, 19 Oct 2026 13:50:51 +0800 Subject: [PATCH] =?UTF-8?q?security:=20=E6=96=B0=E5=A2=9E=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E9=99=90=E6=B5=81=20+=205xx=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=84=B1=E6=95=8F=20+=20=E5=85=9C=E5=BA=95=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 内存级限流器(令牌桶):/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 - 文档补充限流与错误脱敏说明 --- docs/部署验收与1分钱支付闭环.md | 6 +- server/src/index.ts | 94 ++++++++++++++++++++++++++---- server/tests/ratelimit-5xx.spec.ts | 34 +++++++++++ server/tests/upload.spec.ts | 4 +- 4 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 server/tests/ratelimit-5xx.spec.ts diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index be9e65d..573d8ba 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -147,8 +147,12 @@ MYSQL_DATABASE=peiwan - `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。 6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。 -## 八、内容安全(可选) +### 限流与错误信息(已内置,无需额外配置) +- **全局限流**:`/api` 每 IP 600 次/分钟;**写接口单独限流**:发帖 10/分、评论 20/分、组局申请 20/分、举报 5/分、上传 30/分、下单 20/分、支付下单 30/分、充值/提现 10/分、浏览 1 次/3 秒(同 IP+动态)。超限返回 HTTP `429` + `Retry-After`。 +- **5xx 脱敏**:服务端 5xx 只记录日志,对外统一返回「服务器内部错误,请稍后重试」,不再回传 SQL/堆栈等内部细节。 +- 限流为**单实例内存级**;多实例部署时如需跨实例统一限流,需改用 Redis 计数(可选)。 +## 八、内容安全(可选) 后台填入真实小程序 `appId` 与 32 位 `appSecret` 后,内容检测会真实调用微信 `msgSecCheck`;未配置时自动跳过远端、仅跑本地敏感词库(fail-open,不影响业务)。 ## 九、回滚 diff --git a/server/src/index.ts b/server/src/index.ts index 6ab8183..b006812 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(); +// 周期性清理过期桶,避免内存无界增长 +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(); diff --git a/server/tests/ratelimit-5xx.spec.ts b/server/tests/ratelimit-5xx.spec.ts new file mode 100644 index 0000000..5b2d790 --- /dev/null +++ b/server/tests/ratelimit-5xx.spec.ts @@ -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\''); + }); +}); diff --git a/server/tests/upload.spec.ts b/server/tests/upload.spec.ts index 3fee9e2..c3be000 100644 --- a/server/tests/upload.spec.ts +++ b/server/tests/upload.spec.ts @@ -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 与合法图片格式过滤', () => {