Commit f658bd053de051d7d51ae95c8ec3f2dd520321f2
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>
Showing
3 changed files
with
175 additions
and
2 deletions
.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 | +}); | ... | ... |