fix: 自定义文本类环境变量读取绕过 dotenv 注释截断以保留 # (#443)

问题:dotenv.config() 将未加引号的行内 # 视为注释起始,导致自定义文本类变量中合法包含的 # 被截断、规则失效;即便用户通过前端写入双引号可规避,直接编辑 .env 不加引号时仍会触发。

方案:在 Envs.get 中对未加密的文本类自定义变量(RAW_ENV_KEYS)统一路由到 getRawEnv,直接解析 .env 原始文本(仅跳过整行 # 注释、剥离外层双引号),从而保留 # 等字符。优先级保持系统环境变量 > .env 原始值 > 默认值,与既有 systemEnvBackup 机制一致;非 Node 运行时(测试 / Workers)无原始 .env 时退化为普通取值语义,不影响测试隔离与平台兼容。前端设置页读取的 originalEnvVars 同样由 get/getRawEnv 写入,显示侧一并修复。同步移除 resolveCustomMergeRules 中已冗余的 accessedEnvVars.set 调用。

覆盖范围:RAW_ENV_KEYS 含合并映射表、标题/自动匹配映射表、弹幕偏移、屏蔽词、各标题/剧集噪音过滤器、IP 黑名单、AI 匹配提示,以及非加密的 COLOR_POOL / DANMU_PUSH_URL / OTHER_SERVER / VOD_SERVERS(URL / 颜色类)。

加密守卫:get() 路由条件加 !encrypt,防止加密变量(PROXY_URL / CUSTOM_SOURCE_API_URL / UPSTASH_REDIS_REST_URL / LOCAL_REDIS_URL 等带令牌 URL)被绕过加密返回明文;此类加密变量刻意不入 RAW_ENV_KEYS(其 # 风险极低且经安全写入路径)。

测试:worker.test.js 增加对应注释测试桩(按项目约定注释待启用);独立的 raw-env-read.test.mjs 仅本地验证、不入库。

Co-authored-by: huangxd- <649924757@qq.com>
This commit is contained in:
Elegy233andhuangxd- authored and GitHub committed 2026-08-15 08:01:47 +08:00
1 parent 00efe4a815
commit 7e5c8073e0
3 files changed
+115 -2

No files matched your search

+70 -2
View File
@@ -13,6 +13,13 @@ export class Envs {
static originalEnvVars = new Map();
static accessedEnvVars = new Map();
// Node 本地部署时由 server.js 注入:启动前的真实系统环境变量快照(最高优先级判定依据)与 .env 原始解析结果
static systemEnvBackup = null;
static rawEnvValues = null;
// 允许在值中写入 # 等 dotenv 视为注释字符的文本类变量;读取时绕过 dotenv 截断以保留完整内容。仅纳入 encrypt=false 变量(带令牌/密码 URL 若入此集合会绕过加密返回明文,故禁止纳入)。
static RAW_ENV_KEYS = new Set(['AI_MATCH_PROMPT', 'ANIME_TITLE_FILTER', 'AUTO_MATCH_MAPPING_TABLE', 'BLOCKED_WORDS', 'COLOR_POOL', 'CUSTOM_MERGE_RULES', 'DANMU_OFFSET', 'DANMU_PUSH_URL', 'EPISODE_TITLE_FILTER', 'IP_BLACKLIST', 'OTHER_SERVER', 'TITLE_MAPPING_TABLE', 'TITLE_NOISE_FILTER', 'VOD_SERVERS']);
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan']; // vod允许的播放平台
static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'dandan', 'bahamut', 'animeko', 'custom']; // 全部源允许的播放平台
static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'dandan', 'bahamut', 'animeko', 'custom']; // 允许的源
@@ -63,6 +70,10 @@ export class Envs {
* @returns {any} 转换后的值
*/
static get(key, defaultValue, type = 'string', encrypt = false) {
// 文本类且未加密的自定义变量绕过 dotenv 注释截断,保留 # 等字符;加密变量不在此路径,避免绕过加密返回明文
if (type === 'string' && !encrypt && Envs.RAW_ENV_KEYS.has(key)) {
return this.getRawEnv(key, defaultValue);
}
let value;
if (typeof this.env !== 'undefined' && this.env[key]) {
value = this.env[key];
@@ -119,6 +130,64 @@ export class Envs {
return '*'.repeat(str.length);
}
/**
* 解析 .env 原始内容:跳过整行 # 注释、保留行内 #,并剥除整体双引号包裹(与 node-handler 引号写入一致)。
* @param {string} text .env 文件原始内容
* @returns {Object} 键值映射
*/
static parseRawEnvText(text) {
const result = {};
if (typeof text !== 'string') return result;
const lines = text.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
if (!key) continue;
let value = trimmed.slice(eq + 1).trim();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
result[key] = value;
}
return result;
}
/**
* 读取自定义文本类变量,绕过 dotenv 截断保留 #:系统环境变量 > .env 原始值 > 默认值;非 Node 部署退化为普通取值。
* @param {string} key 环境变量键
* @param {string} defaultValue 默认值
* @returns {string} 原始值(含 #)
*/
static getRawEnv(key, defaultValue = '') {
const finalize = (v) => {
this.originalEnvVars.set(key, v);
this.accessedEnvVars.set(key, v);
return v;
};
// 非 Node 运行时(测试 / Workers):不做文件读取,等价于普通取值,保证测试隔离与平台兼容
if (!Envs.systemEnvBackup) {
if (this.env && this.env[key]) return finalize(this.env[key]);
if (typeof process !== 'undefined' && process.env?.[key]) return finalize(process.env[key]);
return finalize(defaultValue);
}
// Node 运行时:系统环境变量始终最高优先级
if (Object.prototype.hasOwnProperty.call(Envs.systemEnvBackup, key)) {
return finalize(Envs.systemEnvBackup[key]);
}
// 否则读取 .env 原始行(保留 #),未配置该键时回退 process.env 或默认值
if (Envs.rawEnvValues && Object.prototype.hasOwnProperty.call(Envs.rawEnvValues, key)) {
return finalize(Envs.rawEnvValues[key]);
}
if (typeof process !== 'undefined' && process.env?.[key]) return finalize(process.env[key]);
return finalize(defaultValue);
}
/**
* 解析 VOD 服务器配置
* @returns {Array} 服务器列表
@@ -321,8 +390,7 @@ export class Envs {
console.warn(`[Envs] 解析合并映射表规则失败: ${rStr}`, e);
}
}
this.accessedEnvVars.set('CUSTOM_MERGE_RULES', raw);
return rules;
}
+13
View File
@@ -10,6 +10,7 @@ import dotenv from 'dotenv';
import { Request as NodeFetchRequest } from 'node-fetch';
import { handleRequest } from './worker.js';
import { Globals } from './configs/globals.js';
import { Envs } from './configs/envs.js';
import { clearBangumiDataCache, initBangumiData } from './utils/bangumi-data-util.js';
import { getLocalCaches, judgeLocalCacheValid } from './utils/cache-util.js';
import { getRedisCaches, judgeRedisValid } from './utils/redis-util.js';
@@ -42,6 +43,9 @@ const envPath = path.join(configDir, '.env');
// 保存系统环境变量的副本,确保它们具有最高优先级
const systemEnvBackup = { ...process.env };
// 注入到 Envs,供自定义规则变量读取时判定系统环境变量优先级(绕过 dotenv 注释截断)
Envs.systemEnvBackup = systemEnvBackup;
// 引入 zlib 模块,用于响应数据的 GZIP 压缩
// (注:zlib 已在顶部 import,此处保留原版注释意图说明)
@@ -138,6 +142,15 @@ function loadEnv() {
process.env[key] = value;
}
// 解析 .env 原始内容,供支持自定义规则的变量绕过 dotenv 注释截断(保留 # 等字符)
try {
if (fs.existsSync(envPath)) {
Envs.rawEnvValues = Envs.parseRawEnvText(fs.readFileSync(envPath, 'utf8'));
}
} catch (e) {
// 原始解析失败不影响启动,相关变量回退到普通取值语义
}
console.log('[server] .env file loaded successfully');
} catch (e) {
console.log('[server] dotenv not available or .env file not found, using system environment variables');
+32
View File
@@ -2850,6 +2850,38 @@ test('worker.js API endpoints', async (t) => {
// });
// });
// // 测试自定义文本类变量绕过 dotenv 注释截断(保留 # 等字符),对应 envs.js RAW_ENV_KEYS 修复
// import { Envs } from './configs/envs.js';
//
// test('envs RAW_ENV_KEYS 保留 # 不被 dotenv 截断', async (t) => {
// const reset = () => { Envs.systemEnvBackup = null; Envs.rawEnvValues = null; Envs.env = undefined; };
//
// await t.test('parseRawEnvText 保留行内 # 与剥除外层双引号', () => {
// const parsed = Envs.parseRawEnvText('K1=v1\nK2=v with # hash\nK3="q # v"');
// assert.strictEqual(parsed.K2, 'v with # hash');
// assert.strictEqual(parsed.K3, 'q # v');
// });
//
// await t.test('CUSTOM_MERGE_RULES / COLOR_POOL / URL 类变量含 # 完整保留', () => {
// reset();
// Envs.systemEnvBackup = {};
// Envs.rawEnvValues = {
// CUSTOM_MERGE_RULES: 'A #1 revival@bili',
// COLOR_POOL: '#FF0000,#00FF00',
// DANMU_PUSH_URL: 'http://h.com/cb#frag',
// };
// assert.strictEqual(Envs.get('CUSTOM_MERGE_RULES', '', 'string'), 'A #1 revival@bili');
// assert.strictEqual(Envs.get('COLOR_POOL', '', 'string'), '#FF0000,#00FF00');
// assert.strictEqual(Envs.get('DANMU_PUSH_URL', '', 'string'), 'http://h.com/cb#frag');
// });
//
// await t.test('!encrypt 守卫:加密变量不走原始解析,防止绕过加密', () => {
// reset();
// Envs.systemEnvBackup = {};
// Envs.rawEnvValues = { DANMU_PUSH_URL: 'http://x.com/cb#frag' };
// assert.strictEqual(Envs.get('DANMU_PUSH_URL', 'DEF', 'string', true), 'DEF');
// });
// test('nipaplay 弹弹302关联工具函数', async (t) => {
//
// // generateNipaplaySignature:相同入参确定性产出,输出为 sha256 的 base64(44 字符)