Commit f658bd053de051d7d51ae95c8ec3f2dd520321f2

Authored by yibo
1 parent 35805d85

fix(security): JWT_SECRET 静默降级到仓库内公开密钥;顺带修 .env.example 的端口

## 问题

server/middleware/jwt-auth.js 的兜底密钥 'lemon-dev-secret-change-in-production'
提交在仓库里,而 .env.example **没有 JWT_SECRET 这一项** —— npm run setup 就是
从 .env.example 生成 .env(CLAUDE.md「快速启动」),所以照文档装出来的部署
必然用这个公开密钥签会话,且启动时零告警。

危害不止「密钥弱」。extractUser 拿 decoded.userId 回库重查用户
(所以伪造 role:'admin' 这条路本身是堵住的,这个设计是对的),但
user-db 的 id 是自增小整数、不是 uuid:拿已知密钥签 {userId: N}、从 1 试起
就能命中真实账号,拿到的是该账号在库里的真实 role。命中 admin 就是
requireAdmin 守着的整个 /api/users/* 全部敞开。不需要密码、不需要 OAuth,
只要读过这个仓库。

本机 .env 有 64 字符密钥,未受影响;受影响的是默认部署路径。

## 三处改动

1. .env.example 补 JWT_SECRET(含 openssl rand -hex 32 生成命令与危害说明)
2. jwt-auth.js:NODE_ENV=production 且未设 JWT_SECRET 时**拒绝启动**
   (签名密钥缺失不是可以边跑边补的事);开发环境保留兜底但每次起进程吼一次。
   三种情形都实测过:生产无密钥→抛错拒启;生产有密钥→正常且不告警;开发无密钥→放行+告警。
3. 新增 guard server/tests/unit/env-example-secrets.test.js:
   「安全类 env + **非空**硬编码兜底」必须在 .env.example 里有一行。
   只管非空兜底是刻意的 —— 空串兜底(FEISHU_ENCRYPT_KEY、OPEN_API_KEYS)是
   「未配置」的正当写法,缺了 fail closed;把它们也算进来只会制造噪音,
   然后被加白名单,然后白名单腐烂。危险的是「缺了也能跑、还跑得像配好了」那一类。

## 顺带修掉一个能把全新克隆直接搞坏的

.env.example 写 PORT=3000,而 config.js 默认 3010、web/vite.config.js 的代理
目标硬编码 3010 → npm run setup 装出来的环境里前端每个 API 调用都 502,
而症状离原因很远。改成 3010,并加一条 guard 钉住「example / config 默认 /
vite 代理」三者一致。

## 验证

857 全绿(85 文件),既有断言未改。3 处变异精确变红后逐字还原。
其中 S3 那条变异(把兜底常量改成内联字面量)是必要的:guard 第一版只认内联
字面量,而我重构时把兜底提成了命名常量 DEV_FALLBACK_SECRET,于是 guard 对它
诞生的那个 case 恰好失明 —— 被它自己的自检当场抓到,遂加上同文件常量绑定解析。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.env.example
... ... @@ -6,10 +6,19 @@ DB_USER=autodev
6 6 DB_PASSWORD=autodev123
7 7  
8 8 # 服务器配置
9   -PORT=3000
  9 +# 3010 不是 3000:server/config.js 的默认值是 3010,web/vite.config.js 的代理目标
  10 +# 也硬编码打 3010。这里写 3000 的话,npm run setup 装出来的环境里前端每个 API 调用都会 502。
  11 +PORT=3010
10 12 HOST=0.0.0.0
11 13 # CORS_ORIGIN=http://localhost:5173,https://your-domain.com
12 14  
  15 +# 会话签名密钥(必填)
  16 +# server/middleware/jwt-auth.js 用它签发登录 cookie。**不设它会静默降级到代码里的
  17 +# 兜底值**,而那个兜底值提交在仓库里——任何读过这个仓库的人都能伪造任意用户的会话
  18 +# (用户 id 是自增小整数,从 1 试起就能命中)。NODE_ENV=production 时不设它会拒绝启动。
  19 +# 生成一个:openssl rand -hex 32
  20 +JWT_SECRET=change-me-openssl-rand-hex-32
  21 +
13 22 # WhatsApp Bot
14 23 export WHATSAPP_TRIGGER=@Andy
15 24 export WHATSAPP_BOT_NAME=Andy
... ...
server/middleware/jwt-auth.js
... ... @@ -10,7 +10,34 @@
10 10 const jwt = require('jsonwebtoken');
11 11 const userDb = require('../services/user-db');
12 12  
13   -const JWT_SECRET = process.env.JWT_SECRET || 'lemon-dev-secret-change-in-production';
  13 +/**
  14 + * 会话签名密钥。
  15 + *
  16 + * 兜底值只给本地开发用,而且**必须吵**:它提交在仓库里,任何读过仓库的人都能拿它
  17 + * 签一个 `{userId: N}`。extractUser 会用 decoded.userId 回库重查(所以伪造
  18 + * `role:'admin'` 这条路是堵住的),但 user-db 的 id 是自增小整数、不是 uuid,
  19 + * 从 1 试起就能命中真实账号 —— 于是「已知密钥」直接等于「任意账号会话伪造」,
  20 + * 命中的账号恰好是 admin 时就是完整的后台权限。
  21 + *
  22 + * 以前这里是静默降级:.env.example 里没有 JWT_SECRET 这一项,而 npm run setup
  23 + * 就是从它生成 .env,所以照文档装出来的部署**必然**落在兜底值上,且启动时零告警。
  24 + * 现在生产环境直接拒启(签名密钥缺失不是可以边跑边补的事),开发环境保留兜底但吼一声。
  25 + */
  26 +const DEV_FALLBACK_SECRET = 'lemon-dev-secret-change-in-production';
  27 +const JWT_SECRET = process.env.JWT_SECRET || DEV_FALLBACK_SECRET;
  28 +
  29 +if (!process.env.JWT_SECRET) {
  30 + const lines = [
  31 + 'JWT_SECRET 未设置,会话正在用仓库里公开的兜底密钥签名。',
  32 + '任何读过本仓库的人都能伪造任意用户的登录态(用户 id 是自增整数,可枚举)。',
  33 + '生成一个并写进 .env: openssl rand -hex 32',
  34 + ];
  35 + if (process.env.NODE_ENV === 'production') {
  36 + throw new Error(`[jwt-auth] 拒绝以不安全配置启动 —— ${lines.join(' ')}`);
  37 + }
  38 + // 开发环境:不拦,但每次进程起来都提醒一次
  39 + console.warn(`\n⚠️ [jwt-auth] ${lines.join('\n ')}\n`);
  40 +}
