mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(security-audit): reject unavailable prompt config
This commit is contained in:
@@ -44,7 +44,7 @@ type ConfigStore interface {
|
||||
// It must stay false when blocking is not intended, even if config is
|
||||
// untrusted—otherwise default-off deployments fail closed for all traffic.
|
||||
BlockingActivationDegraded() bool
|
||||
Public() PublicConfig
|
||||
Public() (PublicConfig, error)
|
||||
Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error)
|
||||
RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string)
|
||||
Encrypt(value string) (string, error)
|
||||
|
||||
@@ -194,15 +194,15 @@ func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Public() PublicConfig {
|
||||
func (m *ConfigManager) Public() (PublicConfig, error) {
|
||||
if m == nil {
|
||||
return PublicFromStorage(DefaultStorageConfig(), false)
|
||||
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
|
||||
}
|
||||
snapshot := m.snapshot.Load()
|
||||
if snapshot == nil {
|
||||
return PublicFromStorage(DefaultStorageConfig(), false)
|
||||
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
|
||||
}
|
||||
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled)
|
||||
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||||
|
||||
@@ -56,6 +56,57 @@ func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) {
|
||||
require.LessOrEqual(t, len([]rune(message)), 160)
|
||||
}
|
||||
|
||||
func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
|
||||
t.Run("absent persisted setting is legitimate default", func(t *testing.T) {
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: "",
|
||||
SettingKeyRiskControl: "false",
|
||||
}}, nil, prefixEncryptor{})
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
|
||||
public, err := manager.Public()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), public.ConfigVersion)
|
||||
require.False(t, public.Enabled)
|
||||
})
|
||||
|
||||
t.Run("persisted config activation failure is unavailable", func(t *testing.T) {
|
||||
const canary = "persisted-token-canary"
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
|
||||
SettingKeyRiskControl: "true",
|
||||
}}, nil, prefixEncryptor{})
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
|
||||
public, err := manager.Public()
|
||||
require.Error(t, err)
|
||||
require.Empty(t, public)
|
||||
require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err))
|
||||
require.NotContains(t, err.Error(), canary)
|
||||
})
|
||||
|
||||
t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) {
|
||||
storage := DefaultStorageConfig()
|
||||
storage.ConfigVersion = 4
|
||||
storage.ChangeSummary = "trusted snapshot"
|
||||
raw, err := json.Marshal(storage)
|
||||
require.NoError(t, err)
|
||||
repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: string(raw),
|
||||
SettingKeyRiskControl: "false",
|
||||
}}}
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
repository.loadErr = errors.New("settings unavailable")
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
|
||||
public, err := manager.Public()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), public.ConfigVersion)
|
||||
require.Equal(t, "trusted snapshot", public.ChangeSummary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}}
|
||||
current := DefaultStorageConfig()
|
||||
@@ -137,6 +188,18 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string
|
||||
return nil, errors.New("settings unavailable")
|
||||
}
|
||||
|
||||
type switchableSettingRepository struct {
|
||||
staticSettingRepository
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
if r.loadErr != nil {
|
||||
return nil, r.loadErr
|
||||
}
|
||||
return r.staticSettingRepository.GetMultiple(ctx, keys)
|
||||
}
|
||||
|
||||
func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
|
||||
// Settings unavailable and no prior blocking intent: stay ModeOff so the
|
||||
// gateway remains usable and admins can still disable/configure Prompt Audit.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type PromptAdminService interface {
|
||||
GetConfig() PublicConfig
|
||||
GetConfig() (PublicConfig, error)
|
||||
SaveConfig(context.Context, UpdateConfigRequest, int64) (PublicConfig, error)
|
||||
Probe(context.Context, ProbeRequest) ProbeResult
|
||||
Runtime(context.Context) RuntimeSnapshot
|
||||
@@ -31,7 +31,14 @@ func NewPromptAdminHandler(service PromptAdminService) *PromptAdminHandler {
|
||||
return &PromptAdminHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *PromptAdminHandler) GetConfig(c *gin.Context) { response.Success(c, h.service.GetConfig()) }
|
||||
func (h *PromptAdminHandler) GetConfig(c *gin.Context) {
|
||||
config, err := h.service.GetConfig()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, config)
|
||||
}
|
||||
|
||||
func (h *PromptAdminHandler) UpdateConfig(c *gin.Context) {
|
||||
var request UpdateConfigRequest
|
||||
|
||||
@@ -29,7 +29,9 @@ type fakePromptAdminService struct {
|
||||
deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error)
|
||||
}
|
||||
|
||||
func (s *fakePromptAdminService) GetConfig() PublicConfig { return s.config }
|
||||
func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) {
|
||||
return s.config, nil
|
||||
}
|
||||
func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||||
if s.save == nil {
|
||||
return PublicConfig{}, errors.New("unexpected SaveConfig call")
|
||||
@@ -156,6 +158,21 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
|
||||
const canary = "persisted-config-secret-canary"
|
||||
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
service := &PromptService{config: manager}
|
||||
|
||||
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil)
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||
require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable)
|
||||
require.NotContains(t, response.Body.String(), canary)
|
||||
require.NotContains(t, response.Body.String(), `"config_version"`)
|
||||
require.NotContains(t, response.Body.String(), `"token"`)
|
||||
}
|
||||
|
||||
func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) {
|
||||
const canary = "probe-token-canary"
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -166,7 +166,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis
|
||||
return s.evaluator.Evaluate(ctx, cfg, snapshot)
|
||||
}
|
||||
|
||||
func (s *PromptService) GetConfig() PublicConfig { return s.config.Public() }
|
||||
func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() }
|
||||
|
||||
func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||||
return s.config.Save(ctx, req, actorID)
|
||||
|
||||
@@ -12,11 +12,12 @@ const (
|
||||
ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate"
|
||||
PayloadKeyPrefix = "sub2api:prompt_audit:payload:"
|
||||
|
||||
ErrorCodeBlocked = "prompt_guard_blocked"
|
||||
ErrorCodeUnavailable = "prompt_guard_unavailable"
|
||||
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
|
||||
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
|
||||
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
|
||||
ErrorCodeBlocked = "prompt_guard_blocked"
|
||||
ErrorCodeUnavailable = "prompt_guard_unavailable"
|
||||
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
|
||||
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
|
||||
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
|
||||
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
|
||||
|
||||
DefaultGuardModel = "sileader/qwen3guard:0.6b"
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *fakeConfigStore) EffectiveMode() Mode {
|
||||
return s.cfg.EffectiveMode()
|
||||
}
|
||||
func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false }
|
||||
func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} }
|
||||
func (s *fakeConfigStore) Public() (PublicConfig, error) { return PublicConfig{}, nil }
|
||||
func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) {
|
||||
return PublicConfig{}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user