security_settings_test.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. "vocat/internal/store"
  11. )
  12. func TestParseAccessConfigValidation(t *testing.T) {
  13. if _, err := parseAccessConfig(accessConfig{Mode: "bogus"}); err == nil {
  14. t.Fatal("accepted an invalid mode")
  15. }
  16. if _, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"not-a-cidr"}}); err == nil {
  17. t.Fatal("accepted an invalid CIDR")
  18. }
  19. parsed, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"203.0.113.0/24", "198.51.100.7"}})
  20. if err != nil {
  21. t.Fatalf("parseAccessConfig: %v", err)
  22. }
  23. if len(parsed.cidrs) != 2 {
  24. t.Fatalf("cidrs = %v", parsed.cidrs)
  25. }
  26. }
  27. func TestAccessControlMiddleware(t *testing.T) {
  28. server := &Server{logger: regionTestLogger()}
  29. ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
  30. handler := server.accessControl(ok)
  31. check := func(config parsedAccessConfig, remoteAddr string, forwardedFor string) int {
  32. server.accessMu.Lock()
  33. server.access = config
  34. server.accessMu.Unlock()
  35. req := httptest.NewRequest(http.MethodGet, "/", nil)
  36. req.RemoteAddr = remoteAddr
  37. if forwardedFor != "" {
  38. req.Header.Set("X-Forwarded-For", forwardedFor)
  39. }
  40. recorder := httptest.NewRecorder()
  41. handler.ServeHTTP(recorder, req)
  42. return recorder.Code
  43. }
  44. internal := parsedAccessConfig{mode: "internal"}
  45. if got := check(internal, "192.168.2.10:5000", ""); got != http.StatusOK {
  46. t.Fatalf("private IP denied: %d", got)
  47. }
  48. if got := check(internal, "127.0.0.1:5000", ""); got != http.StatusOK {
  49. t.Fatalf("loopback denied: %d", got)
  50. }
  51. if got := check(internal, "8.8.8.8:5000", ""); got != http.StatusForbidden {
  52. t.Fatalf("public IP allowed in internal mode: %d", got)
  53. }
  54. // Custom CIDR admits an otherwise-public range.
  55. withCIDR := parsedAccessConfig{mode: "internal"}
  56. parsed, _ := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"8.8.8.0/24"}})
  57. withCIDR = parsed
  58. if got := check(withCIDR, "8.8.8.8:5000", ""); got != http.StatusOK {
  59. t.Fatalf("custom CIDR not honored: %d", got)
  60. }
  61. // Public mode allows anything.
  62. public := parsedAccessConfig{mode: "public"}
  63. if got := check(public, "8.8.8.8:5000", ""); got != http.StatusOK {
  64. t.Fatalf("public mode denied a public IP: %d", got)
  65. }
  66. // Proxy headers are ignored unless explicitly trusted.
  67. trust := parsedAccessConfig{mode: "internal", trustProxy: true}
  68. if got := check(trust, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusOK {
  69. t.Fatalf("trusted X-Forwarded-For not honored: %d", got)
  70. }
  71. if got := check(internal, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusForbidden {
  72. t.Fatalf("untrusted X-Forwarded-For was honored: %d", got)
  73. }
  74. }
  75. func TestLoginRateLimiterLocksAndResets(t *testing.T) {
  76. limiter := newLoginRateLimiter()
  77. now := time.Now()
  78. limiter.now = func() time.Time { return now }
  79. key := "192.168.1.1|admin"
  80. for i := 0; i < limiter.maxFailures-1; i++ {
  81. if _, locked := limiter.recordFailure(key); locked {
  82. t.Fatalf("locked after %d failures, below threshold", i+1)
  83. }
  84. }
  85. if _, locked := limiter.recordFailure(key); !locked {
  86. t.Fatal("not locked at the failure threshold")
  87. }
  88. if _, locked := limiter.checkLocked(key); !locked {
  89. t.Fatal("checkLocked did not report the lock")
  90. }
  91. // Success clears the track record.
  92. limiter.recordSuccess(key)
  93. if _, locked := limiter.checkLocked(key); locked {
  94. t.Fatal("still locked after a success")
  95. }
  96. // Lockout expires after the lockout duration.
  97. for i := 0; i < limiter.maxFailures; i++ {
  98. limiter.recordFailure(key)
  99. }
  100. now = now.Add(limiter.lockout + time.Second)
  101. if _, locked := limiter.checkLocked(key); locked {
  102. t.Fatal("lock did not expire after the lockout window")
  103. }
  104. }
  105. func newSettingsTestServer(t *testing.T) *Server {
  106. t.Helper()
  107. database, err := store.Open(context.Background(), ":memory:")
  108. if err != nil {
  109. t.Fatal(err)
  110. }
  111. t.Cleanup(func() { _ = database.Close() })
  112. return &Server{
  113. store: database,
  114. logger: regionTestLogger(),
  115. maxRequestBodyBytes: 4096,
  116. access: defaultAccessConfig(),
  117. }
  118. }
  119. func TestHandleSecuritySettingsRoundTrip(t *testing.T) {
  120. server := newSettingsTestServer(t)
  121. body := `{"mode":"internal","allowed_cidrs":["203.0.113.0/24"],"trust_proxy_headers":true}`
  122. request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(body))
  123. request.Header.Set("Content-Type", "application/json")
  124. request.RemoteAddr = "192.168.2.20:5000"
  125. recorder := httptest.NewRecorder()
  126. server.handleSecuritySettings(recorder, request)
  127. if recorder.Code != http.StatusOK {
  128. t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String())
  129. }
  130. if server.currentAccessConfig().trustProxy != true {
  131. t.Fatal("runtime access config was not updated")
  132. }
  133. // Persisted?
  134. setting, err := server.store.AppSetting(context.Background(), accessSettingKey)
  135. if err != nil || !strings.Contains(string(setting.Value), "203.0.113.0/24") {
  136. t.Fatalf("access policy not persisted: %v %v", setting, err)
  137. }
  138. // GET reflects it.
  139. getRec := httptest.NewRecorder()
  140. getReq := httptest.NewRequest(http.MethodGet, "/api/settings/security", nil)
  141. getReq.RemoteAddr = "192.168.2.20:5000"
  142. server.handleSecuritySettings(getRec, getReq)
  143. var envelope struct {
  144. Data map[string]any `json:"data"`
  145. }
  146. if err := json.NewDecoder(getRec.Body).Decode(&envelope); err != nil {
  147. t.Fatal(err)
  148. }
  149. if envelope.Data["trust_proxy_headers"] != true || envelope.Data["client_allowed"] != true {
  150. t.Fatalf("GET data = %v", envelope.Data)
  151. }
  152. }
  153. func TestHandleSecuritySettingsRejectsBadPolicy(t *testing.T) {
  154. server := newSettingsTestServer(t)
  155. request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(`{"mode":"nowhere"}`))
  156. request.Header.Set("Content-Type", "application/json")
  157. recorder := httptest.NewRecorder()
  158. server.handleSecuritySettings(recorder, request)
  159. if recorder.Code != http.StatusBadRequest {
  160. t.Fatalf("status = %d, want 400", recorder.Code)
  161. }
  162. }
  163. func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
  164. server := newSettingsTestServer(t)
  165. // Seed 10 log rows.
  166. for i := 0; i < 10; i++ {
  167. if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
  168. Level: "info", Message: "entry",
  169. }); err != nil {
  170. t.Fatal(err)
  171. }
  172. }
  173. // Keep only the newest 4.
  174. request := httptest.NewRequest(http.MethodPut, "/api/settings/logging", strings.NewReader(`{"mode":"count","count":4}`))
  175. request.Header.Set("Content-Type", "application/json")
  176. recorder := httptest.NewRecorder()
  177. server.handleLoggingSettings(recorder, request)
  178. if recorder.Code != http.StatusOK {
  179. t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String())
  180. }
  181. count, err := server.store.CountLogEvents(context.Background())
  182. if err != nil {
  183. t.Fatal(err)
  184. }
  185. if count != 4 {
  186. t.Fatalf("stored log count = %d, want 4 after retention", count)
  187. }
  188. }
  189. func TestLoginLockoutViaHTTP(t *testing.T) {
  190. app := newTestApplication(t)
  191. for i := 0; i < 4; i++ {
  192. response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json",
  193. strings.NewReader(`{"username":"admin","password":"wrong"}`))
  194. if err != nil {
  195. t.Fatal(err)
  196. }
  197. response.Body.Close()
  198. if response.StatusCode != http.StatusUnauthorized {
  199. t.Fatalf("attempt %d status = %d, want 401", i+1, response.StatusCode)
  200. }
  201. }
  202. // Fifth consecutive failure crosses the threshold and locks.
  203. response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json",
  204. strings.NewReader(`{"username":"admin","password":"wrong"}`))
  205. if err != nil {
  206. t.Fatal(err)
  207. }
  208. response.Body.Close()
  209. if response.StatusCode != http.StatusTooManyRequests {
  210. t.Fatalf("5th failure status = %d, want 429", response.StatusCode)
  211. }
  212. // Even the correct password is refused while locked.
  213. response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json",
  214. strings.NewReader(`{"username":"admin","password":"correct-password"}`))
  215. if err != nil {
  216. t.Fatal(err)
  217. }
  218. response.Body.Close()
  219. if response.StatusCode != http.StatusTooManyRequests {
  220. t.Fatalf("locked login status = %d, want 429", response.StatusCode)
  221. }
  222. }