From fad7fac044f2bb9c4cb1c4b1b5b519dec8a3ae9f Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 10:42:58 +0000 Subject: [PATCH] fix(authz): close preference, session project, and recategorize IDOR gaps Bind preference writes and session-scoped project reads to the authenticated principal, and require an owned session_id for recategorize so empty ids cannot touch all messages. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 8 +- internal/server/chat_recategorize_api.go | 18 +++ internal/server/chat_recategorize_api_test.go | 18 ++- internal/server/chat_restore_test.go | 15 +- internal/server/handlers.go | 32 +++- internal/server/idor_hotfixes_test.go | 144 ++++++++++++++++++ 6 files changed, 217 insertions(+), 18 deletions(-) create mode 100644 internal/server/idor_hotfixes_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index d14bab4d..d2d547e4 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,8 +15,8 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| | Cursor-Auto | 2026-09-20-p0-hotfixes(Wave 0 文档) | docs/p0-triage-2026-09-20 | - | docs | done | -| Cursor-Auto | 2026-09-20-p0-capability-00058 | fix/capability-merge-00058-2026-09-20 | - | infra+chat | in_progress | # merging -| Cursor-Auto | 2026-09-20-p0-idor | fix/authz-idor-hotfixes-2026-09-20 | - | authz | planned | +| Cursor-Auto | 2026-09-20-p0-capability-00058 | fix/capability-merge-00058-2026-09-20 | - | infra+chat | done | +| Cursor-Auto | 2026-09-20-p0-idor | fix/authz-idor-hotfixes-2026-09-20 | - | authz | in_progress | | Cursor-Auto | 2026-09-20-p0-runtime-stability | fix/runtime-panic-races-2026-09-20 | - | taskrun+chat+web | planned | | Cursor-Auto | 2026-09-20-p0-credentials | fix/credential-hardening-2026-09-20 | - | ops+infra | planned | | Cursor-Auto | 2026-09-20-proposed-authoritative-reports | feature/capability-enablement-impact-plan | - | docgov | done | @@ -165,7 +165,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor-Auto | merging | 2026-09-20 | fix/capability-merge-00058 → schema 58 | fix/authz-idor → fix/runtime-panic-races → fix/credential-hardening | +| - | idle | 2026-09-20 | capability-00058 rebuild PASS schema 58;槽位释放 | fix/authz-idor → fix/runtime-panic-races → fix/credential-hardening | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -175,7 +175,7 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | **57** | healthy(实测;goose 跳过 00054;P0 Wave 1 将升 **58**;`pi-agent`;events `:8088`) | +| dev | 8080 | **58** | healthy(`chat_summaries` 已落地;`pi-agent`;events `:8088`) | | test | 8081 | **57** | healthy(含 triage `00057`;分支树最高仍可能为 56——晋升前须对齐;events `:8089`) | | main | 8000 | **52** | healthy(分支树仅到 00025,环境超前——对齐晋升记 backlog;events `:8090`) | diff --git a/internal/server/chat_recategorize_api.go b/internal/server/chat_recategorize_api.go index 2259a370..36603518 100644 --- a/internal/server/chat_recategorize_api.go +++ b/internal/server/chat_recategorize_api.go @@ -5,6 +5,7 @@ package server import ( "encoding/json" "net/http" + "strings" ) type recategorizeReq struct { @@ -12,6 +13,10 @@ type recategorizeReq struct { } func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } if s.ctxMgr == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable") return @@ -21,6 +26,19 @@ func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } + req.SessionID = strings.TrimSpace(req.SessionID) + if req.SessionID == "" { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "success": false, + "error": "session_id is required", + }) + return + } + if !s.isSystemAdminUser(r.Context(), user) { + if !s.assertSessionOwnedByUser(w, r, req.SessionID, user.ID) { + return + } + } res, err := s.ctxMgr.RecategorizeMessages(r.Context(), req.SessionID) if err != nil { diff --git a/internal/server/chat_recategorize_api_test.go b/internal/server/chat_recategorize_api_test.go index cdc8f74f..3d04bc52 100644 --- a/internal/server/chat_recategorize_api_test.go +++ b/internal/server/chat_recategorize_api_test.go @@ -14,27 +14,33 @@ import ( func TestServer_ChatRecategorizeAPI(t *testing.T) { cfg := &config.Config{ - Env: "dev", - HTTPHost: "127.0.0.1", - HTTPPort: 8892, - DataDir: t.TempDir(), + Env: "dev", + HTTPHost: "127.0.0.1", + HTTPPort: 8892, + DataDir: t.TempDir(), + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + AgentBackend: "stub", } svc := bizdev.MustNewService(t, cfg) srv := server.NewServer(cfg, svc) handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) sessID := "sess_recategorize_api_001" ctx := t.Context() ctxMgr := bizdev.Snapshot(svc).Ctx - // 注入种子消息 + if _, err := ctxMgr.EnsureChatSession(ctx, sessID, 1, 0, "recategorize"); err != nil { + t.Fatalf("ensure session: %v", err) + } _, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "请问二类注册审批周期?", "QUERY_REGULATIONS", "", nil) _, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", "根据现有规定,一般需 6-9 个月。", "", "", nil) - // 调用 POST /api/v1/chat/recategorize reqBody := `{"session_id":"` + sessID + `"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/recategorize", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, adminToken) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/internal/server/chat_restore_test.go b/internal/server/chat_restore_test.go index 86677993..e2a0fc37 100644 --- a/internal/server/chat_restore_test.go +++ b/internal/server/chat_restore_test.go @@ -17,13 +17,17 @@ import ( func TestServer_ChatRestoreAfterRestart(t *testing.T) { cfg := &config.Config{ - HTTPHost: "127.0.0.1", - HTTPPort: 8889, - DataDir: t.TempDir(), + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + AgentBackend: "stub", } svc := bizdev.MustNewService(t, cfg) srv := server.NewServer(cfg, svc) handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) sessID := "sess_restore_test_001" userID := int64(88) @@ -111,12 +115,13 @@ func TestServer_ChatRestoreAfterRestart(t *testing.T) { t.Errorf("turn 2 user message mismatch: %+v", allMsgs[2]) } - // 通过 /api/v1/projects 检查恢复后的上下文事实 + // 通过 /api/v1/projects 检查恢复后的上下文事实(须带认证) reqCtx := httptest.NewRequest("GET", "/api/v1/projects?session_id="+sessID, nil) + withBearerAuth(reqCtx, adminToken) rrCtx := httptest.NewRecorder() handler.ServeHTTP(rrCtx, reqCtx) if rrCtx.Code != http.StatusOK { - t.Fatalf("GET /api/v1/projects failed: %d", rrCtx.Code) + t.Fatalf("GET /api/v1/projects failed: %d %s", rrCtx.Code, rrCtx.Body.String()) } var ctxMap map[string]any _ = json.Unmarshal(rrCtx.Body.Bytes(), &ctxMap) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f0acf80a..a81b67c5 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -161,14 +161,30 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } sessionID := r.URL.Query().Get("session_id") if sessionID == "" { writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param") return } - sess, err := s.getOrCreateSession(r.Context(), 0, sessionID, "", "", "", "", "", "", "", "") - if err != nil || sess == nil || sess.UserCtx == nil { + // 传入真实 caller,触发 getOrCreateSession 归属校验;无 owner 的遗留会话仍可读。 + sess, err := s.getOrCreateSession(r.Context(), user.ID, sessionID, "", "", "", "", "", "", "", "") + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusNotFound, "session or project context not found") + return + } + if sess == nil || sess.UserCtx == nil { writeSafeError(w, r, http.StatusNotFound, "session or project context not found") return } @@ -506,13 +522,23 @@ type UpdateUserPreferenceRequest struct { // handleUpdateUserPreference 定期整理或前端保存用户习惯偏好 func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } var req UpdateUserPreferenceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } if req.UserID <= 0 { - writeSafeError(w, r, http.StatusBadRequest, "user_id must be positive") + req.UserID = user.ID + } + if req.UserID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权修改其他用户的偏好", + }) return } diff --git a/internal/server/idor_hotfixes_test.go b/internal/server/idor_hotfixes_test.go new file mode 100644 index 00000000..bab75ab6 --- /dev/null +++ b/internal/server/idor_hotfixes_test.go @@ -0,0 +1,144 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +func TestUpdateUserPreference_CrossUserIs403(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AdminUsername: "admin", AdminPassword: "admin-test-pass"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) + + suffix := time.Now().Format("150405.000") + aliceToken := createAndLoginUser(t, handler, adminToken, "pref_alice_"+suffix, "pass12345") + bobToken := createAndLoginUser(t, handler, adminToken, "pref_bob_"+suffix, "pass12345") + + // resolve alice user id via /auth/me + meReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + withBearerAuth(meReq, aliceToken) + meRec := httptest.NewRecorder() + handler.ServeHTTP(meRec, meReq) + if meRec.Code != http.StatusOK { + t.Fatalf("auth/me: %d %s", meRec.Code, meRec.Body.String()) + } + var me map[string]any + _ = json.Unmarshal(meRec.Body.Bytes(), &me) + userObj, _ := me["user"].(map[string]any) + aliceID, _ := userObj["id"].(float64) + if aliceID <= 0 { + t.Fatalf("cannot resolve alice id from me: %s", meRec.Body.String()) + } + + body, _ := json.Marshal(map[string]any{ + "user_id": int64(aliceID), + "communication_style": "hijacked", + "custom_instructions": "inject prompt as bob", + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/context/user/preference", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, bobToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 cross-user preference write, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestGetProject_OtherUserSessionDenied(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AdminUsername: "admin", AdminPassword: "admin-test-pass"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) + + suffix := time.Now().Format("150405.000") + victimToken := createAndLoginUser(t, handler, adminToken, "proj_victim_"+suffix, "pass12345") + attackerToken := createAndLoginUser(t, handler, adminToken, "proj_attacker_"+suffix, "pass12345") + sessionID := "sess-proj-idor-" + suffix + + chatBody, _ := json.Marshal(map[string]any{ + "session_id": sessionID, + "project_name": "IDOR会话项目", + "message": "私密立项上下文", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(chatBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, victimToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("victim chat: %d %s", rec.Code, rec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/projects?session_id="+sessionID, nil) + withBearerAuth(getReq, attackerToken) + getRec := httptest.NewRecorder() + handler.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusForbidden { + t.Fatalf("expected 403 other-user session project read, got %d %s", getRec.Code, getRec.Body.String()) + } +} + +func TestChatRecategorize_RequiresOwnedSession(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AdminUsername: "admin", AdminPassword: "admin-test-pass"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) + + suffix := time.Now().Format("150405.000") + ownerToken := createAndLoginUser(t, handler, adminToken, "recat_owner_"+suffix, "pass12345") + otherToken := createAndLoginUser(t, handler, adminToken, "recat_other_"+suffix, "pass12345") + sessionID := "sess-recat-idor-" + suffix + + chatBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "需要重分类的消息"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(chatBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, ownerToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("owner chat: %d %s", rec.Code, rec.Body.String()) + } + + emptyBody := bytes.NewReader([]byte(`{}`)) + emptyReq := httptest.NewRequest(http.MethodPost, "/api/v1/chat/recategorize", emptyBody) + emptyReq.Header.Set("Content-Type", "application/json") + withBearerAuth(emptyReq, ownerToken) + emptyRec := httptest.NewRecorder() + handler.ServeHTTP(emptyRec, emptyReq) + if emptyRec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty session_id, got %d %s", emptyRec.Code, emptyRec.Body.String()) + } + + otherBody, _ := json.Marshal(map[string]string{"session_id": sessionID}) + otherReq := httptest.NewRequest(http.MethodPost, "/api/v1/chat/recategorize", bytes.NewReader(otherBody)) + otherReq.Header.Set("Content-Type", "application/json") + withBearerAuth(otherReq, otherToken) + otherRec := httptest.NewRecorder() + handler.ServeHTTP(otherRec, otherReq) + if otherRec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for other user's session, got %d %s", otherRec.Code, otherRec.Body.String()) + } + + ownBody, _ := json.Marshal(map[string]string{"session_id": sessionID}) + ownReq := httptest.NewRequest(http.MethodPost, "/api/v1/chat/recategorize", bytes.NewReader(ownBody)) + ownReq.Header.Set("Content-Type", "application/json") + withBearerAuth(ownReq, ownerToken) + ownRec := httptest.NewRecorder() + handler.ServeHTTP(ownRec, ownReq) + if ownRec.Code != http.StatusOK { + t.Fatalf("expected 200 for owned session, got %d %s", ownRec.Code, ownRec.Body.String()) + } +}