security: 后台默认口令生产 fail-closed(修复 admin/admin 可登录的高危漏洞)
- 生产环境未配置 ADMIN_PASSWORD(或仍为 admin/admin123)时,后台登录直接 503 拒绝 - 启动自检新增两条告警:未配有效 ADMIN_PASSWORD、非 production 身份校验放宽 - 部署文档补充 ADMIN_PASSWORD/ADMIN_USERNAME 必配说明 - 新增回归护栏 admin-credential-hardening.spec.ts(88 套件/642 用例全绿);冒烟 5/5
这个提交包含在:
@@ -14,6 +14,10 @@ PORT=3000
|
||||
ADMIN_TOKEN_SECRET=<强随机长串>
|
||||
USER_TOKEN_SECRET=<另一个强随机长串>
|
||||
|
||||
# 后台登录口令(生产必配;未配置或仍为 admin/admin123 时后台登录被禁用)
|
||||
ADMIN_USERNAME=<后台账号,默认 admin>
|
||||
ADMIN_PASSWORD=<强口令;多个可用 | 分隔>
|
||||
|
||||
# 数据库
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
|
||||
@@ -180,7 +180,10 @@ const ADMIN_TOKEN_SECRET = (() => {
|
||||
return DEFAULT_ADMIN_TOKEN_SECRET;
|
||||
})();
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
const ADMIN_PASSWORDS = (process.env.ADMIN_PASSWORD || 'admin|admin123').split('|').filter(Boolean);
|
||||
const ADMIN_PASSWORD_SET = String(process.env.ADMIN_PASSWORD || '').trim();
|
||||
const ADMIN_PASSWORDS = (ADMIN_PASSWORD_SET || 'admin|admin123').split('|').filter(Boolean);
|
||||
// 生产环境安全闸门:未配置 ADMIN_PASSWORD(或仍使用内置默认口令)时,禁止后台登录,避免默认口令被爆破
|
||||
const ADMIN_PASSWORD_CONFIGURED = Boolean(ADMIN_PASSWORD_SET) && !/^(admin|admin123)$/i.test(ADMIN_PASSWORD_SET);
|
||||
|
||||
function signAdminToken(): string {
|
||||
const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now() })).toString('base64url');
|
||||
@@ -376,6 +379,14 @@ 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(或仍为默认口令),已禁用后台登录'));
|
||||
return res.status(503).json({
|
||||
code: 50000,
|
||||
message: '生产环境未配置后台登录口令:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
|
||||
});
|
||||
}
|
||||
// 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖
|
||||
const passwordOk = ADMIN_PASSWORDS.some((p) => safeStringEqual(String(password), p));
|
||||
if (safeStringEqual(String(username), ADMIN_USERNAME) && passwordOk) {
|
||||
@@ -4497,6 +4508,16 @@ 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):生产环境后台登录已被禁用,请尽快设置后重启。');
|
||||
}
|
||||
|
||||
// 非生产环境身份校验放宽(允许客户端声明身份),禁止用于公网部署
|
||||
if (!isProd) {
|
||||
console.warn('[Peiwan Config][WARN] 当前非 production:用户身份校验已放宽(未登录请求将采用客户端声明的 userId),仅限本地联调,切勿用于公网部署。');
|
||||
}
|
||||
|
||||
const cfg: any = await resolveWechatPayRuntimeConfig();
|
||||
const missing: string[] = [];
|
||||
if (!cfg.appId) missing.push('appId');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第七十二批回归闸门:后台默认口令生产 fail-closed。
|
||||
* 背景:ADMIN_PASSWORD 未配置时默认 admin/admin123,公网部署等于开放后台。
|
||||
*/
|
||||
describe('第七十二批后台凭证闸门', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
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');
|
||||
|
||||
it('生产未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed)', () => {
|
||||
expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED');
|
||||
expect(indexCode).toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)');
|
||||
expect(indexCode).toContain('生产环境未配置后台登录口令');
|
||||
});
|
||||
|
||||
it('启动自检提示默认口令与非生产放宽风险', () => {
|
||||
expect(indexCode).toContain('未设置有效的 ADMIN_PASSWORD');
|
||||
expect(indexCode).toContain('用户身份校验已放宽');
|
||||
});
|
||||
|
||||
it('部署文档包含 ADMIN_PASSWORD', () => {
|
||||
expect(doc).toContain('ADMIN_PASSWORD');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户