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:
@@ -10,6 +10,9 @@ const { sanitizeUploadFileName } = require('../utils/filename');
|
||||
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
|
||||
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
|
||||
const { sendFile } = require('../utils/sendFile');
|
||||
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
|
||||
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
|
||||
const { requireTestPassword } = require('../lib/passwordPolicy');
|
||||
|
||||
/**
|
||||
* 管理控制器 - AdminController
|
||||
@@ -1609,7 +1612,11 @@ class AdminController extends Controller {
|
||||
// 因此测试人员需先重新走「短信激活」,激活后再以该口令登录重测——不是直连登录。
|
||||
// (此处曾误写「可直接以该口令登录重测」,与实际 activated:false 自相矛盾,已校正。
|
||||
// 权威断言:scripts/verify_feedback_3.py:70,74 与 scripts/verify_testdata_mgmt.py。)
|
||||
const defaultHash = await ctx.helper.hashPassword(ctx.app.config.testDefaultPassword);
|
||||
// 🔴 R106-D7:走唯一入口 —— 生产未显式注入 TEST_DEFAULT_PASSWORD 时明确失败。
|
||||
// (本函数上方另有 `NODE_ENV === 'production'` 的 403 守卫;两者口径互补:
|
||||
// 守卫拦「明确的生产」,本函数兜住 `APP_ENV=production` 但 NODE_ENV 未设的分叉情形。)
|
||||
const testPw = requireTestPassword(ctx.app.config);
|
||||
const defaultHash = await ctx.helper.hashPassword(testPw);
|
||||
|
||||
// 事务包裹全部写操作:任意一步失败整体回滚,避免「部分重置」脏状态
|
||||
// (例如选手状态已清零、但提交记录未删,重测时仍显示旧作品 / 已提交态)。
|
||||
@@ -1757,7 +1764,7 @@ class AdminController extends Controller {
|
||||
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
|
||||
|
||||
ctx.logger.info('[测试数据重置]', results);
|
||||
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${ctx.app.config.testDefaultPassword}」,需先用默认口令完成短信激活即可登录重测`);
|
||||
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`);
|
||||
} catch (err) {
|
||||
ctx.logger.error('重置测试数据失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '重置测试数据失败');
|
||||
|
||||
@@ -186,8 +186,43 @@ function checkPassword(password, context = {}) {
|
||||
return { ok: true, rule: 'ok', reason: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「测试数据默认口令」—— **唯一入口**(R106-D7,2026-10-12)。
|
||||
*
|
||||
* 背景:`config.testDefaultPassword` 原为无条件 `process.env.TEST_DEFAULT_PASSWORD || '123456'`,
|
||||
* 而 `deploy/.env.production` 的 `TEST_DEFAULT_PASSWORD` 一直是**注释状态** ⇒ 生产环境实际
|
||||
* 取到的就是弱口令 `'123456'`。一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式
|
||||
* `testFlag=true` 的 Excel 导入),一批账号会被设为 `'123456'` —— 这是**静默弱口令**,
|
||||
* 而不是"注入链路断了"的明确失败,与 `_secret.cjs` / `DB_PASSWORD` 的既有纪律不一致
|
||||
* (R104 结论:同族问题各自实现必然漂移)。
|
||||
*
|
||||
* 现约定:生产(`config.isProduction`)未显式注入 ⇒ `config.testDefaultPassword` 为 `null`
|
||||
* ⇒ 本函数**明确失败**并给出可行动指引(⛔ 不静默回退)。测试环境逐字不变(仍 `'123456'`)。
|
||||
*
|
||||
* ⚠️ 与 `controller.admin.resetTestData` 里 `process.env.NODE_ENV === 'production'` 的
|
||||
* 403 守卫**口径不同**(后者只看 NODE_ENV,本函数看 `config.isProduction`,即
|
||||
* `APP_ENV`/`appInfo.env`)⇒ 两者互为补充:守卫拦"明确的生产",本函数兜住
|
||||
* "`APP_ENV=production` 但 NODE_ENV 未设"这种分叉情形。
|
||||
*
|
||||
* @param {object} appConfig Egg `app.config`
|
||||
* @returns {string} 生效的测试默认口令
|
||||
*/
|
||||
function requireTestPassword(appConfig) {
|
||||
const pw = appConfig && appConfig.testDefaultPassword;
|
||||
if (pw) return pw;
|
||||
throw new Error(
|
||||
'[安全阻断] 生产环境未配置 TEST_DEFAULT_PASSWORD —— 拒绝把账号批量设为「默认口令」。\n' +
|
||||
' 该操作会写入 config.testDefaultPassword 的 bcrypt 哈希;若静默回退到内置弱口令\n' +
|
||||
" (原行为 '123456'),等价于在生产上批量设置弱口令。请任选其一:\n" +
|
||||
' ① 确实需要:在 deploy/.env.production 显式设置 TEST_DEFAULT_PASSWORD 后重启\n' +
|
||||
' (推荐 —— 让「生产要设默认口令」成为一个显式决定);\n' +
|
||||
' ② 若本就不该在生产重置测试数据,请在带 test_flag 的测试库执行。'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkPassword,
|
||||
requireTestPassword,
|
||||
MIN_LENGTH,
|
||||
MAX_LENGTH,
|
||||
STRENGTH_HINT,
|
||||
|
||||
@@ -10,6 +10,9 @@ const {
|
||||
buildAggregationKeyMap,
|
||||
resolveAggregationKeysForContestant,
|
||||
} = require('../lib/aggregationKey');
|
||||
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
|
||||
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
|
||||
const { requireTestPassword } = require('../lib/passwordPolicy');
|
||||
|
||||
/**
|
||||
* 客观题(选择题 / 判断题)每小题固定分值。
|
||||
@@ -510,7 +513,10 @@ class AdminService extends Service {
|
||||
// 测试数据统一使用「默认口令」的 bcrypt 哈希,便于重置/导入后直接登录重测。
|
||||
let defaultTestHash = null;
|
||||
if (importAsTest) {
|
||||
defaultTestHash = await ctx.helper.hashPassword(ctx.app.config.testDefaultPassword);
|
||||
// 🔴 R106-D7:走唯一入口 —— 生产未显式注入 TEST_DEFAULT_PASSWORD 时**明确失败**,
|
||||
// ⛔ 不再静默把导入的账号批量设为内置弱口令 '123456'。
|
||||
// (生产环境 importAsTest 默认 false;仅当调用方显式传 testFlag=true 才会走到这里。)
|
||||
defaultTestHash = await ctx.helper.hashPassword(requireTestPassword(ctx.app.config));
|
||||
}
|
||||
|
||||
// 读取 Excel 文件
|
||||
|
||||
Reference in New Issue
Block a user