Files
competition-website/scripts/_secret.cjs
qubiaobiao 76ac1c7b6b 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
2026-10-12 11:06:01 +08:00

113 lines
4.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 后端配置共用:**配置密钥解析(唯一入口)**
* ============================================================
* 当前覆盖两个同族密钥:
* · `JWT_SECRET` —— JWT 签名密钥(`loadJwtSecret`)
* · `APP_KEYS` —— Cookie 签名密钥(`loadAppKeys`,Egg `config.keys`)
*
* 为什么单独成文件(R105-D1 + R106-D6,2026-10-12)
* ------------------------------------------------------------
* 原实现(config.default.js 旧 L300/L304 与 L157/L158)各自带**可预测的兜底值**:
* const DEFAULT_JWT_SECRET = '<32 位明文串>';
* secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET
* const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
* config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS
* 两者都是「**已入库、任何人可读或可推算**」的值 ⇒ 等价于「密钥公开」:
* · JWT_SECRET 公开 ⇒ 任意人可伪造 token 冒充任意身份;
* · APP_KEYS 公开 ⇒ 任意人可伪造已签名 Cookie(session / 防篡改校验)。
* 且它们与仓库既有纪律直接冲突:
* · `scripts/_creds.py` / `backend/scripts/_db.cjs` 早已统一为「缺凭据明确失败」;
* · config.default.js 对 `DB_PASSWORD` 也已改为「缺失即阻断」。
* 同族问题若各自实现必然漂移(R104 的结论)⇒ 收敛到**唯一解析源**,不留兜底。
*
* 读取优先级(与 `_db.cjs` / `_creds.py` 同一纪律)
* ------------------------------------------------------------
* 1. 对应的**环境变量**(生产由 systemd `EnvironmentFile=` / 启动器注入;本地由
* config.default.js 顶部的 `loadDeployEnv()` 注入 —— 注意 **R106-D9 起
* `JWT_SECRET` / `APP_KEYS` 属"非生产跳过键"**,本地开发不再继承生产值)
* 2. 仓库根 `scripts/.creds.local.json` 的对应键(本机开发用;`.gitignore` 已排除;
* `jwt_fallback_secret` 同时是 `scripts/_fixtures.py` 的末级来源,两侧同源 ⇒
* 自签 token 与后端验签必然一致)
* 3. **明确失败** —— ⛔ **刻意不提供明文兜底**:抛错并给出可行动的修复指引。
* 理由:密钥缺失属"注入链路断了"的真实故障,明确阻断远优于静默用公开值签名。
*
* ⚠️ 与 Python 侧 `scripts/_creds.py`(`JWT_FALLBACK_SECRET` / `APP_KEYS`)口径必须一致;
* 如需增删键,两边同时改。
*/
const fs = require('fs');
const path = require('path');
const LOCAL_FILE = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json');
function _loadLocal() {
try {
return JSON.parse(fs.readFileSync(LOCAL_FILE, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') return {}; // 文件不存在合法(走环境变量)
// 损坏必须报错,不静默降级
throw new Error(`凭据文件解析失败 ${LOCAL_FILE}: ${e.message}`);
}
}
/**
* 通用解析:env → `.creds.local.json` → 明确失败。
*
* @param {object} o
* @param {string} o.envKey 环境变量名(如 'JWT_SECRET')
* @param {string} o.localKey `.creds.local.json` 中的键名(如 'jwt_fallback_secret')
* @param {string} o.purpose 失败时的用途说明(如 '签名/校验登录态')
* @param {string} o.builtin 被移除的兜底物名称(如 '内置密钥')
* @param {string} o.whyWrong 该兜底值为何不可接受的后果(如 'JWT 可被任意伪造')
* @returns {string} 生效中的密钥
*/
function _loadSecret(o) {
const { envKey, localKey, purpose, builtin, whyWrong } = o;
const fromEnv = process.env[envKey];
if (fromEnv) return fromEnv;
const local = _loadLocal();
const fromFile = local[localKey];
if (fromFile) return fromFile;
throw new Error(
`[配置缺失] 未取到 ${envKey} —— 无法安全地${purpose}。请任选其一:\n` +
` ① 设置环境变量 ${envKey}(生产/CI 推荐,deploy/.env.production 已含该键);\n` +
` ② 在 ${LOCAL_FILE} 写入键 "${localKey}"(本机开发用,该文件已被 .gitignore 排除);\n` +
`⛔ 本模块刻意不提供明文兜底:原 config.default.js 的${builtin}已移除,\n` +
` 因为「可预测的公开密钥」等价于「${whyWrong}」。`
);
}
/**
* JWT 签名密钥。
* @returns {string}
*/
function loadJwtSecret() {
return _loadSecret({
envKey: 'JWT_SECRET',
localKey: 'jwt_fallback_secret',
purpose: '签名/校验登录态',
builtin: '内置密钥',
whyWrong: 'JWT 可被任意伪造',
});
}
/**
* Cookie 签名密钥(Egg `config.keys`)。
* @returns {string}
*/
function loadAppKeys() {
return _loadSecret({
envKey: 'APP_KEYS',
localKey: 'app_keys',
purpose: '签发/校验 Cookie 签名',
builtin: '内置兜底值',
whyWrong: 'Cookie 可被任意伪造',
});
}
module.exports = { loadJwtSecret, loadAppKeys };