Compare commits
5 Commits
95ad1adca4
...
76ac1c7b6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ac1c7b6b | |||
| cf91f6ba34 | |||
| 89b9b2b0da | |||
| 3b4d422e36 | |||
| c94d8fe3ff |
@@ -10,6 +10,9 @@ const { sanitizeUploadFileName } = require('../utils/filename');
|
|||||||
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
|
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
|
||||||
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
|
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
|
||||||
const { sendFile } = require('../utils/sendFile');
|
const { sendFile } = require('../utils/sendFile');
|
||||||
|
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
|
||||||
|
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
|
||||||
|
const { requireTestPassword } = require('../lib/passwordPolicy');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理控制器 - AdminController
|
* 管理控制器 - AdminController
|
||||||
@@ -1609,7 +1612,11 @@ class AdminController extends Controller {
|
|||||||
// 因此测试人员需先重新走「短信激活」,激活后再以该口令登录重测——不是直连登录。
|
// 因此测试人员需先重新走「短信激活」,激活后再以该口令登录重测——不是直连登录。
|
||||||
// (此处曾误写「可直接以该口令登录重测」,与实际 activated:false 自相矛盾,已校正。
|
// (此处曾误写「可直接以该口令登录重测」,与实际 activated:false 自相矛盾,已校正。
|
||||||
// 权威断言:scripts/verify_feedback_3.py:70,74 与 scripts/verify_testdata_mgmt.py。)
|
// 权威断言: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;
|
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
|
||||||
|
|
||||||
ctx.logger.info('[测试数据重置]', results);
|
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) {
|
} catch (err) {
|
||||||
ctx.logger.error('重置测试数据失败:', err);
|
ctx.logger.error('重置测试数据失败:', err);
|
||||||
ctx.body = ctx.helper.error(500, '重置测试数据失败');
|
ctx.body = ctx.helper.error(500, '重置测试数据失败');
|
||||||
|
|||||||
@@ -186,8 +186,43 @@ function checkPassword(password, context = {}) {
|
|||||||
return { ok: true, rule: 'ok', reason: '' };
|
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 = {
|
module.exports = {
|
||||||
checkPassword,
|
checkPassword,
|
||||||
|
requireTestPassword,
|
||||||
MIN_LENGTH,
|
MIN_LENGTH,
|
||||||
MAX_LENGTH,
|
MAX_LENGTH,
|
||||||
STRENGTH_HINT,
|
STRENGTH_HINT,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const {
|
|||||||
buildAggregationKeyMap,
|
buildAggregationKeyMap,
|
||||||
resolveAggregationKeysForContestant,
|
resolveAggregationKeysForContestant,
|
||||||
} = require('../lib/aggregationKey');
|
} = 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 哈希,便于重置/导入后直接登录重测。
|
// 测试数据统一使用「默认口令」的 bcrypt 哈希,便于重置/导入后直接登录重测。
|
||||||
let defaultTestHash = null;
|
let defaultTestHash = null;
|
||||||
if (importAsTest) {
|
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 文件
|
// 读取 Excel 文件
|
||||||
|
|||||||
@@ -32,13 +32,32 @@ async function sendFile(ctx, app, opts) {
|
|||||||
if (absolutePath) {
|
if (absolutePath) {
|
||||||
absPath = absolutePath;
|
absPath = absolutePath;
|
||||||
} else {
|
} else {
|
||||||
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
|
// 🔴 路径穿越防护(2026-10-12 修复):改为「危险形态黑名单 + resolve 前缀校验」。
|
||||||
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
|
// 原实现是全 ASCII 白名单 /^[A-Za-z0-9_.\/-]+$/,会把合法中文文件名(如 `测试.xlsx`)
|
||||||
|
// 误判为非法路径 ⇒ materials(资料)/ exam(模板)/ admin(作品·导出)三个下载通道
|
||||||
|
// 全部对中文名返回 400「非法文件路径」(生产实测 bug)。中文文件名是设计内支持的
|
||||||
|
// (见下方 RFC 5987 分支),故白名单口径错误。
|
||||||
|
// ① 形态拦截:控制字符/NUL · 反斜杠 · 绝对路径(/ 开头或盘符) · `..` 路径段
|
||||||
|
const unsafeShape =
|
||||||
|
!relativePath ||
|
||||||
|
/[\u0000-\u001f\u007f\\]/.test(relativePath) ||
|
||||||
|
relativePath.startsWith('/') ||
|
||||||
|
/^[A-Za-z]:/.test(relativePath) ||
|
||||||
|
relativePath.split('/').includes('..');
|
||||||
|
if (unsafeShape) {
|
||||||
|
ctx.status = 400;
|
||||||
|
ctx.body = ctx.helper.error(400, '非法文件路径');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ② 归一化后前缀校验(最硬兜底):解析结果必须仍落在 app/public 之内,
|
||||||
|
// 即使 ① 被绕过也逃不出根目录(比字符白名单更可靠,且不误伤 Unicode 文件名)。
|
||||||
|
const publicRoot = path.resolve(app.baseDir, 'app/public');
|
||||||
|
absPath = path.resolve(publicRoot, relativePath);
|
||||||
|
if (absPath !== publicRoot && !absPath.startsWith(publicRoot + path.sep)) {
|
||||||
ctx.status = 400;
|
ctx.status = 400;
|
||||||
ctx.body = ctx.helper.error(400, '非法文件路径');
|
ctx.body = ctx.helper.error(400, '非法文件路径');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
absPath = path.join(app.baseDir, 'app/public', relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
|
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
|
||||||
@@ -77,16 +96,37 @@ async function sendFile(ctx, app, opts) {
|
|||||||
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
|
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
|
||||||
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
|
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
|
||||||
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
|
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
|
||||||
|
// 🔴 X-Accel-Redirect 是 HTTP 头值,只允许 ASCII:中文/全角文件名必须**逐段 URL 编码**
|
||||||
|
// (保留 `/` 分隔符),否则 Node 的 res.setHeader 抛 ERR_INVALID_CHAR ⇒ 500。
|
||||||
|
// 2026-10-12 生产实测:修复 :36 校验放行中文名后,本行成为新的失败点(400 → 500)。
|
||||||
|
// nginx 按**解码后**的 URI 匹配 internal location,再由 alias 落到磁盘真实中文文件名。
|
||||||
|
const internalUri = '/' + internalPath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
ctx.set('Content-Type', contentType);
|
ctx.set('Content-Type', contentType);
|
||||||
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
|
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
|
||||||
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
|
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
|
||||||
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
|
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与哨兵,不一致即拒落盘。
|
||||||
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
|
|
||||||
ctx.set('X-File-Size', String(fileStat.size));
|
ctx.set('X-File-Size', String(fileStat.size));
|
||||||
|
// 🔴🔴 2026-10-12(R103)生产实测修正 —— 原注释「nginx 的 X-Accel-Redirect 会保留上游自定义
|
||||||
|
// 响应头,故此头可穿透到浏览器」**不成立**,已证伪:
|
||||||
|
// 双通道对照实测(探针 .workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js):
|
||||||
|
// · 直连后端 :8023 ⇒ 204 空体,但响应带 `x-file-size: 19009`(后端确实发到了线上)
|
||||||
|
// · 经 nginx :80 ⇒ 200 / 19009 字节(sha256 与磁盘逐字节一致,确由 nginx 吐出),
|
||||||
|
// 而 **`x-file-size` 缺失**
|
||||||
|
// · 对照 /api/health 经 nginx ⇒ 自定义头 `x-readtime` **保留**
|
||||||
|
// ⇒ 丢弃只发生在 **X-Accel 内部重定向**这条路径(非「nginx 一概丢自定义头」)。
|
||||||
|
// 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。
|
||||||
|
// 修法:改走「nginx 会保留的通道」—— Content-Disposition 实测能穿透(ARM B 仍在),
|
||||||
|
// 故把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`。
|
||||||
|
// 前端按 `x-file-size` → CD `size=` 顺序回落(见 utils/download.js readDeclaredSize)。
|
||||||
|
// 两通道同源同值;任一缺失只降级不误伤。
|
||||||
|
// ⚠️ Node 流分支(absolutePath)无需此旁路:其响应走 nginx 常规转发,自定义头本就保留。
|
||||||
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
|
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
|
||||||
// 切勿再包 encodeURIComponent 造成双重编码。
|
// 切勿再包 encodeURIComponent 造成双重编码。
|
||||||
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
// `size=` 追加在 filename* 之后:浏览器忽略未知 disposition 参数,不影响下载行为。
|
||||||
ctx.set('X-Accel-Redirect', internalPath);
|
ctx.set('Content-Disposition',
|
||||||
|
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}; size=${fileStat.size}`);
|
||||||
|
// ⚠️ 必须用编码后的 internalUri(纯 ASCII),不可直接用含中文的 internalPath。
|
||||||
|
ctx.set('X-Accel-Redirect', internalUri);
|
||||||
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
|
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
|
||||||
ctx.status = 200;
|
ctx.status = 200;
|
||||||
ctx.body = null;
|
ctx.body = null;
|
||||||
|
|||||||
@@ -35,9 +35,25 @@ const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
|||||||
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
||||||
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
||||||
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
|
// 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_'];
|
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) {
|
function loadDeployEnv(appInfo) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -94,6 +110,13 @@ function loadDeployEnv(appInfo) {
|
|||||||
skipped.push(key);
|
skipped.push(key);
|
||||||
continue;
|
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;
|
process.env[key] = val;
|
||||||
}
|
}
|
||||||
loadedFrom = file;
|
loadedFrom = file;
|
||||||
@@ -149,13 +172,28 @@ module.exports = (appInfo) => {
|
|||||||
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
|
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
|
||||||
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
|
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
|
||||||
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
|
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
|
||||||
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
|
// 🔴 2026-10-12(R106-D7):**生产环境取消弱口令兜底**。
|
||||||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
|
// 原实现无条件 `|| '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,缺省时回退到内置值(仅供本地开发)
|
// Cookie 签名密钥(Egg `config.keys`):唯一来源见 backend/scripts/_secret.cjs;⛔ 无兜底。
|
||||||
// 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】
|
// 🔴 2026-10-12(R106-D6):原实现为
|
||||||
const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
// const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||||
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
|
// 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
|
// add your config here
|
||||||
config.middleware = ['concurrencyGuard'];
|
config.middleware = ['concurrencyGuard'];
|
||||||
@@ -214,7 +252,16 @@ module.exports = (appInfo) => {
|
|||||||
config.sequelize = {
|
config.sequelize = {
|
||||||
dialect: 'mysql',
|
dialect: 'mysql',
|
||||||
database: process.env.DB_NAME || 'competition_db',
|
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,
|
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||||
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
||||||
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
||||||
@@ -297,11 +344,19 @@ module.exports = (appInfo) => {
|
|||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// JWT 鉴权配置
|
// JWT 鉴权配置
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
|
// 🔴 2026-10-12(R105-D1,用户授权):移除内置明文兜底密钥。
|
||||||
|
// 原实现:`const DEFAULT_JWT_SECRET = '<明文串>'` + `process.env.JWT_SECRET || DEFAULT_JWT_SECRET`
|
||||||
|
// —— 密钥缺失时静默回退到一个**已入库、任何人可读**的常量,且该常量与
|
||||||
|
// scripts/.creds.local.json 的 jwt_fallback_secret 同值
|
||||||
|
// ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。
|
||||||
|
// 现收敛到唯一解析入口 `backend/scripts/_secret.cjs`(与 _db.cjs / _creds.py 同一纪律):
|
||||||
|
// ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败
|
||||||
|
// 行为等价性:生产与本地均由注入链路提供密钥(deploy/.env.production 的 JWT_SECRET
|
||||||
|
// 经 loadDeployEnv 注入 process.env)⇒ 正常运行路径逐字不变,仅消除了兜底分支。
|
||||||
|
const { loadJwtSecret } = require('../scripts/_secret.cjs');
|
||||||
config.jwt = {
|
config.jwt = {
|
||||||
// JWT 签名密钥:优先读取环境变量 JWT_SECRET,缺省时回退到内置值(仅供本地开发)
|
// JWT 签名密钥:唯一来源见 _secret.cjs 头部说明;⛔ 无明文兜底,缺失即明确失败
|
||||||
// 【测试模拟值,生产请通过环境变量 JWT_SECRET 替换为 64 位以上随机串,否则启动被守卫阻断】
|
secret: loadJwtSecret(),
|
||||||
secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
|
|
||||||
enable: true,
|
enable: true,
|
||||||
// Token 有效期(秒),默认 7 天
|
// Token 有效期(秒),默认 7 天
|
||||||
expiresIn: 7 * 24 * 60 * 60,
|
expiresIn: 7 * 24 * 60 * 60,
|
||||||
@@ -446,12 +501,19 @@ module.exports = (appInfo) => {
|
|||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// 生产环境安全守卫:禁止使用内置默认密钥上线
|
// 生产环境安全守卫:禁止使用内置默认密钥上线
|
||||||
// (由 APP_ENV=production 触发;测试环境允许使用模拟密钥)
|
// (由 APP_ENV=production 触发)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
if (config.isProduction) {
|
if (config.isProduction) {
|
||||||
const insecure = [];
|
const insecure = [];
|
||||||
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
|
// 🔴 2026-10-12(R105-D1):JWT_SECRET 不再在此比对 —— 内置兜底常量已移除,
|
||||||
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
|
// 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置
|
||||||
|
// 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法)
|
||||||
|
// 再做「是否等于内置值」的判断。
|
||||||
|
// 🔴 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):口令缺失同样阻断。
|
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
|
||||||
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
||||||
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
||||||
|
|||||||
112
scripts/_secret.cjs
Normal file
112
scripts/_secret.cjs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'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 };
|
||||||
Reference in New Issue
Block a user