Compare commits

...

3 Commits

Author SHA1 Message Date
sagit c794586cc7 fix(security): require patched Go and Node.js runtimes (#566) 2026-10-22 11:15:28 +08:00
sagit d9303032ca feat: support username-free passkey login (#564) 2026-09-30 21:12:18 +08:00
sagit 6da8b2b6ec docs: clarify passkey setup after upgrades (#563) 2026-09-30 20:23:28 +08:00
21 changed files with 332 additions and 99 deletions
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
node-version-file: '.node-version'
- name: Install pnpm
run: npm install -g pnpm@10.28.1
@@ -54,7 +54,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -89,7 +89,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -112,7 +112,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
- name: Cache Go modules
uses: actions/cache@v4
@@ -197,7 +197,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
- name: Cache Go dependencies
uses: actions/cache@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.node-version'
registry-url: 'https://registry.npmjs.org'
- name: Get version from tag
+1
View File
@@ -0,0 +1 @@
22.23.4
+2 -1
View File
@@ -40,7 +40,8 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
- **Go versions**: all three modules require Go 1.26.10 or newer (CVE-2026-111943); CI reads the module's `go.mod`. Do not use Go 1.27.0–1.27.2, which also lack this fix.
- **Node.js versions**: `.node-version` pins 22.23.4 for CI; local builds also support patched 24.22.1+ and 26.12.1+ (CVE-2026-111497).
## Anti-patterns
+31 -8
View File
@@ -17,6 +17,12 @@
- 支持隧道修改配置、转发修改隧道
## 构建环境
Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27,须为 **1.27.3 或更新补丁版**。前端与 skill 发布使用 **Node.js 22.23.4**(见 `.node-version`),本地也支持 24.22.1+ 或 26.12.1+。前端使用 `pnpm@10.28.1`。
这些版本包含 2026 年 10 月的运行时安全修复。已有部署还需重新构建并更新后端镜像及所有 agent 二进制,详见 [安全排查与升级说明](docs/security/2026-10-runtime-updates.md)。
## 部署流程
---
### Docker Compose部署
@@ -73,25 +79,42 @@ docker compose up -d
#### 通行证密钥登录(可选)
通行证密钥登录不依赖 Cloudflare Turnstile,但必须在验证码可用时**预先绑定**。原密码登录保持可用;未绑定密钥的用户无法借此绕过验证码。
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
1. 在面板后端进程的环境中设置可信的、浏览器实际访问的公开源。例如在 Compose 的 `.env` 中添加:
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
```dotenv
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
```
该值必须是完整的 HTTPS origin(协议、域名和可选端口),不能带路径、查询参数或尾部 `/`。仅本机开发允许 `http://localhost:3000` 或 `http://127.0.0.1:3000`。在反向代理后部署时填浏览器地址栏中的**前端**公开源,而不是容器内部地址、后端监听地址或代理转发头。API 可以位于另一地址;WebAuthn 验证的是发起操作的前端页面 origin。Compose 模板会将变量传给后端容器;自行部署时需传给 `paneld` 进程。
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
2. 使环境变量对后端生效(Compose 部署可运行 `docker compose up -d backend`)。登录页出现“使用通行证密钥登录”按钮时表示浏览器支持该功能且配置有效。
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
3. 用户先照常用密码登录,在“个人”页面的“通行证密钥”卡片中输入**当前密码**、可选名称,然后选择“绑定通行证密钥”并完成设备解锁。建议保留至少一种可用的密码恢复途径。绑定或删除密钥均需当前密码;只有该账号能列出和删除自己的密钥。
```yaml
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
```
4. 以后在登录页输入用户名,选择“使用通行证密钥登录”,通过设备解锁即可进入面板。账号被停用时密钥登录也会被拒绝。需要撤销设备时,在个人页面输入当前密码并删除对应密钥。
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
数据库级备份会保留密钥记录。当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
**入口未显示时,按顺序检查:**
1. 在部署目录运行 `docker compose exec backend printenv FLVX_WEBAUTHN_ORIGIN`,仅检查这一项是否为预期的公开前端 origin;不要贴出完整 `docker compose config` 或其他环境变量,以免泄露密码和密钥。若为空,检查 `.env` 和实际 Compose 模板的 `backend.environment`,然后按步骤 2 重建后端。
2. 向浏览器实际调用的后端地址发送公开状态请求,例如将以下示例地址替换为自己的 API 地址后运行 `curl -sS -X POST 'https://api.example.com/api/v1/user/passkey/status' -H 'Content-Type: application/json' -d '{}'`。应返回 `code: 0` 且 `data.enabled: true`;若为 `false`,后端尚未接受有效配置。此接口无需登录或提供密码。
3. 在访问面板的浏览器控制台检查 `window.isSecureContext` 为 `true`,且 `typeof window.PublicKeyCredential` 不是 `"undefined"`。普通公网 HTTP 页面或不支持 WebAuthn 的浏览器不会显示入口。确认后刷新页面,并查看 `/api/v1/user/passkey/status` 的实际网络响应。
4. 若状态已启用且浏览器满足条件,检查 `docker compose images frontend` 中的前端镜像版本,并按步骤 2 的 PWA 提示刷新或使用无痕窗口。登录页按钮与个人资料页卡片均不存在时,通常是旧前端页面或状态请求未成功;只有其中一个缺失时,记录页面地址和该请求的响应再排查。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人资料页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
建议保留至少一种可用的密码恢复途径。数据库级备份会保留密钥记录;当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
#### 从 SQLite 迁移到 PostgreSQL
+37
View File
@@ -0,0 +1,37 @@
# 2026-10-22 运行时漏洞排查与升级
## 排查结论
| 漏洞 | 本项目涉及范围 | 修复措施 |
| --- | --- | --- |
| CVE-2026-109618、CVE-2026-109619(PHP RCE) | 仓库无 PHP 源码、Composer 依赖或 PHP 容器。项目后端为 Go,前端生产容器为 Nginx 静态服务。 | 本项目无 PHP 运行时需要更新;同机部署的其他 PHP 服务需单独排查。转发 PHP 网站的流量不会修复目标网站的 PHP。 |
| CVE-2026-111497(Node.js Brotli 越界写入) | 前端 Docker 构建使用 Node.js 22;CI 原使用已停止支持的 Node.js 20。Node.js 用于构建、开发及 skill 安装/发布,不是面板生产服务端。未发现项目源码直接调用 Brotli 解码,但构建依赖和下载工具仍运行于 Node.js。 | Docker、CI 和 `.node-version` 固定为 22.23.4;包声明仅接受已修复的 22.23.4+、24.22.1+、26.12.1+ 分支,前端启用 `engine-strict`。 |
| CVE-2026-111943(Go crypto/x509 中间证书信任) | 后端的版本检查、升级下载、授权验证等使用 HTTPS;agent 的更新下载、TLS 拨号和证书验证使用 Go 标准库。原 Go 1.25 工具链缺少此次拒绝信任修复。 | 三个 Go 模块最低版本升至 1.26.10,CI 从对应 `go.mod` 读取,后端 Docker 使用 1.26.10-bookworm。 |
Go 漏洞涉及一组特定中间证书,并非拒绝所有包含八层中间证书的证书链。官方修复在 `crypto/x509.Certificate.Verify` 中拒绝包含这些证书的链。Go 1.27.0–1.27.2 也未修复;手动选择 Go 1.27 时必须使用 1.27.3+。`go.mod` 的最低版本无法排除该较新分支里的旧补丁版本,因此发布必须使用仓库固定的工具链。
## 让已部署实例获得修复
1. 使用本次修改后的构建配置发布新版本,重新构建后端及前端镜像、Linux amd64/arm64 agent。Go 标准库编译进二进制,仅更新宿主机 Go 或 `ca-certificates` 不会替换已有二进制中的标准库。已有镜像标签也不会因源码更新自动修复。
2. 在面板实际部署目录,将 `.env` 中的 `FLUX_VERSION` 设置为包含本次修复的**已发布版本**,再执行 `docker compose pull backend frontend` 和 `docker compose up -d backend frontend`。如使用独立构建或私有仓库,应部署对应的新镜像。
3. 将所有节点的 agent 更新为同一安全版本并重启服务,包括离线节点。使用解压后的构建产物执行 `go version -m /path/to/gost` 或 `go version -m /path/to/paneld`,核对构建版本为 go1.26.10(或已修复的更新版本);UPX 压缩可能需要先对副本解压以读取构建信息。
4. 开发机和自托管构建机也需升级 Node.js。本次排查时本机为 v26.0.0,属于受影响版本;应升级至 26.12.1+,或切换到 `.node-version` 的 22.23.4。无需在运行静态前端的 Nginx 容器内安装 Node.js。
本次代码修改不表示远端部署已经更新;须完成新版本发布、镜像替换与节点升级后,才能确认线上获得修复。更新系统 CA 信任包可作为额外措施,但不能替代 Go 二进制重建。
## 本次验证
- Go 1.26.10 下,`go-backend`、`go-gost`、`go-gost/x` 各自的 `go test ./...` 全部通过。PostgreSQL 外部数据库契约测试仍需配置 `FLVX_POSTGRES_TEST_DSN` 后单独执行。
- Node.js 22.23.4、pnpm 10.28.1 下,前端 `pnpm install --frozen-lockfile` 与 `pnpm run build` 通过,无需更改依赖锁文件。
- 本机 Node.js 26.0.0 执行前端构建时,被 `engine-strict` 拒绝。
- 使用 `CGO_ENABLED=0` 交叉编译后端和 agent 的 Linux amd64/arm64 共四个二进制成功;`go version -m` 确认均为 go1.26.10。
- Docker Hub 上两个固定构建镜像标签均存在,且包含 Linux amd64/arm64。未执行 Docker 镜像构建或线上部署。
- 工作流 YAML、包 JSON 解析及 `git diff --check` 通过。
## 官方依据
- [PHP 官方安全公告](https://www.php.net/archive/2026.php#2026-10-21-4)
- [Node.js 2026-10-21 安全更新及修复版本](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
- [Go 官方问题 #82345](https://go.dev/issue/82345)
- [Go 漏洞库 GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809)
- [Go 官方发布记录](https://go.dev/doc/devel/release)
+2 -1
View File
@@ -1,4 +1,5 @@
FROM golang:1.25-bookworm AS builder
# CVE-2026-111943: crypto/x509 is linked into paneld; rebuild with the fixed Go release.
FROM golang:1.26.10-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
+1 -1
View File
@@ -1,6 +1,6 @@
module go-backend
go 1.25.0
go 1.26.10
require (
github.com/fxamacker/cbor/v2 v2.9.0
+40 -26
View File
@@ -60,7 +60,7 @@ func passkeyConfig() (*webauthn.WebAuthn, string) {
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementPreferred},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
@@ -112,6 +112,17 @@ func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (we
return c.session, true
}
func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) {
return passkeyCeremony{}, false
}
return c, true
}
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
@@ -178,7 +189,7 @@ func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u)
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
@@ -278,26 +289,16 @@ func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) {
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByUsername(strings.TrimSpace(req.Username))
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
u, err := h.loadPasskeyUser(user.ID)
if err != nil || len(u.credentials) == 0 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
options, session, err := wa.BeginLogin(u, webauthn.WithUserVerification(protocol.VerificationRequired))
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: user.ID, kind: "login", origin: origin, session: *session})
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
@@ -320,22 +321,35 @@ func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
// Login sessions carry the user ID; the caller cannot choose a different account at finish.
h.passkeyMu.Lock()
pending := h.passkeyPending[req.SessionID]
h.passkeyMu.Unlock()
session, ok := h.takePasskeyCeremony(req.SessionID, "login", origin, pending.userID)
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(pending.userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
// The credential ID determines ownership. A supplied userHandle cannot select another account.
if len(rawID) == 0 || len(userHandle) != 8 {
return nil, errInvalidPasskey
}
row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID))
if lookupErr != nil || row == nil {
return nil, errInvalidPasskey
}
owner, loadErr := h.loadPasskeyUser(row.UserID)
if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) {
return nil, errInvalidPasskey
}
return owner, nil
}, pending.session, credentialRequest(r, req.Credential))
var u passkeyUser
if err == nil {
var valid bool
u, valid = resolved.(passkeyUser)
if !valid {
err = errInvalidPasskey
}
}
credential, err := wa.FinishLogin(u, session, credentialRequest(r, req.Credential))
if err != nil || credential.Authenticator.CloneWarning {
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
@@ -9,21 +9,48 @@ import (
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); !ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("expired challenge was accepted")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok {
t.Fatal("wrong origin was accepted")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("failed discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok {
t.Fatal("valid discoverable challenge was rejected")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("expired discoverable challenge was accepted")
}
}
@@ -22,6 +22,17 @@ func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error)
return &key, err
}
// Credential IDs are unique across all users. Resolve the owner from the stored
// credential before checking the untrusted user handle in a discoverable login.
func (r *Repository) GetPasskeyByID(id string) (*model.Passkey, error) {
var key model.Passkey
err := r.db.Where("id = ?", id).Take(&key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &key, err
}
func (r *Repository) CreatePasskey(key *model.Passkey) error {
return r.db.Create(key).Error
}
@@ -62,7 +62,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
if status["enabled"] != false {
t.Fatalf("invalid origin enabled passkeys: %v", status)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-disabled"}); result.Code == 0 {
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}); result.Code == 0 {
t.Fatal("login begin succeeded with invalid origin")
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
@@ -70,7 +70,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
}
}
func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
func TestPasskeyRegistrationAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
@@ -90,6 +90,10 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
}
// The failed cross-user attempt consumes the challenge.
begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
selection := begin["options"].(map[string]interface{})["publicKey"].(map[string]interface{})["authenticatorSelection"].(map[string]interface{})
if selection["residentKey"] != "required" || selection["requireResidentKey"] != true {
t.Fatalf("registration did not require a discoverable credential: %v", selection)
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
@@ -113,45 +117,148 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 {
t.Fatal("Bob deleted Alice's credential")
}
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
bad := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), "https://wrong.example.test", "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": bad}); result.Code == 0 {
t.Fatal("wrong origin was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
assertion := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
loginBody := map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}
loginResult := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody)
if passkeyData(t, loginResult)["token"] == "" {
t.Fatal("passkey login returned no JWT")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody); result.Code == 0 {
t.Fatal("login challenge was reusable")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
badRP := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "wrong.example.test", true, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": badRP}); result.Code == 0 {
t.Fatal("wrong RP ID was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
noUV := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", false, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": noUV}); result.Code == 0 {
t.Fatal("assertion without user verification was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
reusedCounter := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": reusedCounter}); result.Code == 0 {
t.Fatal("reused nonzero signature counter was accepted")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 {
t.Fatal("delete did not require password re-verification")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 {
t.Fatalf("delete: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}); result.Code == 0 {
t.Fatal("deleted credential could still start login")
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}))
aliceHandle := make([]byte, 8)
binary.BigEndian.PutUint64(aliceHandle, 9302)
assertion := makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1, aliceHandle)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}); result.Code == 0 {
t.Fatal("deleted credential could still complete login")
}
// Deletion must not prevent the same account from binding a new key.
rebind := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
newID := make([]byte, 32)
if _, err := rand.Read(newID); err != nil {
t.Fatal(err)
}
newResponse := makeRegistrationResponse(t, privateKey, newID, passkeyChallenge(t, rebind), passkeyTestOrigin)
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": rebind["sessionId"], "credential": newResponse}); result.Code != 0 {
t.Fatalf("rebind after delete: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-alice", "password": "alice-password"}); result.Code != 0 {
t.Fatalf("password login regressed after enabling passkeys: %s", result.Msg)
}
}
func TestPasskeyDiscoverableLoginAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9311, "discover-alice", "alice-password")
seedLegacyUser(t, r, 9312, "discover-bob", "bob-password")
aliceToken, _ := auth.GenerateToken(9311, "discover-alice", 1, "passkey-test-secret")
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatal(err)
}
register := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
registration := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, register), passkeyTestOrigin)
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": register["sessionId"], "credential": registration}); result.Code != 0 {
t.Fatalf("register: %s", result.Msg)
}
aliceHandle := make([]byte, 8)
binary.BigEndian.PutUint64(aliceHandle, 9311)
bobHandle := make([]byte, 8)
binary.BigEndian.PutUint64(bobHandle, 9312)
begin := func(body interface{}) map[string]interface{} {
t.Helper()
return passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", body))
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "discover-alice"}); result.Code == 0 {
t.Fatal("username-based passkey fallback was accepted")
}
finish := func(session map[string]interface{}, assertion map[string]interface{}) response.R {
t.Helper()
return passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": session["sessionId"], "credential": assertion})
}
assertionFor := func(session map[string]interface{}, handle []byte, origin, rpID string, uv bool, count uint32) map[string]interface{} {
t.Helper()
return makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, session), origin, rpID, uv, count, handle)
}
// No username and no allowCredentials let the authenticator offer an account.
session := begin(map[string]string{})
options := session["options"].(map[string]interface{})["publicKey"].(map[string]interface{})
if list, exists := options["allowCredentials"]; exists && list != nil {
t.Fatalf("discoverable login unexpectedly restricted credentials: %v", list)
}
if options["userVerification"] != "required" {
t.Fatalf("discoverable login did not require UV: %v", options)
}
wrongHandle := assertionFor(session, bobHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, wrongHandle); result.Code == 0 {
t.Fatal("Bob's user handle selected Alice's credential")
}
if result := finish(session, wrongHandle); result.Code == 0 {
t.Fatal("failed discoverable challenge was reusable")
}
session = begin(map[string]string{})
// Older preferred resident-key registrations may have no discoverable userHandle.
missingHandle := assertionFor(session, nil, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, missingHandle); result.Code == 0 {
t.Fatal("discoverable login accepted a missing user handle")
}
session = begin(map[string]string{})
unknownID := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
unknownID["id"] = base64.RawURLEncoding.EncodeToString([]byte("unknown-credential"))
unknownID["rawId"] = unknownID["id"]
if result := finish(session, unknownID); result.Code == 0 {
t.Fatal("unknown credential ID selected Alice's account")
}
for _, test := range []struct {
name string
origin string
rpID string
uv bool
}{
{"origin", "https://wrong.example.test", "panel.example.test", true},
{"RP ID", passkeyTestOrigin, "wrong.example.test", true},
{"user verification", passkeyTestOrigin, "panel.example.test", false},
} {
session := begin(map[string]string{})
assertion := assertionFor(session, aliceHandle, test.origin, test.rpID, test.uv, 1)
if result := finish(session, assertion); result.Code == 0 {
t.Fatalf("invalid discoverable assertion was accepted: %s", test.name)
}
}
session = begin(map[string]string{})
badSignature := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
badSignature["response"].(map[string]interface{})["signature"] = base64.RawURLEncoding.EncodeToString([]byte("invalid-signature"))
if result := finish(session, badSignature); result.Code == 0 {
t.Fatal("invalid signature was accepted")
}
session = begin(map[string]string{})
valid := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if got := passkeyData(t, finish(session, valid))["name"]; got != "discover-alice" {
t.Fatalf("discoverable login selected the wrong account: %v", got)
}
if result := finish(session, valid); result.Code == 0 {
t.Fatal("discoverable assertion was replayed")
}
session = begin(map[string]string{})
reusedCounter := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
if result := finish(session, reusedCounter); result.Code == 0 {
t.Fatal("discoverable login accepted a reused nonzero signature counter")
}
if err := r.DB().Exec("UPDATE user SET status = 0 WHERE id = ?", 9311).Error; err != nil {
t.Fatal(err)
}
session = begin(map[string]string{})
disabled := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 2)
if result := finish(session, disabled); result.Code == 0 {
t.Fatal("disabled account logged in with a discoverable credential")
}
}
@@ -180,6 +287,10 @@ func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []b
}
func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} {
return makeAssertionResponseWithHandle(t, privateKey, id, challenge, origin, rpID, verified, count, nil)
}
func makeAssertionResponseWithHandle(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32, handle []byte) map[string]interface{} {
t.Helper()
rpHash := sha256.Sum256([]byte(rpID))
authData := append([]byte{}, rpHash[:]...)
@@ -197,5 +308,9 @@ func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte
if err != nil {
t.Fatal(err)
}
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": nil}}
var userHandle interface{}
if handle != nil {
userHandle = base64.RawURLEncoding.EncodeToString(handle)
}
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": userHandle}}
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/gost
go 1.25.0
go 1.26.10
require (
github.com/go-gost/core v0.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/x
go 1.25.0
go 1.26.10
require github.com/shirou/gopsutil/v3 v3.24.5
+3
View File
@@ -1,6 +1,9 @@
{
"name": "@flvx/skill-api",
"version": "2.1.9-rc4",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
+2 -1
View File
@@ -1 +1,2 @@
package-lock=true
package-lock=true
engine-strict=true
+2 -1
View File
@@ -1,5 +1,6 @@
# 多阶段构建 - 构建阶段
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
# CVE-2026-111497: use the patched Node.js Brotli decoder during builds.
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS builder
WORKDIR /app
+3
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.28.1",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"type": "module",
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -84,8 +84,8 @@ export interface PasskeyItem {
export const getPasskeyStatus = () =>
Network.post<{ enabled: boolean }>("/user/passkey/status");
export const beginPasskeyLogin = (username: string) =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", { username });
export const beginPasskeyLogin = () =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", {});
export const finishPasskeyLogin = (sessionId: string, credential: unknown) =>
Network.post<LoginResponse>("/user/passkey/login/finish", {
sessionId,
+3 -8
View File
@@ -55,14 +55,9 @@ export default function IndexPage() {
}, []);
const handlePasskeyLogin = async () => {
if (!form.username.trim()) {
toast.error("请输入用户名");
return;
}
setLoading(true);
try {
const begin = await beginPasskeyLogin(form.username.trim());
const begin = await beginPasskeyLogin();
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
@@ -282,9 +277,9 @@ export default function IndexPage() {
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={handlePasskeyLogin}
onPress={() => void handlePasskeyLogin()}
>
使用通行证密钥登录
选择通行证密钥登录
</Button>
)}
</div>