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 次删除

查看文件

@@ -14,10 +14,16 @@ PORT=3000
ADMIN_TOKEN_SECRET=<强随机长串> ADMIN_TOKEN_SECRET=<强随机长串>
USER_TOKEN_SECRET=<另一个强随机长串> USER_TOKEN_SECRET=<另一个强随机长串>
# 后台登录口令(生产必配;未配置或仍为 admin/admin123 时后台登录被禁用) # 后台登录口令(必配;未配置或仍为 admin/admin123 时后台登录被禁用)
ADMIN_USERNAME=<后台账号,默认 admin> ADMIN_USERNAME=<后台账号,默认 admin>
ADMIN_PASSWORD=<强口令;多个可用 | 分隔> ADMIN_PASSWORD=<强口令;多个可用 | 分隔>
# 安全闸门不再依赖 NODE_ENV:上传/内容检测/核销归属/提现打款等默认按生产口径收紧,
# 只有本地联调显式设置 ALLOW_INSECURE_DEV_AUTH=1 才放开;沙箱支付需显式 ALLOW_SANDBOX_PAY=1。
#
# 订阅消息跳转版本:正式发布后请设置为 formal,否则真实用户收不到订阅消息(默认随 NODE_ENV 推导)
MINIPROGRAM_STATE=formal
# 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌) # 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌)
# ADMIN_TOKEN_TTL_HOURS=24 # ADMIN_TOKEN_TTL_HOURS=24

查看文件

