diff --git a/.env.example b/.env.example index 8c0df94..27af89b 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,19 @@ DB_USER=autodev DB_PASSWORD=autodev123 # 服务器配置 -PORT=3000 +# 3010 不是 3000:server/config.js 的默认值是 3010,web/vite.config.js 的代理目标 +# 也硬编码打 3010。这里写 3000 的话,npm run setup 装出来的环境里前端每个 API 调用都会 502。 +PORT=3010 HOST=0.0.0.0 # CORS_ORIGIN=http://localhost:5173,https://your-domain.com +# 会话签名密钥(必填) +# server/middleware/jwt-auth.js 用它签发登录 cookie。**不设它会静默降级到代码里的 +# 兜底值**,而那个兜底值提交在仓库里——任何读过这个仓库的人都能伪造任意用户的会话 +# (用户 id 是自增小整数,从 1 试起就能命中)。NODE_ENV=production 时不设它会拒绝启动。 +# 生成一个:openssl rand -hex 32 +JWT_SECRET=change-me-openssl-rand-hex-32 + # WhatsApp Bot export WHATSAPP_TRIGGER=@Andy export WHATSAPP_BOT_NAME=Andy diff --git a/server/middleware/jwt-auth.js b/server/middleware/jwt-auth.js index 99507ad..2fb1403 100644 --- a/server/middleware/jwt-auth.js +++ b/server/middleware/jwt-auth.js @@ -10,7 +10,34 @@ const jwt = require('jsonwebtoken'); const userDb = require('../services/user-db'); -const JWT_SECRET = process.env.JWT_SECRET || 'lemon-dev-secret-change-in-production'; +/** + * 会话签名密钥。 + * + * 兜底值只给本地开发用,而且**必须吵**:它提交在仓库里,任何读过仓库的人都能拿它 + * 签一个 `{userId: N}`。extractUser 会用 decoded.userId 回库重查(所以伪造 + * `role:'admin'` 这条路是堵住的),但 user-db 的 id 是自增小整数、不是 uuid, + * 从 1 试起就能命中真实账号 —— 于是「已知密钥」直接等于「任意账号会话伪造」, + * 命中的账号恰好是 admin 时就是完整的后台权限。 + * + * 以前这里是静默降级:.env.example 里没有 JWT_SECRET 这一项,而 npm run setup + * 就是从它生成 .env,所以照文档装出来的部署**必然**落在兜底值上,且启动时零告警。 + * 现在生产环境直接拒启(签名密钥缺失不是可以边跑边补的事),开发环境保留兜底但吼一声。 + */ +const DEV_FALLBACK_SECRET = 'lemon-dev-secret-change-in-production'; +const JWT_SECRET = process.env.JWT_SECRET || DEV_FALLBACK_SECRET; + +if (!process.env.JWT_SECRET) { + const lines = [ + 'JWT_SECRET 未设置,会话正在用仓库里公开的兜底密钥签名。', + '任何读过本仓库的人都能伪造任意用户的登录态(用户 id 是自增整数,可枚举)。', + '生成一个并写进 .env: openssl rand -hex 32', + ]; + if (process.env.NODE_ENV === 'production') { + throw new Error(`[jwt-auth] 拒绝以不安全配置启动 —— ${lines.join(' ')}`); + } + // 开发环境:不拦,但每次进程起来都提醒一次 + console.warn(`\n⚠️ [jwt-auth] ${lines.join('\n ')}\n`); +} const COOKIE_NAME = 'lemon_token'; const TOKEN_EXPIRY = '7d'; diff --git a/server/tests/unit/env-example-secrets.test.js b/server/tests/unit/env-example-secrets.test.js new file mode 100644 index 0000000..d693f6e --- /dev/null +++ b/server/tests/unit/env-example-secrets.test.js @@ -0,0 +1,137 @@ +/** + * Unit Tests — 防漂移闸:**有非空硬编码兜底的安全类 env,必须写进 .env.example** + * + * 挡的是已经发生过的这一条:`server/middleware/jwt-auth.js` 里 + * `process.env.JWT_SECRET || 'lemon-dev-secret-change-in-production'`, + * 而 `.env.example` 里**没有** JWT_SECRET 这一项 —— 偏偏 `npm run setup` 就是 + * 从 .env.example 生成 .env(见 CLAUDE.md「快速启动」),所以照文档装出来的部署 + * **必然**用那个提交在仓库里的密钥签会话,而且启动时一句告警都没有。 + * + * 危害不止「密钥弱」:extractUser 拿 decoded.userId 回库重查(伪造 role 这条路是堵住的), + * 但 user-db 的 id 是自增小整数、不是 uuid,从 1 试起就能命中真实账号 —— + * 已知密钥 = 任意账号会话伪造,命中 admin 就是完整后台权限。 + * + * ── 规则 ──────────────────────────────────────────────────────── + * 只管**非空**兜底。空串兜底(`process.env.FEISHU_ENCRYPT_KEY || ''`、 + * `OPEN_API_KEYS || ''`)是「未配置」的正当写法,缺了就 fail closed,不构成风险; + * 把它们也要求进 example 只会制造噪音,然后被加白名单,然后白名单腐烂。 + * 危险的恰恰是「缺了也能跑,而且跑得像配好了一样」那一类。 + * + * ponytail: 静态正则扫源码(先剥注释 —— 注释里举例不算代码,这个坑本仓库踩过两次)。 + * 认两种兜底写法:内联字面量,以及同文件内 `const X = '字面量'` 的命名常量 + * (后者是必须的 —— jwt-auth.js 就把兜底提成了常量,只认内联字面量时这条 guard + * 对它诞生的那个 case 恰好失明)。`??`、跨文件常量、拼接出来的兜底仍查不出来。 + * 名字匹配 SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL —— 宁可范围窄而准, + * 也不要宽到需要维护例外表。 + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../../..'); +const SECRETISH = /SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL/; + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .map((l) => l.replace(/\/\/.*$/, '')) + .join('\n'); +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules') continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (p.endsWith('.js')) out.push(p); + } + return out; +} + +/** 生产代码里「process.env.X || '兜底'」的全部命中 */ +function fallbacks() { + const dirs = ['server/services', 'server/src', 'server/routes', 'server/middleware', 'packages']; + const files = dirs.flatMap((d) => walk(path.join(ROOT, d))); + for (const f of ['server/config.js', 'server/index.js', 'server/agent-server.js']) { + files.push(path.join(ROOT, f)); + } + + const hits = []; + for (const f of files) { + if (!fs.existsSync(f)) continue; + const src = stripComments(fs.readFileSync(f, 'utf-8')); + + // 同文件内的 `const X = '字面量'` 绑定:兜底值经常先提成命名常量再用 + // (jwt-auth.js 就是这样 —— 只认内联字面量的话,这条 guard 对它诞生的那个 + // case 恰好失明,实测被自检当场抓到) + const consts = new Map(); + for (const c of src.matchAll(/\bconst\s+([A-Za-z0-9_$]+)\s*=\s*(['"`])([^'"`]*)\2\s*;/g)) { + consts.set(c[1], c[3]); + } + + // 兜底既可能是字面量,也可能是标识符 + for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)\s*\|\|\s*(?:(['"`])([^'"`]*)\2|([A-Za-z0-9_$]+))/g)) { + const literal = m[2] !== undefined ? m[3] : undefined; + const viaConst = m[4] !== undefined && consts.has(m[4]) ? consts.get(m[4]) : undefined; + const fallback = literal !== undefined ? literal : viaConst; + // 兜底是个查不出值的表达式(函数调用、另一个 env、数字…)→ 不猜,跳过 + if (fallback === undefined) continue; + hits.push({ name: m[1], fallback, file: path.relative(ROOT, f) }); + } + } + return hits; +} + +/** .env.example 里声明过的键(同时认 `X=` 和 `export X=` 两种写法) */ +function exampleKeys() { + const p = path.join(ROOT, '.env.example'); + const src = fs.readFileSync(p, 'utf-8'); + const keys = new Set(); + for (const line of src.split('\n')) { + const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/); + if (m) keys.add(m[1]); + } + return keys; +} + +describe('.env.example 必须覆盖有非空兜底的安全类 env', () => { + const hits = fallbacks(); + const keys = exampleKeys(); + + it('两侧都不是空的(防止 guard 因为路径/正则失效而静默空跑)', () => { + expect(hits.length).toBeGreaterThan(0); + expect(keys.size).toBeGreaterThan(3); + // JWT_SECRET 是本条 guard 的由来,它从任一侧消失都说明扫描口径坏了 + expect(hits.some((h) => h.name === 'JWT_SECRET')).toBe(true); + expect(keys.has('JWT_SECRET')).toBe(true); + }); + + it('每个「安全类 env + 非空硬编码兜底」都在 .env.example 里有一行', () => { + const missing = hits + .filter((h) => SECRETISH.test(h.name) && h.fallback !== '') + .filter((h) => !keys.has(h.name)) + .map((h) => `${h.name}(${h.file},兜底非空)未出现在 .env.example`); + + expect(missing).toEqual([]); + }); + + it('.env.example 的 PORT 与 config.js 默认值、vite 代理目标三者一致', () => { + // 这三处对不上时,npm run setup 装出来的环境前端每个 API 调用都 502, + // 而且症状(502)离原因(example 里端口写错)很远。实际发生过:example 写 3000。 + const example = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf-8'); + const cfg = fs.readFileSync(path.join(ROOT, 'server/config.js'), 'utf-8'); + const vite = fs.readFileSync(path.join(ROOT, 'web/vite.config.js'), 'utf-8'); + + const examplePort = example.match(/^\s*(?:export\s+)?PORT\s*=\s*(\d+)/m)?.[1]; + const cfgPort = cfg.match(/process\.env\.PORT\s*\|\|\s*(\d+)/)?.[1]; + const vitePort = vite.match(/target:\s*['"]https?:\/\/localhost:(\d+)/)?.[1]; + + expect(examplePort).toBeTruthy(); + expect(cfgPort).toBeTruthy(); + expect(vitePort).toBeTruthy(); + expect(examplePort).toBe(cfgPort); + expect(examplePort).toBe(vitePort); + }); +});