plugin_security_regression_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package service
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "google.golang.org/grpc"
  16. )
  17. type pluginTokenRepository struct {
  18. PluginRepository
  19. installation *PluginInstallation
  20. listErr error
  21. }
  22. func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) {
  23. if r.listErr != nil {
  24. return nil, r.listErr
  25. }
  26. if r.installation == nil {
  27. return nil, nil
  28. }
  29. copy := *r.installation
  30. return []*PluginInstallation{&copy}, nil
  31. }
  32. func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
  33. if r.installation == nil {
  34. return nil, errors.New("插件不存在")
  35. }
  36. copy := *r.installation
  37. return &copy, nil
  38. }
  39. type pluginTokenEncryptor struct{}
  40. func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) {
  41. return "ENC:" + plaintext, nil
  42. }
  43. func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) {
  44. if !strings.HasPrefix(ciphertext, "ENC:") {
  45. return "", errors.New("密文无效")
  46. }
  47. return strings.TrimPrefix(ciphertext, "ENC:"), nil
  48. }
  49. func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) {
  50. repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
  51. first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
  52. second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
  53. token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute)
  54. require.NoError(t, err)
  55. require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second)
  56. pluginID, err := second.ResolveUIAssetToken(token)
  57. require.NoError(t, err)
  58. require.Equal(t, int64(42), pluginID)
  59. decoded, err := base64.RawURLEncoding.DecodeString(token)
  60. require.NoError(t, err)
  61. decoded[len(decoded)-1] ^= 1
  62. _, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded))
  63. require.Error(t, err)
  64. }
  65. func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) {
  66. repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
  67. manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
  68. encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`)
  69. require.NoError(t, err)
  70. token := base64.RawURLEncoding.EncodeToString([]byte(encrypted))
  71. _, err = manager.ResolveUIAssetToken(token)
  72. require.ErrorContains(t, err, "会话无效")
  73. }
  74. func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) {
  75. manager := &PluginManager{
  76. repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")},
  77. runtimes: make(map[int64]*pluginRuntime),
  78. localInstallations: make(map[int64]*PluginInstallation),
  79. }
  80. err := manager.reconcileOnce(context.Background())
  81. require.ErrorContains(t, err, "读取插件启用状态")
  82. require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{
  83. ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  84. }))
  85. request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
  86. require.NoError(t, requestErr)
  87. _, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{
  88. ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  89. })
  90. require.True(t, handled)
  91. require.ErrorContains(t, routeErr, "插件不可用")
  92. }
  93. type normalizingPluginClient struct {
  94. pluginv1.TransportPluginClient
  95. normalized []byte
  96. applied []byte
  97. }
  98. type pluginConfigRepository struct {
  99. PluginRepository
  100. installation *PluginInstallation
  101. encrypted string
  102. }
  103. func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
  104. copy := *r.installation
  105. return &copy, nil
  106. }
  107. func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error {
  108. if expectedBinarySHA256 != r.installation.BinarySHA256 {
  109. return ErrPluginStateChanged
  110. }
  111. r.encrypted = encrypted
  112. return nil
  113. }
  114. func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) {
  115. return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil
  116. }
  117. func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) {
  118. c.applied = append([]byte(nil), request.ConfigJson...)
  119. return &pluginv1.ApplyConfigResponse{Applied: true}, nil
  120. }
  121. func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) {
  122. client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)}
  123. runtime := &pluginRuntime{api: client}
  124. normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`))
  125. require.NoError(t, err)
  126. require.JSONEq(t, `{"a":1,"z":2}`, string(normalized))
  127. require.Equal(t, normalized, client.applied)
  128. }
  129. func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) {
  130. client := &normalizingPluginClient{normalized: []byte(`{"broken"`)}
  131. runtime := &pluginRuntime{api: client}
  132. _, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`))
  133. require.ErrorContains(t, err, "规范化配置")
  134. require.Empty(t, client.applied)
  135. }
  136. func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) {
  137. installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)}
  138. repo := &pluginConfigRepository{installation: installation}
  139. client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)}
  140. manager := &PluginManager{
  141. repo: repo, encryptor: pluginTokenEncryptor{},
  142. runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}},
  143. }
  144. saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`))
  145. require.NoError(t, err)
  146. require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved))
  147. plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted)
  148. require.NoError(t, err)
  149. require.JSONEq(t, string(saved), plaintext)
  150. }
  151. func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) {
  152. gin.SetMode(gin.TestMode)
  153. recorder := httptest.NewRecorder()
  154. c, _ := gin.CreateTestContext(recorder)
  155. c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  156. account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  157. transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true}
  158. result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true)
  159. require.Same(t, transportErr, result)
  160. var failover *UpstreamFailoverError
  161. require.False(t, errors.As(result, &failover))
  162. }
  163. func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) {
  164. err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true)
  165. var transportErr *PluginTransportError
  166. require.ErrorAs(t, err, &transportErr)
  167. require.True(t, transportErr.RequestSent)
  168. recorder := httptest.NewRecorder()
  169. c, _ := gin.CreateTestContext(recorder)
  170. c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  171. account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  172. result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
  173. require.Same(t, err, result)
  174. }
  175. func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) {
  176. err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false)
  177. var transportErr *PluginTransportError
  178. require.ErrorAs(t, err, &transportErr)
  179. require.False(t, transportErr.RequestSent)
  180. recorder := httptest.NewRecorder()
  181. c, _ := gin.CreateTestContext(recorder)
  182. c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  183. account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  184. result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
  185. var failover *UpstreamFailoverError
  186. require.ErrorAs(t, result, &failover)
  187. }
  188. func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) {
  189. ctx, cancel := context.WithCancel(context.Background())
  190. cancel()
  191. err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true)
  192. require.ErrorIs(t, err, context.Canceled)
  193. }
  194. func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) {
  195. manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}}
  196. require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)}))
  197. require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)}))
  198. }