From 289cf6a24f037d7b3d41b12831edaae04b25cc25 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:56:39 -0400 Subject: [PATCH] =?UTF-8?q?fix(security):=20jwt.key=20=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E5=8F=AF=E6=B5=8F=E8=A7=88=E5=B7=A5=E4=BD=9C=E5=8C=BA(?= =?UTF-8?q?=E5=AD=98=E9=A1=B9=E7=9B=AE=E6=A0=B9,=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=BB=8E=20data=20=E8=BF=81=E7=A7=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作空间文件管理器的根目录是 dataDir(s.m.dir),而 jwt.key 原来就存在 dataDir/jwt.key —— 会被列出/下载(签名密钥泄露→可伪造 token)/删除(重启重生 成→全体登出)。 - loadOrCreateJWTKey(keyDir, dataDir):密钥改存 keyDir/jwt.key;若旧 data/jwt.key 仍在,启动时迁移(保留密钥不登出、删旧文件从工作区消失) - New() 增加 keyDir 参数;生产传 config.BaseDir()(项目根),在 dataDir 之外, wsResolve 的路径限定使其不可列/读/下载/删 - 测试传临时目录,隔离且不触发迁移;.gitignore 忽略根目录 /jwt.key 注:项目根在 Docker 里不持久化,容器重建会重新生成密钥、需重新登录(单人系统可接受)。 --- .gitignore | 3 +++ cmd/artex/main.go | 2 +- server/auth.go | 24 ++++++++++++++++++++---- server/core_test.go | 2 +- server/mgmt_test.go | 2 +- server/server.go | 4 ++-- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 6e12987..6e9f785 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,8 @@ Thumbs.db /server/webui/dist/ /dist/ +# JWT 签名密钥(运行时生成,存项目根,绝不提交) +/jwt.key + # Docker 部署生成的本地环境(含密码/密钥,不提交) .env \ No newline at end of file diff --git a/cmd/artex/main.go b/cmd/artex/main.go index 2bda91b..a2b6611 100644 --- a/cmd/artex/main.go +++ b/cmd/artex/main.go @@ -80,7 +80,7 @@ func main() { skillDir = abs } log.Printf("[config] skill 目录: %s", skillDir) - srv := server.New(ctx, mgr, skillDir, *dataDir) + srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir()) httpSrv := &http.Server{ Addr: *addr, Handler: srv.Handler(), diff --git a/server/auth.go b/server/auth.go index 26aee9e..2fa398b 100644 --- a/server/auth.go +++ b/server/auth.go @@ -22,10 +22,26 @@ const ( keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ) -// loadOrCreateJWTKey reads the 32-byte signing key from dataDir/jwt.key. -// On first run it generates a cryptographically random key and persists it. -func loadOrCreateJWTKey(dataDir string) ([]byte, error) { - path := filepath.Join(dataDir, jwtKeyFilename) +// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is +// the project base dir (next to the executable), NOT the browsable workspace root +// (dataDir) — the signing key must never be listable/downloadable via the file +// manager. Legacy installs kept it at dataDir/jwt.key; if present there and not yet +// at the new location, it is migrated (key preserved, so sessions stay valid) and +// the old file removed so it disappears from the workspace. On first run a random +// key is generated and persisted. +func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) { + path := filepath.Join(keyDir, jwtKeyFilename) + // one-time migration out of the old in-workspace location. + if legacy := filepath.Join(dataDir, jwtKeyFilename); legacy != path { + if _, err := os.Stat(path); os.IsNotExist(err) { + if data, rerr := os.ReadFile(legacy); rerr == nil { + if werr := os.WriteFile(path, data, 0o600); werr == nil { + _ = os.Remove(legacy) + log.Printf("[auth] JWT key 已从 %s 迁移到 %s(移出可浏览工作区)", legacy, path) + } + } + } + } if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 { return []byte(strings.TrimSpace(string(data))), nil } diff --git a/server/core_test.go b/server/core_test.go index ae9c8ca..49e9417 100644 --- a/server/core_test.go +++ b/server/core_test.go @@ -22,7 +22,7 @@ func TestCoreTaskLifecyclePG(t *testing.T) { } defer m.Close() td := t.TempDir() - s := New(context.Background(), m, td, td) + s := New(context.Background(), m, td, td, td) h := s.Handler() tok, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/mgmt_test.go b/server/mgmt_test.go index cb2c54d..9bfebb7 100644 --- a/server/mgmt_test.go +++ b/server/mgmt_test.go @@ -19,7 +19,7 @@ func TestMgmtAPI(t *testing.T) { t.Skip("postgres unavailable — skipping management API test") } td := t.TempDir() - s := New(context.Background(), m, td, td) + s := New(context.Background(), m, td, td, td) h := s.Handler() tok, err := signJWT(s.jwtKey) if err != nil { diff --git a/server/server.go b/server/server.go index 0729c8d..0ec0271 100644 --- a/server/server.go +++ b/server/server.go @@ -97,8 +97,8 @@ type triggeredRun struct { mergeable bool // true for finding/goal event triggers (merge by taskID) } -func New(ctx context.Context, m *Manager, skillDir string, dataDir string) *Server { - key, err := loadOrCreateJWTKey(dataDir) +func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string) *Server { + key, err := loadOrCreateJWTKey(keyDir, dataDir) if err != nil { log.Fatalf("[auth] JWT key: %v", err) }