fix(server): getOrCreateSession 增加会话归属校验(R-1a)

调用方传入 callerUserID;持久层与内存会话归属不匹配时返回 SESSION_FORBIDDEN;覆盖 NewSession 默认 UserID=1 占位。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-18 03:39:08 +00:00
co-authored by Cursor
parent 426af51365
commit ecdf1fc833
3 changed files with 65 additions and 13 deletions
+28 -5
View File
@@ -143,8 +143,8 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
return
}
sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "")
if sess == nil || sess.UserCtx == nil {
sess, err := s.getOrCreateSession(0, sessionID, "", "", "", "", "", "", "", "")
if err != nil || sess == nil || sess.UserCtx == nil {
writeSafeError(w, r, http.StatusNotFound, "session or project context not found")
return
}
@@ -169,7 +169,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
req.WorkDir = ""
sess := s.getOrCreateSession(
sess, err := s.getOrCreateSession(
user.ID,
req.SessionID,
req.ProjectName,
req.CustomerName,
@@ -180,7 +181,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
req.UserName,
req.UserRole,
)
sess.UserID = user.ID
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.StatusInternalServerError, "resolve session failed: "+err.Error())
return
}
s.hydrateVisibleHistory(r.Context(), sess, user.ID)
if req.ProjectID > 0 {
sess.ProjectID = req.ProjectID
@@ -982,6 +993,10 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req upsertChatSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -992,9 +1007,17 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return
}
req.UserID = user.ID
summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title)
summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title)
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.StatusInternalServerError, "upsert chat session failed: "+err.Error())
return
}
+24 -5
View File
@@ -391,8 +391,9 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int {
return cleaned
}
// getOrCreateSession 获取或动态构建会话
// getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。
func (s *Server) getOrCreateSession(
callerUserID int64,
sessionID string,
projectName string,
customerName string,
@@ -402,25 +403,38 @@ func (s *Server) getOrCreateSession(
workDir string,
userName string,
userRole string,
) *chat.Session {
) (*chat.Session, error) {
if sessionID == "" {
sessionID = fmt.Sprintf("sess_%d", time.Now().UnixNano())
}
// 归属校验必须先于任何缓存命中/持久层灌入
if callerUserID > 0 && s.ctxMgr != nil {
if owner, err := s.ctxMgr.GetSessionOwnerID(context.Background(), sessionID); err == nil && owner > 0 && owner != callerUserID {
return nil, ctxmgr.ErrSessionOwnerMismatch
}
}
s.sessionsMu.RLock()
existing, exists := s.sessions[sessionID]
s.sessionsMu.RUnlock()
if exists && existing != nil {
if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID {
return nil, ctxmgr.ErrSessionOwnerMismatch
}
existing.LastAccessedAt = time.Now()
return existing
return existing, nil
}
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
// 双重检查
if existing, exists := s.sessions[sessionID]; exists && existing != nil {
if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID {
return nil, ctxmgr.ErrSessionOwnerMismatch
}
existing.LastAccessedAt = time.Now()
return existing
return existing, nil
}
// 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话)
@@ -549,9 +563,14 @@ func (s *Server) getOrCreateSession(
}
}
// 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1 不能当作已绑定归属。
if callerUserID > 0 && (restored == nil || restored.UserID <= 0) {
sess.UserID = callerUserID
}
sess.LastAccessedAt = time.Now()
s.sessions[sessionID] = sess
return sess
return sess, nil
}
func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, viewerUserID int64) {
+13 -3
View File
@@ -173,7 +173,8 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
req.WorkDir = ""
// 会话与项目绑定必须在 SSE headers/Flush 之前完成授权(与 tasks/run/stream 对齐)
sess := s.getOrCreateSession(
sess, err := s.getOrCreateSession(
user.ID,
req.SessionID,
req.ProjectName,
req.CustomerName,
@@ -184,7 +185,17 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
req.UserName,
req.UserRole,
)
sess.UserID = req.UserID
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.StatusInternalServerError, "resolve session failed: "+err.Error())
return
}
s.hydrateVisibleHistory(r.Context(), sess, user.ID)
if req.ProjectID > 0 {
sess.ProjectID = req.ProjectID
@@ -306,7 +317,6 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
}
// 4. 同 session 串行执行后发起流式对话
var err error
execErr := sess.WithExecLock(func() error {
var sendErr error
_, sendErr = sess.SendMessageStreamTurn(ctx, req.Message, listener, req.IncludeDetails, turnOpts)