From 76ac1c7b6bbc038204104287da31b9b28fc83b50 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 12 Oct 2026 11:06:01 +0800 Subject: [PATCH] =?UTF-8?q?security(config):=20=E5=9B=9B=E4=B8=AA=E5=90=8C?= =?UTF-8?q?=E6=97=8F=E5=85=9C=E5=BA=95=E7=BC=BA=E9=99=B7=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20=E2=80=94=E2=80=94=20APP=5FKEYS=20/=20?= =?UTF-8?q?=E5=BC=B1=E5=8F=A3=E4=BB=A4=20/=20IPv6=20=E5=85=9C=E5=BA=95=20/?= =?UTF-8?q?=20=E6=9C=AC=E5=9C=B0=E7=94=9F=E4=BA=A7=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E5=90=8C=E5=80=BC=EF=BC=88R106-D6~D9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共同根因(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 --- app/controller/admin.js | 11 ++++- app/lib/passwordPolicy.js | 35 ++++++++++++++ app/service/admin.js | 8 +++- config/config.default.js | 69 ++++++++++++++++++++++++---- scripts/_secret.cjs | 96 +++++++++++++++++++++++++++++---------- 5 files changed, 182 insertions(+), 37 deletions(-) diff --git a/app/controller/admin.js b/app/controller/admin.js index 2ec7cea..dba98a8 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -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, '重置测试数据失败'); diff --git a/app/lib/passwordPolicy.js b/app/lib/passwordPolicy.js index 8df188a..4bced72 100644 --- a/app/lib/passwordPolicy.js +++ b/app/lib/passwordPolicy.js @@ -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, diff --git a/app/service/admin.js b/app/service/admin.js index 2ee18c5..5c171a7 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -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 文件 diff --git a/config/config.default.js b/config/config.default.js index 5b9730e..d0ba739 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -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 本就含该键, diff --git a/scripts/_secret.cjs b/scripts/_secret.cjs index 51a1efd..869019e 100644 --- a/scripts/_secret.cjs +++ b/scripts/_secret.cjs @@ -1,35 +1,40 @@ 'use strict'; /** - * 后端配置共用:JWT 签名密钥解析(**唯一入口**) + * 后端配置共用:**配置密钥解析(唯一入口)** * ============================================================ + * 当前覆盖两个同族密钥: + * · `JWT_SECRET` —— JWT 签名密钥(`loadJwtSecret`) + * · `APP_KEYS` —— Cookie 签名密钥(`loadAppKeys`,Egg `config.keys`) * - * 为什么单独成文件(R105 / 2026-10-12,D1) + * 为什么单独成文件(R105-D1 + R106-D6,2026-10-12) * ------------------------------------------------------------ - * 原实现(config.default.js 旧 L300/L304)为: + * 原实现(config.default.js 旧 L300/L304 与 L157/L158)各自带**可预测的兜底值**: * const DEFAULT_JWT_SECRET = '<32 位明文串>'; * secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET - * 这是**可预测的明文兜底**:密钥缺失时静默使用一个**已入库、任何人可读**的常量, - * 且该常量与 `scripts/.creds.local.json` 的 `jwt_fallback_secret` 同值 ⇒ 等价于 - * 「JWT 签名密钥公开」。它与仓库既有纪律直接冲突: + * 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:459 对 DB_PASSWORD 也已改为「缺失即阻断」。 - * 同族问题若各自实现,必然漂移(R104 的结论)⇒ 收敛到**唯一解析源**,不留兜底。 + * · config.default.js 对 `DB_PASSWORD` 也已改为「缺失即阻断」。 + * 同族问题若各自实现必然漂移(R104 的结论)⇒ 收敛到**唯一解析源**,不留兜底。 * * 读取优先级(与 `_db.cjs` / `_creds.py` 同一纪律) * ------------------------------------------------------------ - * 1. 环境变量 JWT_SECRET(生产 systemd / 启动器注入优先;本地由 config.default.js - * 顶部的 loadDeployEnv() 从 deploy/.env.production 注入,非跳过键) - * 2. 仓库根 `scripts/.creds.local.json` 的 `jwt_fallback_secret`(本机开发, - * .gitignore 已排除;也是 scripts/_fixtures.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. 明确失败 —— ⛔ **刻意不提供明文兜底**:抛错并给出修复指引。 + * 3. **明确失败** —— ⛔ **刻意不提供明文兜底**:抛错并给出可行动的修复指引。 * 理由:密钥缺失属"注入链路断了"的真实故障,明确阻断远优于静默用公开值签名。 * - * ⚠️ 与 Python 侧 `scripts/_creds.py`(JWT_FALLBACK_SECRET)口径必须一致; + * ⚠️ 与 Python 侧 `scripts/_creds.py`(`JWT_FALLBACK_SECRET` / `APP_KEYS`)口径必须一致; * 如需增删键,两边同时改。 - * - * @returns {string} 生效中的 JWT 签名密钥 */ const fs = require('fs'); const path = require('path'); @@ -46,21 +51,62 @@ function _loadLocal() { } } -function loadJwtSecret() { - const fromEnv = process.env.JWT_SECRET; +/** + * 通用解析: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.jwt_fallback_secret; + const fromFile = local[localKey]; if (fromFile) return fromFile; throw new Error( - '[配置缺失] 未取到 JWT_SECRET —— 无法安全地签名/校验登录态。请任选其一:\n' + - ' ① 设置环境变量 JWT_SECRET(生产/CI 推荐,deploy/.env.production 已含该键);\n' + - ` ② 在 ${LOCAL_FILE} 写入键 "jwt_fallback_secret"(本机开发用,该文件已被 .gitignore 排除);\n` + - '⛔ 本模块刻意不提供明文兜底:原 config.default.js 的内置密钥已移除,\n' + - ' 因为「可预测的公开密钥」等价于「JWT 可被任意伪造」。' + `[配置缺失] 未取到 ${envKey} —— 无法安全地${purpose}。请任选其一:\n` + + ` ① 设置环境变量 ${envKey}(生产/CI 推荐,deploy/.env.production 已含该键);\n` + + ` ② 在 ${LOCAL_FILE} 写入键 "${localKey}"(本机开发用,该文件已被 .gitignore 排除);\n` + + `⛔ 本模块刻意不提供明文兜底:原 config.default.js 的${builtin}已移除,\n` + + ` 因为「可预测的公开密钥」等价于「${whyWrong}」。` ); } -module.exports = { loadJwtSecret }; +/** + * 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 };