security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9)
共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。
本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」,
但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。
D6 · APP_KEYS 派生兜底(原 config.default.js:157)
原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开
⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口:
· scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys()
(① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底)
· 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值)
· scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移)
D7 · 生产弱口令兜底(原 config.default.js:176)
原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产
实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 /
显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。
修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的
app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。
D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217)
原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把
localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、
不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。
且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。
修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。
配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 ——
「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。
D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv)
非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留
JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有
**生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界
实际被打通,属**安全属性**缺陷。
修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由
scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret()
同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端
验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。
行为等价性(生产逐字不变,已逐项实测):
· D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变
· D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达
· D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变
验证:
· 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产
a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null
· 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功,
/api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS)
· D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401**
· 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200
· 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17);
verify_script_health.py H1–H4 PASS
This commit is contained in:
@@ -35,9 +35,25 @@ const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
||||
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
||||
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
|
||||
// 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||||
// 仍保留注入的键:EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||||
// ⚠️ 2026-10-12(R106-D9)修正:JWT_SECRET / APP_KEYS **不再**保留注入。
|
||||
// 原判「本地无副作用」不成立 —— 本地会拿到**生产签名密钥** ⇒ 跨环境会话可互相冒充。
|
||||
// 现已并入 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过,本地改由 scripts/.creds.local.json 供值。
|
||||
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
|
||||
|
||||
// 🔴 2026-10-12(R106-D9):非生产环境额外跳过的**签名密钥精确键**。
|
||||
// 此前注释写「仍保留注入的键:JWT_SECRET / APP_KEYS ... —— 本地无副作用」,该结论**不成立**:
|
||||
// · 本地 dev 于是直接继承 deploy/.env.production 里的**生产签名密钥** ⇒ 本地进程
|
||||
// 可签发**生产可接受**的 token(本地 token 能冒充生产用户,反之亦然),
|
||||
// 两套环境的会话边界实际上是打通的 —— 这是**安全属性**缺陷,不是"无副作用";
|
||||
// · 且它与 R105/R106 刚建立的纪律直接冲突:JWT_SECRET / APP_KEYS 已被要求
|
||||
// "缺凭据明确失败"(_secret.cjs),却仍允许本地静默拿到生产值 ⇒ 掩蔽注入故障。
|
||||
// 修法:与 DB_/REDIS_/ALIYUN_/SMS_ 同向收口,用**精确键**匹配(⛔ 不用前缀,
|
||||
// 避免误伤 APP_KEYS_* 之类的无关键)。非生产侧改由 scripts/.creds.local.json 供值
|
||||
// (_secret.cjs 的第 2 级),与 Python 侧 _fixtures.resolve_jwt_secret() 同源。
|
||||
// 生产(isProdEnv=true)**不跳过** ⇒ 本项零影响(生产本就由 systemd 注入)。
|
||||
const DEPLOY_ENV_LOCAL_SKIP_KEYS = new Set(['JWT_SECRET', 'APP_KEYS']);
|
||||
|
||||
function loadDeployEnv(appInfo) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
@@ -94,6 +110,13 @@ function loadDeployEnv(appInfo) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
// 非生产环境:跳过**签名密钥精确键**(JWT_SECRET / APP_KEYS)—— 避免本地进程持有
|
||||
// 生产签名密钥(跨环境会话可互相冒充),并让本地改由 scripts/.creds.local.json 供值,
|
||||
// 与 Python 侧 _fixtures.resolve_jwt_secret() 同源。见 DEPLOY_ENV_LOCAL_SKIP_KEYS(R106-D9)。
|
||||
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_KEYS.has(key)) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = val;
|
||||
}
|
||||
loadedFrom = file;
|
||||
@@ -149,13 +172,28 @@ module.exports = (appInfo) => {
|
||||
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
|
||||
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
|
||||
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
|
||||
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
|
||||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
|
||||
// 🔴 2026-10-12(R106-D7):**生产环境取消弱口令兜底**。
|
||||
// 原实现无条件 `|| '123456'`,而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD
|
||||
// 一直是**注释状态**(该文件 L88)⇒ 生产实际取到的就是弱口令 '123456'。
|
||||
// 一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入),
|
||||
// 一批账号会被设为 '123456' —— 这是**静默弱口令**,而非"注入链路断了"的明确失败,
|
||||
// 与 _secret.cjs / DB_PASSWORD 的既有纪律不一致(R104 结论:同族问题各自实现必然漂移)。
|
||||
// 现约定:生产(config.isProduction)未显式注入 ⇒ 置 null,由使用点
|
||||
// app/lib/passwordPolicy.requireTestPassword() 明确失败(⛔ 不静默回退)。
|
||||
// 测试环境语义**逐字不变**(仍 '123456',既有测试前置脚本与门禁不受影响)。
|
||||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD
|
||||
|| (config.isProduction ? null : '123456');
|
||||
|
||||
// Cookie 签名密钥:优先读取环境变量 APP_KEYS,缺省时回退到内置值(仅供本地开发)
|
||||
// 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】
|
||||
const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
|
||||
// Cookie 签名密钥(Egg `config.keys`):唯一来源见 backend/scripts/_secret.cjs;⛔ 无兜底。
|
||||
// 🔴 2026-10-12(R106-D6):原实现为
|
||||
// const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||
// config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
|
||||
// —— 「派生兜底」同样是**可预测公开值**(由 appInfo.name 可推算),语义等价于
|
||||
// 「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。与 JWT_SECRET(R105-D1)
|
||||
// 同治法收敛到唯一解析入口:① env APP_KEYS → ② scripts/.creds.local.json:app_keys
|
||||
// → ③ 明确失败(⛔ 刻意不提供明文/派生兜底)。
|
||||
const { loadAppKeys } = require('../scripts/_secret.cjs');
|
||||
config.keys = loadAppKeys();
|
||||
|
||||
// add your config here
|
||||
config.middleware = ['concurrencyGuard'];
|
||||
@@ -214,7 +252,16 @@ module.exports = (appInfo) => {
|
||||
config.sequelize = {
|
||||
dialect: 'mysql',
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
// 🔴 2026-10-12(R106-D8)修复存量缺陷:兜底 host 由字面量 'localhost' 改为 '127.0.0.1'。
|
||||
// 根因(实测):Node ≥ 18 的 DNS 结果顺序默认 `verbatim`(不再强制 IPv4 优先),
|
||||
// 而 'localhost' 在本机通常同时解析出 ::1 与 127.0.0.1 ⇒ 可能先连 IPv6 `::1`;
|
||||
// 本机 MariaDB 默认 `bind-address` 只监听 IPv4 ⇒ 连接落到 ::1 后**建连超时
|
||||
// (ETIMEDOUT)**,既不报 1045 也不报 ECONNREFUSED,现象是「后端启动卡在 DB
|
||||
// 连接」——极易被误判为「服务没起来 / 端口被占」,而真正的故障点是地址族。
|
||||
// 取证一致性问题:本仓 `scripts/_db.cjs:43` 的兜底早已是 '127.0.0.1',唯独此处
|
||||
// 是 'localhost' ⇒ 同一子系统两套默认值,必然漂移。收敛到 IPv4 字面量。
|
||||
// 影响面:生产 `.env.production` 本就显式注入 DB_HOST ⇒ 逐字不变;仅影响兜底路径。
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
||||
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
||||
@@ -462,7 +509,11 @@ module.exports = (appInfo) => {
|
||||
// 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置
|
||||
// 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法)
|
||||
// 再做「是否等于内置值」的判断。
|
||||
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
|
||||
// 🔴 2026-10-12(R106-D6):APP_KEYS 同 JWT_SECRET —— 内置**派生兜底常量**
|
||||
// (appInfo.name + '_competition_website_secret_key_2026')已移除,解析统一走
|
||||
// backend/scripts/_secret.cjs;密钥缺失时 loadAppKeys() 在配置加载期即抛错
|
||||
// ⇒ 任何环境都不可能"静默使用可预测的 Cookie 签名密钥",本守卫无需(也无法)
|
||||
// 再做「是否等于内置值」的判断。
|
||||
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
|
||||
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
||||
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
||||
|
||||
Reference in New Issue
Block a user