fix(security): 安全闸门与 NODE_ENV 解耦,支付/提现 fail-closed

- 新增 SECURE_MODE(仅 ALLOW_INSECURE_DEV_AUTH=1 时放开):后台登录口令、上传、内容检测、核销码归属全部改为默认收紧,修复线上以 NODE_ENV=development 运行时闸门失效

- 提现审核:未开通真实转账且未显式 ALLOW_SANDBOX_PAY=1 时一律 503,杜绝只改状态不打款

- 预支付:未开启沙箱时不再下发沙箱参数;小程序删除预支付失败伪造沙箱成功的兜底,改为抛错

- 订阅消息支持 MINIPROGRAM_STATE=formal|trial|developer 显式配置,避免真实用户收不到通知

- 动态列表/点赞身份统一 normalizeUserId;文档补充安全闸门与订阅消息环境变量

- 109 套件/720 用例全绿,tsc 构建通过
这个提交包含在:
xcbyy
2026-10-21 11:50:11 +08:00
父节点 87cb6a6374
当前提交 dd836b674e
共有 13 个文件被更改,包括 179 次插入 和 60 次删除

查看文件

@@ -499,12 +499,13 @@ app.post('/api/user/login', (req: Request, res: Response) => {
if (isLoginLocked(loginKey)) {
return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' });
}
// 生产环境 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录
if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED) {
logServerError('adminLogin', new Error('生产环境未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录'));
// 安全 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录。
// 不依赖 NODE_ENV(线上曾以 development 运行导致默认口令可直接登录)。
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
logServerError('adminLogin', new Error('未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录'));
return res.status(503).json({
code: 50000,
message: '生产环境未配置后台登录口令:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
message: '后台登录口令未配置:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
});
}
// 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖
@@ -593,8 +594,9 @@ app.all('/api/user/certification', (_req: Request, res: Response) => {
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
const handleFileUpload = (req: Request, res: Response) => {
// 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容)
if (IS_PRODUCTION) {
// 上传要求登录,避免匿名上传被滥用(刷盘/托管非法内容)。
// 不依赖 NODE_ENV:只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
if (SECURE_MODE) {
try {
resolveSecureUserId(req, 0, '上传文件');
} catch (e: any) {
@@ -653,8 +655,8 @@ app.post('/api/user/upload', rateLimit({ name: 'upload_user', windowMs: 60 * 100
app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => {
try {
let callerId = 0;
if (IS_PRODUCTION) {
// 生产环境要求登录,避免匿名滥用微信内容安全额度
if (SECURE_MODE) {
// 要求登录,避免匿名滥用微信内容安全额度
callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测');
}
const { content, scene } = req.body || {};
@@ -977,9 +979,9 @@ app.get('/api/verify/query/:code', async (req: Request, res: Response) => {
try {
const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码');
const info = await repo.queryCheckinCodeInfo(String(req.params.code));
// 生产环境:仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情,
// 防止对 4 位纯数字核销码做批量枚举探测。
if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) {
// 仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情,
// 防止对 4 位纯数字核销码做批量枚举探测(不依赖 NODE_ENV)。
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
let allowed = false;
if (info.type === 'ACTIVITY') {
allowed = Number(info.creatorId) === operator;
@@ -1004,8 +1006,8 @@ app.post('/api/verify/universal', async (req: Request, res: Response) => {
try {
const { verifyCode, operatorId } = req.body;
const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票');
// 生产环境校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销
if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) {
// 校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销(不依赖 NODE_ENV)
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
const info = await repo.queryCheckinCodeInfo(String(verifyCode));
let allowed = false;
if (info.type === 'ACTIVITY') {
@@ -2208,6 +2210,16 @@ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
// - ALLOW_SANDBOX_PAY=1:仅在联调时允许沙箱支付(无真实收款推进订单)
const ALLOW_INSECURE_DEV_AUTH = process.env.ALLOW_INSECURE_DEV_AUTH === '1';
const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1';
// 安全闸门不再依赖 NODE_ENV(线上曾以 NODE_ENV=development 运行导致闸门全部失效):
// 默认按生产口径收紧,只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
const SECURE_MODE = !ALLOW_INSECURE_DEV_AUTH;
// 订阅消息跳转版本:线上若以 NODE_ENV=development 运行,默认会落到 developer(真实用户收不到),
// 因此支持用 MINIPROGRAM_STATE=formal|trial|developer 显式指定。
const MINIPROGRAM_STATE = ((): 'formal' | 'trial' | 'developer' => {
const v = String(process.env.MINIPROGRAM_STATE || '').toLowerCase();
if (v === 'formal' || v === 'trial' || v === 'developer') return v;
return IS_PRODUCTION ? 'formal' : 'developer';
})();
/**
* 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试),
@@ -2579,7 +2591,7 @@ async function notifyBySubscribe(
template_id: templateId,
page: opts.page,
data: buildTemplateData(opts.data || {}),
miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer',
miniprogram_state: MINIPROGRAM_STATE,
});
if (result && result.errcode && result.errcode !== 0) {
logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]下发失败 ${result.errcode} ${result.errmsg}`));
@@ -2930,7 +2942,13 @@ app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 10
});
}
// SANDBOX 兜底(仅开发联调)
// SANDBOX 兜底(仅显式 ALLOW_SANDBOX_PAY=1 的联调环境)
if (!ALLOW_SANDBOX_PAY) {
return res.status(503).json({
code: 50001,
message: '微信支付未就绪:请在后台完成微信支付商户配置后重试'
});
}
res.json({
code: 20000,
data: {
@@ -3395,8 +3413,8 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' });
}
// 未开启真实打款:生产环境拒绝(避免"只改状态、不发钱"的误导);非生产保留沙箱仅改状态
if (IS_PRODUCTION && !ALLOW_SANDBOX_PAY) {
// 未开启真实打款:禁止"只改状态、不发钱"(不依赖 NODE_ENV,避免线上以 development 运行时静默放行)。
if (!ALLOW_SANDBOX_PAY) {
return res.status(503).json({
code: 50001,
message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品'
@@ -4109,7 +4127,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
template_id: templateId,
page,
data: buildTemplateData(payloadData),
miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer',
miniprogram_state: MINIPROGRAM_STATE,
});
const ok = !result || !result.errcode || result.errcode === 0;
return res.json({
@@ -4938,9 +4956,9 @@ async function logStartupConfigSelfCheck(): Promise<void> {
console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`);
}
// 后台登录口令闸门:生产未配置(或为默认口令)时后台登录被禁用
if (isProd && !ADMIN_PASSWORD_CONFIGURED) {
console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):生产环境后台登录已被禁用,请尽快设置后重启。');
// 后台登录口令闸门:未配置有效口令时后台登录被禁用(不再依赖 NODE_ENV)
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):后台登录已被禁用,请尽快设置后重启。');
}
// 危险兜底自检:仅在显式开启时提示(默认全部关闭 = fail-closed)
@@ -4951,7 +4969,7 @@ async function logStartupConfigSelfCheck(): Promise<void> {
console.warn('[Peiwan Config][WARN] 已开启 ALLOW_SANDBOX_PAY=1:允许沙箱支付(无真实收款推进订单),仅限联调环境!');
}
if (!isProd) {
console.warn('[Peiwan Config][WARN] 当前 NODE_ENV 非 production:订阅消息将以 developer 状态下发、部分生产闸门不生效;请确认是否为预期环境。');
console.warn(`[Peiwan Config] NODE_ENV 非 production(仅影响日志/订阅消息默认状态,安全闸门由 SECURE_MODE 控制);MINIPROGRAM_STATE=${MINIPROGRAM_STATE}。若已正式发布,请设置 MINIPROGRAM_STATE=formal 让真实用户收到订阅消息。`);
}
const cfg: any = await resolveWechatPayRuntimeConfig();