security: 移除硬编码微信小程序 appId/appSecret,改为只读配置+缺失即失败

- 后端不再兜底内置 appId/appSecret,缺失时 jscode2session/手机号/支付/内容审核等接口明确拒绝(fail-closed)
- 建表种子改为空值;启动自愈由「写入占位值」改为「清空历史占位值」,杜绝误用非本小程序 appId(40037 根因)
- 后台/引导文案去除具体 AppID;文档补充 WX_APP_ID/WX_APP_SECRET 说明
- 新增回归护栏 wechat-appid-config-hardening.spec.ts(87 套件/636 用例全绿);HTTP 冒烟 4/4 验证缺失即失败
这个提交包含在:
xcbyy
2026-10-19 10:33:31 +08:00
父节点 584e526bca
当前提交 01823a1db4
共有 6 个文件被更改,包括 77 次插入 和 28 次删除

查看文件

@@ -150,7 +150,7 @@
<a-col :span="12">
<a-form-item
label="微信小程序 AppID (appId)"
help="微信公众平台分配的小程序 AppID,如 wx0c929f7e54024e67"
help="微信公众平台分配的小程序 AppID,例如 wx1234567890abcdef"
>
<a-input v-model="wxPayForm.appId" placeholder="请输入 wx 开头的小程序 AppID" allow-clear />
</a-form-item>

查看文件

@@ -796,7 +796,7 @@
</a-col>
<a-col :span="12">
<a-form-item label="微信小程序 AppID (appId)">
<a-input v-model="wxPayForm.valueJson.appId" placeholder="例如:wx0c929f7e54024e67" allow-clear />
<a-input v-model="wxPayForm.valueJson.appId" placeholder="例如:wx1234567890abcdef" allow-clear />
</a-form-item>
</a-col>
</a-row>
@@ -974,7 +974,7 @@
<a-alert type="info" style="margin-bottom: 20px">
在此维护微信小程序的地图定位服务参数(腾讯位置服务 Tencent LBS Key)。用于同城组局发布时的地图选点(chooseLocation)、经纬度逆地址解析及附近搭子/活动测距。
<div style="margin-top: 6px; font-size: 12px; color: var(--color-text-3);">
配置指引:登录腾讯位置服务控制台 (lbs.qq.com) -> 创建应用并添加 Key -> 勾选 WebServiceAPI 与微信小程序授权(填入小程序 AppID: wx0c929f7e54024e67)。
配置指引:登录腾讯位置服务控制台 (lbs.qq.com) -> 创建应用并添加 Key -> 勾选 WebServiceAPI 与微信小程序授权(填入你的小程序 AppID)。
</div>
</a-alert>
@@ -1002,7 +1002,7 @@
allow-clear
/>
<template #extra>
需在腾讯位置服务后台开通 WebServiceAPI,并授权绑定小程序 AppID: <strong>wx0c929f7e54024e67</strong>。
需在腾讯位置服务后台开通 WebServiceAPI,并授权绑定小程序 AppID。
</template>
</a-form-item>
</a-col>
@@ -1504,7 +1504,7 @@ const mapLocationForm = reactive({
enableChooseLocation: 1,
enableDistanceCalculation: 1,
defaultCity: '北京市',
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)'
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序)'
}
});

查看文件

@@ -28,6 +28,11 @@ MYSQL_DATABASE=peiwan
# 可选:跨域白名单(逗号分隔;默认仅同源可用,小程序/服务端调用不受影响)
# CORS_ORIGINS=https://admin.example.com,https://h5.example.com
# 微信小程序凭证(无内置兜底值:不配则"微信登录换 openid / 内容审核 / 订阅消息 / 支付"相关接口直接拒绝)
# 也可在后台「平台配置 → 微信支付商户配置」填写(后台优先于环境变量)
# WX_APP_ID=wx****************
# WX_APP_SECRET=<32位小程序 AppSecret>
# 可选:微信(若已在后台"微信支付配置"页填写,则无需在这里重复;后台配置优先)
# WX_APP_ID=...
# WX_APP_SECRET=...

查看文件

