fix(agent): Bearer Token 改为常量时间比较并发布 v1.2.21
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.2.21] - 2026-09-27
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **Agent Bearer Token 改为常量时间比较**:`internal/agent` 原来用普通字符串比较校验 Token,现将 Authorization 解析与 Token 比较拆分,改用 `crypto/subtle.ConstantTimeCompare`,避免认证路径上的长度与内容时序泄漏
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 版本号提升到 1.2.21;`web/package.json` 与 `web/package-lock.json` 同步对齐
|
||||||
|
- `AgentVersion` 0.12.7 → **0.12.8**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `internal/agent`:新增认证测试,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization、错误认证方案
|
||||||
|
|
||||||
## [v1.2.20] - 2026-09-27
|
## [v1.2.20] - 2026-09-27
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+2
-2
@@ -1,14 +1,14 @@
|
|||||||
# Fonu 文档索引
|
# Fonu 文档索引
|
||||||
|
|
||||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
> 最近更新:2026-09-27(v1.2.20)
|
> 最近更新:2026-09-27(v1.2.21)
|
||||||
|
|
||||||
## 方案与设计(本目录)
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
| 文件 | 主题 | 状态 |
|
| 文件 | 主题 | 状态 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
|
| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 |
|
||||||
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1 已完成,其余待实施 |
|
| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3 已完成,其余待实施 |
|
||||||
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
|
| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 |
|
||||||
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
|
| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 |
|
||||||
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
|
| `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 |
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ Fonu 首次启动已创建管理员账户
|
|||||||
|
|
||||||
## 3.3 P0-3:Agent Token 使用常量时间比较
|
## 3.3 P0-3:Agent Token 使用常量时间比较
|
||||||
|
|
||||||
|
> 实施状态:2026-09-27 已完成。
|
||||||
|
|
||||||
### 3.3.1 现状
|
### 3.3.1 现状
|
||||||
|
|
||||||
`internal/agent/server.go` 的 `auth` 方法使用普通字符串比较:
|
`internal/agent/server.go` 的 `auth` 方法使用普通字符串比较:
|
||||||
@@ -370,6 +372,14 @@ if token == "" || !secureEqual(token, s.cfg.Token) {
|
|||||||
- 正确 Token 通过,错误 Token 拒绝。
|
- 正确 Token 通过,错误 Token 拒绝。
|
||||||
- Agent 不依赖应用侧 API 中间件代码。
|
- Agent 不依赖应用侧 API 中间件代码。
|
||||||
|
|
||||||
|
### 3.3.6 实施记录
|
||||||
|
|
||||||
|
- `internal/agent/server.go` 新增 `bearerToken` 与 `secureTokenEqual`,认证路径改用 `crypto/subtle.ConstantTimeCompare`。
|
||||||
|
- `AgentVersion` 已由 `0.12.7` 提升到 `0.12.8`。
|
||||||
|
- 新增 `internal/agent/auth_test.go`,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization 和错误认证方案。
|
||||||
|
- 内嵌 amd64 / arm64 二进制已重编;VPS 需要执行一次「升级 Agent」。
|
||||||
|
- 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`(17 条)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态
|
## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerAuthToken(t *testing.T) {
|
||||||
|
server := &Server{cfg: Config{Token: "secret-token"}}
|
||||||
|
handler := server.auth(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "正确 Token", header: "Bearer secret-token", status: http.StatusNoContent},
|
||||||
|
{name: "Bearer 大小写不敏感", header: "bearer secret-token", status: http.StatusNoContent},
|
||||||
|
{name: "多余空白可容忍", header: " Bearer secret-token ", status: http.StatusNoContent},
|
||||||
|
{name: "错误 Token", header: "Bearer wrong-token", status: http.StatusUnauthorized},
|
||||||
|
{name: "长度不同的 Token", header: "Bearer secret-token-longer", status: http.StatusUnauthorized},
|
||||||
|
{name: "缺少 Authorization", status: http.StatusUnauthorized},
|
||||||
|
{name: "认证方案错误", header: "Token secret-token", status: http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Authorization", tc.header)
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler(recorder, req)
|
||||||
|
if recorder.Code != tc.status {
|
||||||
|
t.Fatalf("期望状态 %d,实际 %d", tc.status, recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureTokenEqual(t *testing.T) {
|
||||||
|
if !secureTokenEqual("same-token", "same-token") {
|
||||||
|
t.Fatal("相同 Token 应通过")
|
||||||
|
}
|
||||||
|
if secureTokenEqual("short", "longer-token") {
|
||||||
|
t.Fatal("不同 Token 不应通过")
|
||||||
|
}
|
||||||
|
if secureTokenEqual("", "non-empty") {
|
||||||
|
t.Fatal("空 Token 不应通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -48,7 +49,8 @@ import (
|
|||||||
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
|
// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。
|
||||||
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
|
// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带),
|
||||||
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
|
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
|
||||||
const AgentVersion = "0.12.7"
|
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
|
||||||
|
const AgentVersion = "0.12.8"
|
||||||
|
|
||||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -206,7 +208,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
|
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token {
|
token := bearerToken(r)
|
||||||
|
if token == "" || !secureTokenEqual(token, s.cfg.Token) {
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,21 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bearerToken 解析 Authorization: Bearer <token>;格式不符时返回空串。
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(header[len(prefix):])
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureTokenEqual 使用常量时间比较,避免通过响应时间推测 Token 内容。
|
||||||
|
func secureTokenEqual(got, want string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.2.20",
|
"version": "1.2.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.2.20",
|
"version": "1.2.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "^6.11.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.20",
|
"version": "1.2.21",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user