@@ -451,7 +451,7 @@ export async function universalVerifyCheckin(code, operatorId) {
*/ */
export async function getPosts(category, userId) { export async function getPosts(category, userId) {
try { try {
const uid = userId || uni.getStorageSync('userId') || 1001; const uid = normalizeUserId(userId);
let query = `?userId=${uid}`; let query = `?userId=${uid}`;
if (category && category !== 'all') { if (category && category !== 'all') {
query += `&category=${encodeURIComponent(category)}`; query += `&category=${encodeURIComponent(category)}`;
@@ -519,7 +519,7 @@ export async function createPost(payload) {
*/ */
export async function likePost(id, action, userId) { export async function likePost(id, action, userId) {
try { try {
const uid = userId || uni.getStorageSync('userId') || 1001; const uid = normalizeUserId(userId);
const payload = { userId: uid }; const payload = { userId: uid };
if (action) { if (action) {
payload.action = action; payload.action = action;
@@ -1437,16 +1437,12 @@ export async function requestWechatPayment({ amount, title = '同城陪伴担保
userId: uid, userId: uid,
wxCode wxCode
}); });
} catch (_) { } catch (err) {
prepayData = { // 预支付失败必须向上抛出:绝不能伪造"沙箱支付成功",否则用户以为付款成功、订单却未支付。
mode: 'SANDBOX', const e = new Error((err && err.message) || '微信支付发起失败,请稍后重试');
timeStamp: String(Math.floor(Date.now() / 1000)), e.code = err && err.code;
nonceStr: `pw_${Date.now()}`, e.statusCode = err && err.statusCode;
package: `prepay_id=wx${Date.now()}`, throw e;
signType: 'RSA',
paySign: 'SANDBOX_FALLBACK',
outTradeNo: orderId || `WX_${Date.now()}`
};
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

查看文件

@@ -18,8 +18,12 @@ const BASE_URL = (() => {
*/ */
// 登录过期提示仅一次,避免并发请求重复弹窗 // 登录过期提示仅一次,避免并发请求重复弹窗
let authExpiredNotified = false; let authExpiredNotified = false;
function handleAuthExpired() { function handleAuthExpired(hadSession = false) {
let hadLocalAuth = Boolean(hadSession);
try { try {
if (!hadLocalAuth) {
hadLocalAuth = Boolean(uni.getStorageSync('userToken') || uni.getStorageSync('userId'));
}
uni.removeStorageSync('userToken'); uni.removeStorageSync('userToken');
uni.removeStorageSync('userId'); uni.removeStorageSync('userId');
uni.removeStorageSync('userInfo'); uni.removeStorageSync('userInfo');
@@ -27,7 +31,8 @@ function handleAuthExpired() {
} catch (_) { } catch (_) {
// 忽略本地存储清理异常 // 忽略本地存储清理异常
} }
if (!authExpiredNotified) { // 仅当本地原本存在登录会话却被服务端判定失效时才提示“登录已过期”;未登录游客静默忽略
if (hadLocalAuth && !authExpiredNotified) {
authExpiredNotified = true; authExpiredNotified = true;
try { try {
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' }); uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' });
@@ -46,8 +51,10 @@ export function request(options = {}) {
const method = (options.method || 'GET').toUpperCase(); const method = (options.method || 'GET').toUpperCase();
let sessionToken = ''; let sessionToken = '';
let hadSession = false;
try { try {
sessionToken = uni.getStorageSync('userToken') || ''; sessionToken = uni.getStorageSync('userToken') || '';
hadSession = Boolean(sessionToken || uni.getStorageSync('userId'));
} catch (_) { } catch (_) {
sessionToken = ''; sessionToken = '';
} }
@@ -69,13 +76,14 @@ export function request(options = {}) {
// 适配标准的业务封装 { code: 20000, data: ... } // 适配标准的业务封装 { code: 20000, data: ... }
if (res.data && typeof res.data === 'object' && 'code' in res.data) { if (res.data && typeof res.data === 'object' && 'code' in res.data) {
if (res.data.code === 20000) { if (res.data.code === 20000) {
if (sessionToken) authExpiredNotified = false;
resolve(res.data.data); resolve(res.data.data);
} else { } else {
const bizErr = new Error(res.data.message || '业务请求失败'); const bizErr = new Error(res.data.message || '业务请求失败');
bizErr.code = res.data.code; bizErr.code = res.data.code;
bizErr.statusCode = res.statusCode; bizErr.statusCode = res.statusCode;
if (res.data.code === 40100) handleAuthExpired(); if (res.data.code === 40100) handleAuthExpired(hadSession);
else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired(); else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired(hadSession);
reject(bizErr); reject(bizErr);
} }
} else { } else {
@@ -86,8 +94,8 @@ export function request(options = {}) {
const httpErr = new Error(errMsg); const httpErr = new Error(errMsg);
httpErr.statusCode = res.statusCode; httpErr.statusCode = res.statusCode;
httpErr.code = (res.data && res.data.code) || res.statusCode; httpErr.code = (res.data && res.data.code) || res.statusCode;
if (res.statusCode === 401) handleAuthExpired(); if (res.statusCode === 401) handleAuthExpired(hadSession);
else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired(); else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired(hadSession);
reject(httpErr); reject(httpErr);
} }
}, },

查看文件

@@ -427,8 +427,10 @@ const syncCurrentUserProfile = async () => {
...currentUserProfile.value, ...currentUserProfile.value,
...cached ...cached
}; };
const storedUid = Number(uni.getStorageSync('userId'));
if (!storedUid) return currentUserProfile.value;
try { try {
const remote = await getUserProfile(cached.id || 1001); const remote = await getUserProfile(storedUid);
if (remote && remote.nickname) { if (remote && remote.nickname) {
const merged = { const merged = {
...cached, ...cached,

查看文件

@@ -368,7 +368,8 @@ import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscri
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null; const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null;
const isLoggedIn = ref(Boolean(initialUserId)); const initialLoggedOut = typeof uni !== 'undefined' ? (uni.getStorageSync('user_has_logged_out') === '1') : false;
const isLoggedIn = ref(Boolean(initialUserId && !initialLoggedOut));
const showLoginModal = ref(false); const showLoginModal = ref(false);
// 我的收藏与我的举报反馈 // 我的收藏与我的举报反馈
@@ -377,7 +378,12 @@ const loadingFavorites = ref(false);
const myReportsList = ref([]); const myReportsList = ref([]);
const loadMyReports = async () => { const loadMyReports = async () => {
const uid = user.value.id || uni.getStorageSync('userId') || 1001; if (!isLoggedIn.value) {
myReportsList.value = [];
return;
}
const uid = user.value.id || uni.getStorageSync('userId');
if (!uid) return;
try { try {
const list = await getMyReports(uid); const list = await getMyReports(uid);
if (Array.isArray(list)) { if (Array.isArray(list)) {
@@ -409,7 +415,11 @@ const openCoupons = () => {
}; };
const loadCouponUsable = async () => { const loadCouponUsable = async () => {
const uid = uni.getStorageSync('userId') || 0; if (!isLoggedIn.value) {
couponUsable.value = 0;
return;
}
const uid = user.value.id || uni.getStorageSync('userId') || 0;
if (!uid) return; if (!uid) return;
try { try {
const res = await getMyCoupons(uid, 'UNUSED'); const res = await getMyCoupons(uid, 'UNUSED');
@@ -663,7 +673,9 @@ onMounted(() => {
uni.$on('login-success', onLoginSuccess); uni.$on('login-success', onLoginSuccess);
loadUserProfile(); loadUserProfile();
loadPlatformConfigs(); loadPlatformConfigs();
loadMyReports(); if (isLoggedIn.value) {
loadMyReports();
}
// 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势) // 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势)
preloadSubscribeTemplates(); preloadSubscribeTemplates();
}); });
@@ -678,11 +690,16 @@ onShow(() => {
loadPlatformConfigs(); loadPlatformConfigs();
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
loadNoticeUnread();
loadCouponUsable();
if (isLoggedIn.value) { if (isLoggedIn.value) {
loadNoticeUnread();
loadCouponUsable();
loadFavorites(); loadFavorites();
loadMyReports(); loadMyReports();
} else {
noticeUnread.value = 0;
couponUsable.value = 0;
favoriteList.value = [];
myReportsList.value = [];
} }
}); });
@@ -1030,9 +1047,15 @@ const openFavoritesModal = () => {
}; };
const loadFavorites = async () => { const loadFavorites = async () => {
if (!isLoggedIn.value) {
favoriteList.value = [];
return;
}
const uid = user.value.id || uni.getStorageSync('userId');
if (!uid) return;
loadingFavorites.value = true; loadingFavorites.value = true;
try { try {
const list = await getFavorites(user.value.id || 1001); const list = await getFavorites(uid);
favoriteList.value = Array.isArray(list) ? list : []; favoriteList.value = Array.isArray(list) ? list : [];
} catch (_) { } catch (_) {
favoriteList.value = []; favoriteList.value = [];
@@ -1261,7 +1284,11 @@ const openNotifications = () => {
}; };
const loadNoticeUnread = async () => { const loadNoticeUnread = async () => {
const uid = uni.getStorageSync('userId') || 0; if (!isLoggedIn.value) {
noticeUnread.value = 0;
return;
}
const uid = user.value.id || uni.getStorageSync('userId') || 0;
if (!uid) return; if (!uid) return;
try { try {
const res = await getNotifications(uid, 1); const res = await getNotifications(uid, 1);

查看文件

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

查看文件

@@ -11,10 +11,10 @@ describe('第七十二批后台凭证闸门', () => {
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8'); const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
const doc = fs.readFileSync(path.join(root, 'docs/部署验收与1分钱支付闭环.md'), 'utf-8'); const doc = fs.readFileSync(path.join(root, 'docs/部署验收与1分钱支付闭环.md'), 'utf-8');
it('生产未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed)', () => { it('未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed,不依赖 NODE_ENV)', () => {
expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED'); expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED');
expect(indexCode).toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)'); expect(indexCode).toContain('if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED)');
expect(indexCode).toContain('生产环境未配置后台登录口令'); expect(indexCode).toContain('后台登录口令未配置');
}); });
it('启动自检提示默认口令与非生产放宽风险', () => { it('启动自检提示默认口令与非生产放宽风险', () => {

查看文件

@@ -9,11 +9,11 @@ describe('第四十六批内容安全接口闸门', () => {
const rootDir = path.resolve(__dirname, '../../'); const rootDir = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
it('生产需登录且 openid 以会话用户为准', () => { it('需登录且 openid 以会话用户为准(安全闸门不依赖 NODE_ENV)', () => {
const start = indexCode.indexOf("app.post('/api/security/msg-sec-check'"); const start = indexCode.indexOf("app.post('/api/security/msg-sec-check'");
expect(start).toBeGreaterThan(-1); expect(start).toBeGreaterThan(-1);
const block = indexCode.slice(start, start + 1200); const block = indexCode.slice(start, start + 1200);
expect(block).toContain('IS_PRODUCTION'); expect(block).toContain('SECURE_MODE');
expect(block).toContain("'内容安全检测'"); expect(block).toContain("'内容安全检测'");
expect(block).toContain('await resolveUserOpenid(callerId)'); expect(block).toContain('await resolveUserOpenid(callerId)');
expect(block).not.toContain('const { content, openid, scene } = req.body'); expect(block).not.toContain('const { content, openid, scene } = req.body');

查看文件

@@ -20,7 +20,7 @@ describe('第二十二批登录安全闸门', () => {
it('登录路由:超限锁定 + 常量时间比较', () => { it('登录路由:超限锁定 + 常量时间比较', () => {
const start = indexCode.indexOf("app.post('/api/user/login'"); const start = indexCode.indexOf("app.post('/api/user/login'");
expect(start).toBeGreaterThan(-1); expect(start).toBeGreaterThan(-1);
const block = indexCode.slice(start, start + 1200); const block = indexCode.slice(start, start + 2000);
expect(block).toContain('isLoginLocked(loginKey)'); expect(block).toContain('isLoginLocked(loginKey)');
expect(block).toContain('登录失败次数过多'); expect(block).toContain('登录失败次数过多');
expect(block).toContain('recordLoginFailure(loginKey)'); expect(block).toContain('recordLoginFailure(loginKey)');

查看文件

@@ -57,7 +57,7 @@ describe('第七批安全加固闸门 (评价回复 / 服务上下架 / 通用
describe('3. 通用核销归属 (生产闸门)', () => { describe('3. 通用核销归属 (生产闸门)', () => {
it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => { it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => {
const block = routeBlock("app.post('/api/verify/universal'"); const block = routeBlock("app.post('/api/verify/universal'");
expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)'); expect(block).toContain('SECURE_MODE && !isVerifiedAdminRequest(req)');
expect(block).toContain("info.type === 'ACTIVITY'"); expect(block).toContain("info.type === 'ACTIVITY'");
expect(block).toContain('Number(info.creatorId) === operator'); expect(block).toContain('Number(info.creatorId) === operator');
expect(block).toContain('own!.companionIds.includes(operator)'); expect(block).toContain('own!.companionIds.includes(operator)');

查看文件

@@ -0,0 +1,62 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第九十三批:安全闸门与 NODE_ENV 解耦 + 支付/提现 fail-closed。
* 背景:线上以 NODE_ENV=development 运行,导致所有 `IS_PRODUCTION && ...` 闸门失效:
* - 后台可用默认口令 admin/admin123 登录;
* - 提现审核通过只改状态、不真实打款;
* - 4 位核销码可被任意登录用户枚举/核销;
* - 匿名可上传、可无限调用内容安全检测。
* 另外小程序在预支付失败时会伪造"沙箱支付成功",必须改为抛错。
*/
describe('第九十三批安全闸门解耦与支付 fail-closed', () => {
const root = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
const apiCode = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8');
const routeBlock = (marker: string, len = 3000) => {
const start = indexCode.indexOf(marker);
expect(start).toBeGreaterThan(-1);
return indexCode.slice(start, start + len);
};
it('安全闸门统一由 SECURE_MODE 控制,不依赖 NODE_ENV', () => {
expect(indexCode).toContain('const SECURE_MODE = !ALLOW_INSECURE_DEV_AUTH;');
expect(routeBlock("app.post('/api/security/msg-sec-check'")).toContain('if (SECURE_MODE)');
expect(indexCode).not.toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)');
expect(indexCode).not.toContain('if (IS_PRODUCTION) {\n try {\n resolveSecureUserId');
});
it('提现审核未开通真实转账时必须 503,禁止只改状态不打款', () => {
const block = routeBlock("app.post('/api/finance/withdrawals/:id/review'", 6000);
expect(block).not.toContain('IS_PRODUCTION && !ALLOW_SANDBOX_PAY');
expect(block).toContain('if (!ALLOW_SANDBOX_PAY)');
expect(block).toContain('微信商家转账未就绪');
});
it('预支付在未开启沙箱时拒绝下发沙箱参数', () => {
const block = routeBlock("app.post('/api/pay/wechat/prepay'", 12000);
const sandboxIdx = block.indexOf("mode: 'SANDBOX'");
expect(sandboxIdx).toBeGreaterThan(-1);
expect(block.slice(0, sandboxIdx)).toContain('if (!ALLOW_SANDBOX_PAY)');
});
it('订阅消息版本支持 MINIPROGRAM_STATE 显式配置', () => {
expect(indexCode).toContain('process.env.MINIPROGRAM_STATE');
expect(indexCode).toContain('const MINIPROGRAM_STATE = (');
expect(indexCode).not.toContain("miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer'");
});
it('小程序预支付失败必须抛错,不得伪造沙箱支付成功', () => {
expect(apiCode).not.toContain('SANDBOX_FALLBACK');
expect(apiCode).toContain('微信支付发起失败,请稍后重试');
expect(apiCode).toMatch(/catch \(err\) \{\n \/\/ 预支付失败必须向上抛出/);
});
it('动态列表与点赞的身份统一走 normalizeUserId', () => {
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n let query = `?userId=${uid}`;');
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n const payload = { userId: uid };');
});
});

查看文件

@@ -9,11 +9,11 @@ describe('第四十五批上传鉴权闸门', () => {
const rootDir = path.resolve(__dirname, '../../'); const rootDir = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
it('生产环境上传必须要求登录', () => { it('上传必须要求登录(安全闸门不依赖 NODE_ENV)', () => {
const start = indexCode.indexOf('const handleFileUpload ='); const start = indexCode.indexOf('const handleFileUpload =');
expect(start).toBeGreaterThan(-1); expect(start).toBeGreaterThan(-1);
const block = indexCode.slice(start, start + 500); const block = indexCode.slice(start, start + 500);
expect(block).toContain('IS_PRODUCTION'); expect(block).toContain('SECURE_MODE');
expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')"); expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')");
expect(block).toContain('请先登录后再上传'); expect(block).toContain('请先登录后再上传');
}); });

查看文件

@@ -21,7 +21,7 @@ describe('第十五批核销码与抢单池加固闸门', () => {
it('必须要求登录并按门票归属方校验', () => { it('必须要求登录并按门票归属方校验', () => {
const block = routeBlock("app.get('/api/verify/query/:code'"); const block = routeBlock("app.get('/api/verify/query/:code'");
expect(block).toContain("'查询核销码'"); expect(block).toContain("'查询核销码'");
expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)'); expect(block).toContain('SECURE_MODE && !isVerifiedAdminRequest(req)');
expect(block).toContain('无权查看该核销码信息'); expect(block).toContain('无权查看该核销码信息');
}); });
}); });