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 <cursoragent@cursor.com>
This commit is contained in:
+4
-4
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user