mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-21 14:52:05 +02:00
fix: reject stale concurrent interception rule saves
This commit is contained in:
1 parent
4a1d0e114e
commit
1ecbd4054c
14 files changed
+295
-19
No files matched your search
@@ -98,6 +98,7 @@ type ConfigHandler struct {
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
toolGuard *toolguard.Manager
|
||||
toolGuardRevision string // protected by mu; advanced only after a successful save
|
||||
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -19,15 +21,28 @@ func (h *ConfigHandler) SetToolGuard(manager *toolguard.Manager) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.toolGuard = manager
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
}
|
||||
|
||||
// Call with h.mu held. The nonce fences no-op saves and ABA; the hash detects
|
||||
// policy changes made directly through the manager as well.
|
||||
func (h *ConfigHandler) toolGuardETag() string {
|
||||
if h.toolGuardRevision == "" {
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
}
|
||||
data, _ := json.Marshal(h.toolGuard.Config())
|
||||
return fmt.Sprintf(`"%s-%x"`, h.toolGuardRevision, sha256.Sum256(data))
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) GetToolGuard(c *gin.Context) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.toolGuard == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
|
||||
return
|
||||
}
|
||||
c.Header("ETag", h.toolGuardETag())
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, h.toolGuard.Config())
|
||||
}
|
||||
|
||||
@@ -72,6 +87,14 @@ func (h *ConfigHandler) UpdateToolGuard(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
|
||||
return
|
||||
}
|
||||
if c.GetHeader("If-Match") == "" {
|
||||
c.JSON(http.StatusPreconditionRequired, gin.H{"code": "tool_guard_version_required", "error": "缺少调用拦截配置版本,请刷新页面后重试"})
|
||||
return
|
||||
}
|
||||
if c.GetHeader("If-Match") != h.toolGuardETag() {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "tool_guard_conflict", "error": "调用拦截配置已被其他用户修改,本次修改未保存。请刷新页面获取最新配置后重试"})
|
||||
return
|
||||
}
|
||||
// Commit the file first; a validation/write failure must leave the current
|
||||
// effective policy and in-memory config intact.
|
||||
if err := h.saveToolGuardConfig(cfg); err != nil {
|
||||
@@ -85,11 +108,14 @@ func (h *ConfigHandler) UpdateToolGuard(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
h.config.ToolGuard = &cfg
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "config", "tool_guard_update", "更新调用拦截规则", "config", "tool_guard", map[string]interface{}{
|
||||
"enabled": cfg.Enabled, "rule_count": len(cfg.Rules),
|
||||
})
|
||||
}
|
||||
c.Header("ETag", h.toolGuardETag())
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, h.toolGuard.Config())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func toolGuardVersionRequest(t *testing.T, h *ConfigHandler, body interface{}, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return toolGuardRequest(t, func(c *gin.Context) {
|
||||
c.Request.Header.Set("If-Match", etag)
|
||||
h.UpdateToolGuard(c)
|
||||
}, body)
|
||||
}
|
||||
|
||||
func toolGuardUpdateRequest(t *testing.T, h *ConfigHandler, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
return toolGuardVersionRequest(t, h, body, etag)
|
||||
}
|
||||
|
||||
func TestToolGuardConcurrentSameVersionHasOneWinner(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
read := toolGuardRequest(t, h.GetToolGuard, nil)
|
||||
etag := read.Header().Get("ETag")
|
||||
if etag == "" || read.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("GET omitted version or cache protection")
|
||||
}
|
||||
const users = 12
|
||||
start := make(chan struct{})
|
||||
results := make(chan *httptest.ResponseRecorder, users)
|
||||
for i := 0; i < users; i++ {
|
||||
go func(i int) {
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Rules[0].Message = fmt.Sprintf("user-%d", i)
|
||||
<-start
|
||||
results <- toolGuardVersionRequest(t, h, cfg, etag)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
var winner toolguard.Config
|
||||
var winnerVersion string
|
||||
wins := 0
|
||||
for i := 0; i < users; i++ {
|
||||
w := <-results
|
||||
switch w.Code {
|
||||
case http.StatusOK:
|
||||
wins++
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &winner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
winnerVersion = w.Header().Get("ETag")
|
||||
if winnerVersion == "" || winnerVersion == etag {
|
||||
t.Fatal("successful save did not advance the version")
|
||||
}
|
||||
case http.StatusConflict:
|
||||
if !strings.Contains(w.Body.String(), "tool_guard_conflict") {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("got %d successful saves", wins)
|
||||
}
|
||||
loaded, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(winner, loaded.EffectiveToolGuard()) || !reflect.DeepEqual(winner, h.toolGuard.Config()) {
|
||||
t.Fatal("a losing request overwrote the winner")
|
||||
}
|
||||
if read := toolGuardRequest(t, h.GetToolGuard, nil); read.Header().Get("ETag") != winnerVersion {
|
||||
t.Fatal("save receipt and GET version differ")
|
||||
}
|
||||
// An old draft still fails even when its HTTP request is no longer concurrent.
|
||||
winner.Enabled = false
|
||||
if w := toolGuardVersionRequest(t, h, winner, etag); w.Code != http.StatusConflict {
|
||||
t.Fatal("sequential stale draft was accepted")
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, winner, winnerVersion); w.Code != http.StatusOK {
|
||||
t.Fatal("fresh version could not save", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardPreconditionsAndNoOpFence(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
cfg := toolguard.DefaultConfig()
|
||||
before, _ := os.ReadFile(h.configPath)
|
||||
for _, etag := range []string{"", "*", `W/"old"`, `"unknown"`} {
|
||||
w := toolGuardVersionRequest(t, h, cfg, etag)
|
||||
want := http.StatusConflict
|
||||
if etag == "" {
|
||||
want = http.StatusPreconditionRequired
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("got %d, want %d", w.Code, want)
|
||||
}
|
||||
}
|
||||
after, _ := os.ReadFile(h.configPath)
|
||||
if !bytes.Equal(before, after) || h.config.ToolGuard != nil || !reflect.DeepEqual(cfg, h.toolGuard.Config()) {
|
||||
t.Fatal("rejected request changed file or runtime")
|
||||
}
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusOK {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusConflict {
|
||||
t.Fatal("no-op save left old version valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardFailedPersistenceRetainsVersion(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
path := h.configPath
|
||||
h.configPath = path + "/missing.yaml"
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusInternalServerError {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag") != etag || !h.toolGuard.Config().Enabled {
|
||||
t.Fatal("failed persistence advanced version or changed policy")
|
||||
}
|
||||
h.configPath = path
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusOK {
|
||||
t.Fatal("safe retry failed", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardManagerUpdateInvalidatesVersion(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
if err := h.toolGuard.Update(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, toolguard.DefaultConfig(), etag); w.Code != http.StatusConflict || h.toolGuard.Config().Enabled {
|
||||
t.Fatal("direct policy update was overwritten")
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestToolGuardSavePersistsAndAppliesWithoutChangingHITL(t *testing.T) {
|
||||
}
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Rules[0].Message = "识别到 {match},禁止攻击政府网站,请检查目标。"
|
||||
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w := toolGuardUpdateRequest(t, h, cfg)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestToolGuardSavePersistsAndAppliesWithoutChangingHITL(t *testing.T) {
|
||||
t.Fatalf("updated message not applied: %+v", match)
|
||||
}
|
||||
cfg.Enabled = false
|
||||
w = toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w = toolGuardUpdateRequest(t, h, cfg)
|
||||
if w.Code != http.StatusOK || h.toolGuard.Check("scan", map[string]interface{}{"target": "agency.gov"}) != nil {
|
||||
t.Fatal("explicitly disabling protection did not apply")
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestToolGuardInvalidAndFailedSaveKeepEffectivePolicy(t *testing.T) {
|
||||
cfg.Enabled = false
|
||||
cfg.Rules[0].Pattern = "["
|
||||
for _, body := range []interface{}{cfg, map[string]interface{}{}, nil, map[string]interface{}{"enabled": false, "rules": nil}} {
|
||||
w := toolGuardRequest(t, h.UpdateToolGuard, body)
|
||||
w := toolGuardUpdateRequest(t, h, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid update accepted: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func TestToolGuardInvalidAndFailedSaveKeepEffectivePolicy(t *testing.T) {
|
||||
h.configPath = filepath.Join(t.TempDir(), "missing", "config.yaml")
|
||||
cfg = toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w := toolGuardUpdateRequest(t, h, cfg)
|
||||
if w.Code != http.StatusInternalServerError || !h.toolGuard.Config().Enabled || h.config.ToolGuard != nil {
|
||||
t.Fatal("failed persistence changed live configuration")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user