14 41 const COOKIE_NAME = 'lemon_token';
15 42 const TOKEN_EXPIRY = '7d';
16 43  
... ...
server/tests/unit/env-example-secrets.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 防漂移闸:**有非空硬编码兜底的安全类 env,必须写进 .env.example**
  3 + *
  4 + * 挡的是已经发生过的这一条:`server/middleware/jwt-auth.js` 里
  5 + * `process.env.JWT_SECRET || 'lemon-dev-secret-change-in-production'`,
  6 + * 而 `.env.example` 里**没有** JWT_SECRET 这一项 —— 偏偏 `npm run setup` 就是
  7 + * 从 .env.example 生成 .env(见 CLAUDE.md「快速启动」),所以照文档装出来的部署
  8 + * **必然**用那个提交在仓库里的密钥签会话,而且启动时一句告警都没有。
  9 + *
  10 + * 危害不止「密钥弱」:extractUser 拿 decoded.userId 回库重查(伪造 role 这条路是堵住的),
  11 + * 但 user-db 的 id 是自增小整数、不是 uuid,从 1 试起就能命中真实账号 ——
  12 + * 已知密钥 = 任意账号会话伪造,命中 admin 就是完整后台权限。
  13 + *
  14 + * ── 规则 ────────────────────────────────────────────────────────
  15 + * 只管**非空**兜底。空串兜底(`process.env.FEISHU_ENCRYPT_KEY || ''`、
  16 + * `OPEN_API_KEYS || ''`)是「未配置」的正当写法,缺了就 fail closed,不构成风险;
  17 + * 把它们也要求进 example 只会制造噪音,然后被加白名单,然后白名单腐烂。
  18 + * 危险的恰恰是「缺了也能跑,而且跑得像配好了一样」那一类。
  19 + *
  20 + * ponytail: 静态正则扫源码(先剥注释 —— 注释里举例不算代码,这个坑本仓库踩过两次)。
  21 + * 认两种兜底写法:内联字面量,以及同文件内 `const X = '字面量'` 的命名常量
  22 + * (后者是必须的 —— jwt-auth.js 就把兜底提成了常量,只认内联字面量时这条 guard
  23 + * 对它诞生的那个 case 恰好失明)。`??`、跨文件常量、拼接出来的兜底仍查不出来。
  24 + * 名字匹配 SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL —— 宁可范围窄而准,
  25 + * 也不要宽到需要维护例外表。
  26 + */
  27 +import { describe, it, expect } from 'vitest';
  28 +import fs from 'fs';
  29 +import path from 'path';
  30 +
  31 +const ROOT = path.resolve(__dirname, '../../..');
  32 +const SECRETISH = /SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL/;
  33 +
  34 +function stripComments(src) {
  35 + return src
  36 + .replace(/\/\*[\s\S]*?\*\//g, '')
  37 + .split('\n')
  38 + .map((l) => l.replace(/\/\/.*$/, ''))
  39 + .join('\n');
  40 +}
  41 +
  42 +function walk(dir, out = []) {
  43 + if (!fs.existsSync(dir)) return out;
  44 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  45 + if (e.name === 'node_modules') continue;
  46 + const p = path.join(dir, e.name);
  47 + if (e.isDirectory()) walk(p, out);
  48 + else if (p.endsWith('.js')) out.push(p);
  49 + }
  50 + return out;
  51 +}
  52 +
  53 +/** 生产代码里「process.env.X || '兜底'」的全部命中 */
  54 +function fallbacks() {
  55 + const dirs = ['server/services', 'server/src', 'server/routes', 'server/middleware', 'packages'];
  56 + const files = dirs.flatMap((d) => walk(path.join(ROOT, d)));
  57 + for (const f of ['server/config.js', 'server/index.js', 'server/agent-server.js']) {
  58 + files.push(path.join(ROOT, f));
  59 + }
  60 +
  61 + const hits = [];
  62 + for (const f of files) {
  63 + if (!fs.existsSync(f)) continue;
  64 + const src = stripComments(fs.readFileSync(f, 'utf-8'));
  65 +
  66 + // 同文件内的 `const X = '字面量'` 绑定:兜底值经常先提成命名常量再用
  67 + // (jwt-auth.js 就是这样 —— 只认内联字面量的话,这条 guard 对它诞生的那个
  68 + // case 恰好失明,实测被自检当场抓到)
  69 + const consts = new Map();
  70 + for (const c of src.matchAll(/\bconst\s+([A-Za-z0-9_$]+)\s*=\s*(['"`])([^'"`]*)\2\s*;/g)) {
  71 + consts.set(c[1], c[3]);
  72 + }
  73 +
  74 + // 兜底既可能是字面量,也可能是标识符
  75 + for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)\s*\|\|\s*(?:(['"`])([^'"`]*)\2|([A-Za-z0-9_$]+))/g)) {
  76 + const literal = m[2] !== undefined ? m[3] : undefined;
  77 + const viaConst = m[4] !== undefined && consts.has(m[4]) ? consts.get(m[4]) : undefined;
  78 + const fallback = literal !== undefined ? literal : viaConst;
  79 + // 兜底是个查不出值的表达式(函数调用、另一个 env、数字…)→ 不猜,跳过
  80 + if (fallback === undefined) continue;
  81 + hits.push({ name: m[1], fallback, file: path.relative(ROOT, f) });
  82 + }
  83 + }
  84 + return hits;
  85 +}
  86 +
  87 +/** .env.example 里声明过的键(同时认 `X=` 和 `export X=` 两种写法) */
  88 +function exampleKeys() {
  89 + const p = path.join(ROOT, '.env.example');
  90 + const src = fs.readFileSync(p, 'utf-8');
  91 + const keys = new Set();
  92 + for (const line of src.split('\n')) {
  93 + const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/);
  94 + if (m) keys.add(m[1]);
  95 + }
  96 + return keys;
  97 +}
  98 +
  99 +describe('.env.example 必须覆盖有非空兜底的安全类 env', () => {
  100 + const hits = fallbacks();
  101 + const keys = exampleKeys();
  102 +
  103 + it('两侧都不是空的(防止 guard 因为路径/正则失效而静默空跑)', () => {
  104 + expect(hits.length).toBeGreaterThan(0);
  105 + expect(keys.size).toBeGreaterThan(3);
  106 + // JWT_SECRET 是本条 guard 的由来,它从任一侧消失都说明扫描口径坏了
  107 + expect(hits.some((h) => h.name === 'JWT_SECRET')).toBe(true);
  108 + expect(keys.has('JWT_SECRET')).toBe(true);
  109 + });
  110 +
  111 + it('每个「安全类 env + 非空硬编码兜底」都在 .env.example 里有一行', () => {
  112 + const missing = hits
  113 + .filter((h) => SECRETISH.test(h.name) && h.fallback !== '')
  114 + .filter((h) => !keys.has(h.name))
  115 + .map((h) => `${h.name}(${h.file},兜底非空)未出现在 .env.example`);
  116 +
  117 + expect(missing).toEqual([]);
  118 + });
  119 +
  120 + it('.env.example 的 PORT 与 config.js 默认值、vite 代理目标三者一致', () => {
  121 + // 这三处对不上时,npm run setup 装出来的环境前端每个 API 调用都 502,
  122 + // 而且症状(502)离原因(example 里端口写错)很远。实际发生过:example 写 3000。
  123 + const example = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf-8');
  124 + const cfg = fs.readFileSync(path.join(ROOT, 'server/config.js'), 'utf-8');
  125 + const vite = fs.readFileSync(path.join(ROOT, 'web/vite.config.js'), 'utf-8');
  126 +
  127 + const examplePort = example.match(/^\s*(?:export\s+)?PORT\s*=\s*(\d+)/m)?.[1];
  128 + const cfgPort = cfg.match(/process\.env\.PORT\s*\|\|\s*(\d+)/)?.[1];
  129 + const vitePort = vite.match(/target:\s*['"]https?:\/\/localhost:(\d+)/)?.[1];
  130 +
  131 + expect(examplePort).toBeTruthy();
  132 + expect(cfgPort).toBeTruthy();
  133 + expect(vitePort).toBeTruthy();
  134 + expect(examplePort).toBe(cfgPort);
  135 + expect(examplePort).toBe(vitePort);
  136 + });
  137 +});
... ...