@@ -1034,8 +1034,8 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
const wechatPayConfigVal = JSON.stringify({
mode: 'AUTO',
appId: 'wx0c929f7e54024e67',
appSecret: '63a412f3f73af8e73f6923943bf9683c',
appId: '',
appSecret: '',
mchId: '',
apiV3Key: '',
serialNo: '',
@@ -1055,7 +1055,7 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
enableChooseLocation: 1,
enableDistanceCalculation: 1,
defaultCity: '北京市',
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)'
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序)'
});
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
@@ -1197,12 +1197,14 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
}
if (curVal && typeof curVal === 'object') {
let updated = false;
if (!curVal.appId || curVal.appId === 'wx_peiwan_miniapp') {
curVal.appId = 'wx0c929f7e54024e67';
// 历史版本曾在源码/DB 中内置占位 appId/appSecret,这里一次性清空,
// 避免后端误用非本小程序的凭证(如订阅消息 40037、支付参数错乱)。
if (curVal.appId === 'wx0c929f7e54024e67' || curVal.appId === 'wx_peiwan_miniapp') {
curVal.appId = '';
updated = true;
}
if (!curVal.appSecret) {
curVal.appSecret = '63a412f3f73af8e73f6923943bf9683c';
if (curVal.appSecret === '63a412f3f73af8e73f6923943bf9683c') {
curVal.appSecret = '';
updated = true;
}
if (updated) {

查看文件

@@ -1922,8 +1922,8 @@ async function resolveWechatPayRuntimeConfig(overrideCfg?: any) {
const merged = {
mode: 'AUTO',
appId: process.env.WX_APP_ID || 'wx0c929f7e54024e67',
appSecret: process.env.WX_APP_SECRET || '63a412f3f73af8e73f6923943bf9683c',
appId: process.env.WX_APP_ID || '',
appSecret: process.env.WX_APP_SECRET || '',
mchId: process.env.WX_MCH_ID || '',
apiV3Key: process.env.WX_API_V3_KEY || '',
serialNo: process.env.WX_SERIAL_NO || '',
@@ -2211,11 +2211,11 @@ app.post('/api/wechat/code2session', async (req: Request, res: Response) => {
}
const cfg = await resolveWechatPayRuntimeConfig();
const appId = cfg.appId || 'wx0c929f7e54024e67';
const appSecret = cfg.appSecret || '63a412f3f73af8e73f6923943bf9683c';
const appId = String(cfg.appId || '').trim();
const appSecret = String(cfg.appSecret || '').trim();
if (!appSecret) {
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppSecret' });
if (!appId || !appSecret) {
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID/AppSecret,无法换取 openid' });
}
const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`;
@@ -2405,17 +2405,15 @@ setWeChatRefundFn(async (orderId, refundCents, totalCents, reason, outRefundNo)
// 注册微信官方内容安全 msgSecCheck(v2) 真实检测器:
// 本地词库先做即时拦截,本地放行后再交由微信官方机器审核做第二层真实防御。
// 未配置真实小程序凭证(仍为内置占位值)或显式关闭时,自动跳过远端并降级本地词库。
// 未配置真实小程序凭证时(appId/appSecret 为空),自动跳过远端并降级本地词库。
contentSecurityService.setRemoteChecker(async (text: string, openid?: string, scene?: number) => {
try {
const cfg = await resolveWechatPayRuntimeConfig();
const appId = String(cfg.appId || '').trim();
const appSecret = String(cfg.appSecret || '').trim();
const PLACEHOLDER_APP_ID = 'wx0c929f7e54024e67';
const PLACEHOLDER_APP_SECRET = '63a412f3f73af8e73f6923943bf9683c';
const forced = process.env.WX_CONTENT_SEC_CHECK === '1';
// 仅当显式配置了真实小程序凭证时才调用官方内容审核(不再有内置占位值兜底)
if (!appId || !appSecret) return null;
if (!forced && (appId === PLACEHOLDER_APP_ID || appSecret === PLACEHOLDER_APP_SECRET)) return null;
const accessToken = await getWechatAccessToken(appId, appSecret);
if (!accessToken) return null;
@@ -2472,11 +2470,11 @@ app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => {
}
const cfg = await resolveWechatPayRuntimeConfig();
const appId = cfg.appId || 'wx0c929f7e54024e67';
const appSecret = cfg.appSecret || '63a412f3f73af8e73f6923943bf9683c';
const appId = String(cfg.appId || '').trim();
const appSecret = String(cfg.appSecret || '').trim();
if (!appSecret) {
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppSecret' });
if (!appId || !appSecret) {
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID/AppSecret' });
}
// 1. 若前端传递了 wxCode,顺便获取当前用户的真实 openid
@@ -2607,7 +2605,10 @@ app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => {
}
const cfg = await resolveWechatPayRuntimeConfig();
const appId = cfg.appId || 'wx0c929f7e54024e67';
const appId = String(cfg.appId || '').trim();
if (!appId) {
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID(后台「微信支付商户配置」或环境变量 WX_APP_ID),无法发起微信支付' });
}
const outTradeNo = orderId ? String(orderId) : `WX${scene === 'RECHARGE' ? 'R' : 'O'}${Date.now()}${uid}`;
const desc = title || (scene === 'RECHARGE' ? '钱包余额充值' : '同城陪伴担保订单');

查看文件

@@ -0,0 +1,41 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第七十一批回归闸门:移除硬编码微信小程序 appId/appSecret,改为"只读配置 + 缺失即失败"。
* 背景:源码/DB 内置占位凭证会被后端静默使用,导致 appId 与建模板的小程序不一致(订阅消息 40037)。
*/
describe('第七十一批小程序凭证去硬编码闸门', () => {
const root = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
const mysqlCode = fs.readFileSync(path.join(root, 'server/src/db/mysql.ts'), 'utf-8');
const adminCode = fs.readFileSync(
path.join(root, 'admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue'),
'utf-8'
);
it('后端不再有硬编码 appId/appSecret 兜底', () => {
expect(indexCode).not.toContain("|| 'wx0c929f7e54024e67'");
expect(indexCode).not.toContain("|| '63a412f3f73af8e73f6923943bf9683c'");
expect(indexCode).toContain("appId: process.env.WX_APP_ID || ''");
expect(indexCode).toContain("appSecret: process.env.WX_APP_SECRET || ''");
});
it('缺失凭证时按接口 fail-closed 并给出明确提示', () => {
expect(indexCode).toContain('服务端未配置小程序 AppID/AppSecret,无法换取 openid');
expect(indexCode).toContain('服务端未配置小程序 AppID(后台「微信支付商户配置」或环境变量 WX_APP_ID)');
});
it('建表种子为空值,并清理历史遗留的占位凭证', () => {
expect(mysqlCode).toContain('appId: \'\',\n appSecret: \'\',');
// 自愈逻辑由"写入占位值"改为"清空占位值"
expect(mysqlCode).toContain("if (curVal.appId === 'wx0c929f7e54024e67'");
expect(mysqlCode).toContain("if (curVal.appSecret === '63a412f3f73af8e73f6923943bf9683c')");
expect(mysqlCode).toContain('curVal.appId = \'\'');
});
it('后台文案不再暴露具体 AppID', () => {
expect(adminCode).not.toContain('wx0c929f7e54024e67');
});
});