- FIND-SEC-01: GetUserContext requires session ownership (handler 403 + SQL/memory owner filter); in-memory HTTP + isolated-PG regressions - FIND-ARCH-01: archtest fails closed on untracked table writes (+noise exemption) and registers 50 existing writer tables in table_owners - FIND-SEC-02: Authorizer without pool denies by default; explicit NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition - FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact basename allowlist match; prefix-spoof and no-shell-eval tests - FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed hard-refuses validated_only steps (new error codes + HTTP mapping) - FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
269 lines
10 KiB
Go
269 lines
10 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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())
|
|
}
|
|
}
|
|
|
|
// TestGetUserContext_OtherUserSessionDenied FIND-SEC-01 回归:
|
|
// GetUserContext 旁路按 session_id 取消息必须校验会话归属。
|
|
func TestGetUserContext_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, "ctx_victim_"+suffix, "pass12345")
|
|
attackerToken := createAndLoginUser(t, handler, adminToken, "ctx_attacker_"+suffix, "pass12345")
|
|
sessionID := "sess-ctx-idor-" + suffix
|
|
|
|
chatBody, _ := json.Marshal(map[string]any{
|
|
"session_id": sessionID,
|
|
"message": "victim-private-context-body",
|
|
})
|
|
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())
|
|
}
|
|
|
|
// 攻击者用自己的 user_id + 受害者 session_id:必须 403。
|
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/context/user?session_id="+sessionID, nil)
|
|
withBearerAuth(getReq, attackerToken)
|
|
getRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(getRec, getReq)
|
|
if getRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for other user's session context, got %d %s", getRec.Code, getRec.Body.String())
|
|
}
|
|
if strings.Contains(getRec.Body.String(), "victim-private-context-body") {
|
|
t.Fatalf("response must not leak victim messages: %s", getRec.Body.String())
|
|
}
|
|
|
|
// 不存在的会话同样 403(不泄露存在性)。
|
|
ghostReq := httptest.NewRequest(http.MethodGet, "/api/v1/context/user?session_id=sess-ctx-idor-ghost", nil)
|
|
withBearerAuth(ghostReq, attackerToken)
|
|
ghostRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(ghostRec, ghostReq)
|
|
if ghostRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for unknown session, got %d %s", ghostRec.Code, ghostRec.Body.String())
|
|
}
|
|
|
|
// 会话归属者本人仍可正常取回上下文。
|
|
ownReq := httptest.NewRequest(http.MethodGet, "/api/v1/context/user?session_id="+sessionID, nil)
|
|
withBearerAuth(ownReq, victimToken)
|
|
ownRec := httptest.NewRecorder()
|
|
handler.ServeHTTP(ownRec, ownReq)
|
|
if ownRec.Code != http.StatusOK {
|
|
t.Fatalf("owner context read failed: %d %s", ownRec.Code, ownRec.Body.String())
|
|
}
|
|
if !strings.Contains(ownRec.Body.String(), "victim-private-context-body") {
|
|
t.Fatalf("owner should see own session messages: %s", ownRec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetUserContext_PG_SessionOwnerFilter FIND-SEC-01 PG 层防御纵深回归:
|
|
// GetUserContext 的 chat_messages 查询必须按会话归属过滤,DB 层也不泄露
|
|
// 他人会话正文(即使未来调用方遗漏 handler 校验)。
|
|
func TestGetUserContext_PG_SessionOwnerFilter(t *testing.T) {
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
t.Skip("PG not available")
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
handler, srv := setupPGGovernanceServer(t, pool)
|
|
ctxMgr := bizdev.Snapshot(srv.BizdevService()).Ctx
|
|
if ctxMgr == nil {
|
|
t.Fatal("context manager required")
|
|
}
|
|
|
|
ownerToken := loginTestUser(t, handler, "zhanggong", "123456")
|
|
ownerID := fetchAuthUserID(t, handler, ownerToken)
|
|
otherToken := loginTestUser(t, handler, "lizhuguan", "123456")
|
|
otherID := fetchAuthUserID(t, handler, otherToken)
|
|
|
|
suffix := time.Now().Format("150405.000")
|
|
sessionID := "sess-pg-ctx-idor-" + suffix
|
|
if _, err := ctxMgr.EnsureChatSession(ctx, sessionID, ownerID, 0, "PG IDOR 回归"); err != nil {
|
|
t.Fatalf("ensure session: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO chat_messages (session_id, user_id, role, content, created_at)
|
|
VALUES ($1, $2, 'user', 'pg-owner-private-body', now())
|
|
`, sessionID, ownerID); err != nil {
|
|
t.Fatalf("seed message: %v", err)
|
|
}
|
|
|
|
// 他人视角:manager 层不返回会话正文。
|
|
foreign, err := ctxMgr.GetUserContext(ctx, 0, otherID, sessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, m := range foreign.RecentChats {
|
|
if strings.Contains(m.Content, "pg-owner-private-body") {
|
|
t.Fatalf("foreign user must not read owner messages: %+v", m)
|
|
}
|
|
}
|
|
|
|
// 归属者视角:正常取回。
|
|
own, err := ctxMgr.GetUserContext(ctx, 0, ownerID, sessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, m := range own.RecentChats {
|
|
if strings.Contains(m.Content, "pg-owner-private-body") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("owner should read own session messages")
|
|
}
|
|
}
|