Files
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- 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
2026-09-26 02:00:14 +00:00

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")
}
}