prompt_outbound_security_test.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. package securityaudit
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "sync/atomic"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func TestNormalizeBaseURLAllowsAdministratorConfiguredDestinations(t *testing.T) {
  14. allowed := []string{
  15. "https://guard.example.com", "https://guard.example.com/v1", "http://guard.example.com",
  16. "http://127.0.0.1:8080", "http://10.0.0.8:8080", "https://172.16.0.5",
  17. "http://169.254.169.254", "https://metadata.google.internal", "https://192.0.2.1",
  18. "http://internal-admin.local", "http://guard.local:8080",
  19. }
  20. for _, raw := range allowed {
  21. _, err := NormalizeBaseURL(raw)
  22. require.NoError(t, err, raw)
  23. }
  24. blocked := []string{
  25. "ftp://guard.example.com", "https://user:pass@guard.example.com",
  26. "https://guard.example.com?q=secret", "https://guard.example.com/#fragment",
  27. }
  28. for _, raw := range blocked {
  29. _, err := NormalizeBaseURL(raw)
  30. require.Error(t, err, raw)
  31. }
  32. url, err := ChatCompletionsURL("https://guard.example.com/v1")
  33. require.NoError(t, err)
  34. require.Equal(t, "https://guard.example.com/v1/chat/completions", url)
  35. }
  36. func TestHTTPClientUsesDirectStandardDialer(t *testing.T) {
  37. client, err := NewSecureHTTPClient(ActiveEndpoint{BaseURL: "https://guard.example.com", TimeoutMS: 1000})
  38. require.NoError(t, err)
  39. transport, ok := client.Transport.(*http.Transport)
  40. require.True(t, ok)
  41. require.Nil(t, transport.Proxy)
  42. require.NotNil(t, transport.DialContext)
  43. }
  44. func TestOpenAICompatibleScannerRequestContract(t *testing.T) {
  45. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  46. require.Equal(t, "/v1/chat/completions", r.URL.Path)
  47. require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
  48. var payload map[string]any
  49. require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
  50. require.Equal(t, DefaultGuardModel, payload["model"])
  51. require.Equal(t, float64(0), payload["temperature"])
  52. require.Equal(t, float64(64), payload["max_tokens"])
  53. require.Equal(t, float64(42), payload["seed"])
  54. w.Header().Set("Content-Type", "application/json")
  55. _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Safety: Safe\nCategories: None"}}]}`))
  56. }))
  57. defer server.Close()
  58. scanner := NewOpenAICompatibleScanner()
  59. result, err := scanner.Scan(context.Background(), ActiveEndpoint{ID: "one", BaseURL: server.URL, Model: DefaultGuardModel, Token: "token", TimeoutMS: 1000}, "hello", AllScannerIDs)
  60. require.NoError(t, err)
  61. require.Equal(t, EventPass, result.Decision)
  62. }
  63. func TestOpenAICompatibleScannerFollowsRedirectAndRejectsOversize(t *testing.T) {
  64. target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  65. _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Safety: Safe\nCategories: None"}}]}`))
  66. }))
  67. defer target.Close()
  68. redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
  69. defer redirect.Close()
  70. result, err := NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "redirect", BaseURL: redirect.URL, Model: DefaultGuardModel, TimeoutMS: 1000}, "hello", AllScannerIDs)
  71. require.NoError(t, err)
  72. require.Equal(t, EventPass, result.Decision)
  73. oversize := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  74. _, _ = w.Write([]byte(strings.Repeat("x", int(maxGuardResponseBytes)+1)))
  75. }))
  76. defer oversize.Close()
  77. _, err = NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "large", BaseURL: oversize.URL, Model: DefaultGuardModel, TimeoutMS: 1000}, "hello", AllScannerIDs)
  78. require.Error(t, err)
  79. }
  80. func TestOpenAICompatibleScannerClassifiesHTTPConnectionAndTimeoutFailures(t *testing.T) {
  81. tests := []struct {
  82. name string
  83. status int
  84. retryable bool
  85. }{
  86. {name: "authentication", status: http.StatusUnauthorized, retryable: false},
  87. {name: "forbidden", status: http.StatusForbidden, retryable: false},
  88. {name: "rate limited", status: http.StatusTooManyRequests, retryable: true},
  89. {name: "server failure", status: http.StatusBadGateway, retryable: true},
  90. {name: "other client error", status: http.StatusBadRequest, retryable: false},
  91. }
  92. for _, tt := range tests {
  93. t.Run(tt.name, func(t *testing.T) {
  94. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  95. w.WriteHeader(tt.status)
  96. }))
  97. defer server.Close()
  98. _, err := NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "status", BaseURL: server.URL, Model: DefaultGuardModel, TimeoutMS: 1000}, "hello", AllScannerIDs)
  99. var guardErr *GuardError
  100. require.ErrorAs(t, err, &guardErr)
  101. require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
  102. require.Equal(t, tt.status, guardErr.HTTPStatus)
  103. require.Equal(t, tt.retryable, guardErr.Retryable)
  104. require.NotContains(t, err.Error(), server.URL)
  105. })
  106. }
  107. closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
  108. closedURL := closed.URL
  109. closed.Close()
  110. _, err := NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "closed", BaseURL: closedURL, Model: DefaultGuardModel, TimeoutMS: 100}, "hello", AllScannerIDs)
  111. var connectionErr *GuardError
  112. require.ErrorAs(t, err, &connectionErr)
  113. require.True(t, connectionErr.Retryable)
  114. timeout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  115. time.Sleep(100 * time.Millisecond)
  116. w.WriteHeader(http.StatusOK)
  117. }))
  118. defer timeout.Close()
  119. _, err = NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "timeout", BaseURL: timeout.URL, Model: DefaultGuardModel, TimeoutMS: 20}, "hello", AllScannerIDs)
  120. var timeoutErr *GuardError
  121. require.ErrorAs(t, err, &timeoutErr)
  122. require.True(t, timeoutErr.Retryable)
  123. require.True(t, timeoutErr.Timeout)
  124. }
  125. func TestPromptAuditProbeModelsFallbackAndResponseSafety(t *testing.T) {
  126. t.Run("models contains configured model", func(t *testing.T) {
  127. var chatCalls atomic.Int64
  128. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  129. require.Equal(t, "Bearer temporary-token", r.Header.Get("Authorization"))
  130. if r.URL.Path == "/v1/models" {
  131. _, _ = w.Write([]byte(`{"data":[{"id":"` + DefaultGuardModel + `"}]}`))
  132. return
  133. }
  134. chatCalls.Add(1)
  135. }))
  136. defer server.Close()
  137. result := newProbeTestService().Probe(context.Background(), ProbeRequest{Endpoint: probeEndpoint(server.URL, "temporary-token")})
  138. require.True(t, result.OK)
  139. require.True(t, result.TokenApplied)
  140. require.Equal(t, http.StatusOK, result.HTTPStatus)
  141. require.Zero(t, chatCalls.Load())
  142. })
  143. t.Run("invalid models response performs real guard fallback", func(t *testing.T) {
  144. var chatCalls atomic.Int64
  145. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  146. if r.URL.Path == "/v1/models" {
  147. _, _ = w.Write([]byte(`{"unexpected":true}`))
  148. return
  149. }
  150. chatCalls.Add(1)
  151. _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Safety: Safe\nCategories: None"}}]}`))
  152. }))
  153. defer server.Close()
  154. result := newProbeTestService().Probe(context.Background(), ProbeRequest{Endpoint: probeEndpoint(server.URL, "temporary-token")})
  155. require.True(t, result.OK)
  156. require.Equal(t, int64(1), chatCalls.Load())
  157. })
  158. t.Run("fallback authentication failure is stable", func(t *testing.T) {
  159. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  160. if r.URL.Path == "/v1/models" {
  161. w.WriteHeader(http.StatusNotFound)
  162. return
  163. }
  164. w.WriteHeader(http.StatusUnauthorized)
  165. }))
  166. defer server.Close()
  167. result := newProbeTestService().Probe(context.Background(), ProbeRequest{Endpoint: probeEndpoint(server.URL, "temporary-token")})
  168. require.False(t, result.OK)
  169. require.Equal(t, ErrorCodeUnavailable, result.ErrorCode)
  170. require.Equal(t, http.StatusUnauthorized, result.HTTPStatus)
  171. require.False(t, result.Retryable)
  172. })
  173. t.Run("oversized models response is rejected without fallback", func(t *testing.T) {
  174. var chatCalls atomic.Int64
  175. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  176. if r.URL.Path != "/v1/models" {
  177. chatCalls.Add(1)
  178. }
  179. _, _ = w.Write([]byte(strings.Repeat("x", int(maxGuardResponseBytes)+1)))
  180. }))
  181. defer server.Close()
  182. result := newProbeTestService().Probe(context.Background(), ProbeRequest{Endpoint: probeEndpoint(server.URL, "temporary-token")})
  183. require.False(t, result.OK)
  184. require.Equal(t, "response_too_large", result.ErrorCode)
  185. require.Zero(t, chatCalls.Load())
  186. })
  187. }
  188. func TestResolveProbeEndpointReusesTokenOnlyForMatchingBaseURL(t *testing.T) {
  189. manager := &ConfigManager{}
  190. manager.snapshot.Store(&activeConfigSnapshot{active: ActiveConfig{Endpoints: []ActiveEndpoint{{
  191. ID: "guard-1", BaseURL: "https://guard.example.com", Token: "STORED_GUARD_TOKEN", TimeoutMS: 1000, InputLimit: 1024, Enabled: true,
  192. }}}})
  193. service := &PromptService{config: manager}
  194. matched, applied, err := service.resolveProbeEndpoint(UpdateEndpoint{
  195. ID: "guard-1", BaseURL: "https://guard.example.com/v1", TimeoutMS: 1000, InputLimit: 1024,
  196. })
  197. require.NoError(t, err)
  198. require.True(t, applied)
  199. require.Equal(t, "STORED_GUARD_TOKEN", matched.Token)
  200. mismatched, applied, err := service.resolveProbeEndpoint(UpdateEndpoint{
  201. ID: "guard-1", BaseURL: "https://attacker.example.com", TimeoutMS: 1000, InputLimit: 1024,
  202. })
  203. require.NoError(t, err)
  204. require.False(t, applied)
  205. require.Empty(t, mismatched.Token)
  206. }
  207. func newProbeTestService() *PromptService {
  208. return &PromptService{
  209. config: &ConfigManager{}, scanner: NewOpenAICompatibleScanner(), clock: realClock{},
  210. probes: map[string]ProbeResult{},
  211. }
  212. }
  213. func probeEndpoint(baseURL, token string) UpdateEndpoint {
  214. return UpdateEndpoint{
  215. ID: "probe-one", Name: "Probe One", Protocol: "openai_compatible", BaseURL: baseURL,
  216. Model: DefaultGuardModel, Token: token, TimeoutMS: 1000, InputLimit: 1024, Enabled: true,
  217. }
  218. }