@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,503 @@
1
+ package security
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/rand"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "errors"
9
+ "net/http"
10
+ "net/netip"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/pocketbase/pocketbase/apis"
16
+ "github.com/pocketbase/pocketbase/core"
17
+ "github.com/pocketbase/pocketbase/tools/hook"
18
+ "github.com/pocketbase/pocketbase/tools/router"
19
+ )
20
+
21
+ type bucket struct {
22
+ tokens float64
23
+ updated time.Time
24
+ }
25
+ type Observation struct {
26
+ Family string `json:"family"`
27
+ Count int `json:"count"`
28
+ Rejected int `json:"rejected"`
29
+ Failures int `json:"failures"`
30
+ Slow int `json:"slow"`
31
+ }
32
+ type Incident struct {
33
+ ID uint64 `json:"id"`
34
+ Family string `json:"family"`
35
+ Phase string `json:"phase"`
36
+ FirstSeen time.Time `json:"firstSeen"`
37
+ LastSeen time.Time `json:"lastSeen"`
38
+ Evidence Observation `json:"evidence"`
39
+ Acknowledged bool `json:"acknowledged"`
40
+ }
41
+ type Action struct {
42
+ Family string `json:"family"`
43
+ ExpiresAt time.Time `json:"expiresAt"`
44
+ Reason string `json:"reason"`
45
+ }
46
+ type subscriptionState struct {
47
+ count int
48
+ updating bool
49
+ }
50
+
51
+ type TrafficWindow struct {
52
+ At time.Time `json:"at"`
53
+ Count int `json:"count"`
54
+ Rejected int `json:"rejected"`
55
+ Failures int `json:"failures"`
56
+ Slow int `json:"slow"`
57
+ }
58
+
59
+ type Service struct {
60
+ startedAt time.Time
61
+ lastEvaluated time.Time
62
+ history []TrafficWindow
63
+ journalMu sync.Mutex
64
+ storageError bool
65
+ clients map[string]*subscriptionState
66
+ totalSubscriptions int
67
+
68
+ mu sync.Mutex
69
+ config Config
70
+ revision uint64
71
+ key [32]byte
72
+ global bucket
73
+ identities map[string]*bucket
74
+ overflow bucket
75
+ active map[string]int
76
+ observations map[string]*Observation
77
+ streak map[string]int
78
+ clear map[string]int
79
+ incidents []Incident
80
+ nextID uint64
81
+ actions map[string]Action
82
+ stop chan struct{}
83
+ done chan struct{}
84
+ once sync.Once
85
+ total uint64
86
+ rejected uint64
87
+ lastSweep time.Time
88
+ }
89
+
90
+ func New(c Config) (*Service, error) {
91
+ if err := c.Validate(); err != nil {
92
+ return nil, err
93
+ }
94
+ s := &Service{startedAt: time.Now(), clients: map[string]*subscriptionState{}, config: cloneConfig(c), revision: 1, identities: map[string]*bucket{}, active: map[string]int{}, observations: map[string]*Observation{}, streak: map[string]int{}, clear: map[string]int{}, actions: map[string]Action{}, stop: make(chan struct{}), done: make(chan struct{})}
95
+ if _, err := rand.Read(s.key[:]); err != nil {
96
+ return nil, err
97
+ }
98
+ s.loadState()
99
+ go func() {
100
+ defer close(s.done)
101
+ t := time.NewTicker(10 * time.Second)
102
+ defer t.Stop()
103
+ for {
104
+ select {
105
+ case now := <-t.C:
106
+ s.evaluate(now)
107
+ s.persist()
108
+ case <-s.stop:
109
+ return
110
+ }
111
+ }
112
+ }()
113
+ return s, nil
114
+ }
115
+ func (s *Service) Close() { s.once.Do(func() { close(s.stop) }); <-s.done; s.persist() }
116
+ func Register(app core.App, c Config) error {
117
+ if app.Store().Has("pb.security.service") {
118
+ return errors.New("security already registered")
119
+ }
120
+ s, err := New(c)
121
+ if err != nil {
122
+ return err
123
+ }
124
+ app.Store().Set("pb.security.service", s)
125
+ app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{Id: "pb.security.serve", Func: func(e *core.ServeEvent) error {
126
+ s.Bind(e.Router)
127
+ e.UIExtensions = append(e.UIExtensions, core.UIExtension{Name: "security", FS: dashboardFiles})
128
+ return e.Next()
129
+ }})
130
+ app.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{Id: "pb.security.stop", Func: func(e *core.TerminateEvent) error { s.Close(); return e.Next() }})
131
+ app.OnBatchRequest().Bind(&hook.Handler[*core.BatchRequestEvent]{Id: "pb.security.batch", Func: func(e *core.BatchRequestEvent) error {
132
+ extra := max(0, len(e.Batch)-1)
133
+ s.mu.Lock()
134
+ mode := s.config.Mode
135
+ allowed := s.active["writes"]+extra <= s.config.MaxWrites
136
+ if mode == "disabled" {
137
+ s.mu.Unlock()
138
+ return e.Next()
139
+ }
140
+ if !allowed {
141
+ e.Set("pb.security.rejected", true)
142
+ }
143
+ if !allowed && mode == "enforce" {
144
+ s.mu.Unlock()
145
+ e.Response.Header().Set("Retry-After", "1")
146
+ return e.TooManyRequestsError("Batch operation budget exhausted", nil)
147
+ }
148
+ s.active["writes"] += extra
149
+ s.mu.Unlock()
150
+ defer func() { s.mu.Lock(); s.active["writes"] -= extra; s.mu.Unlock() }()
151
+ return e.Next()
152
+ }})
153
+ app.OnRealtimeConnectRequest().Bind(&hook.Handler[*core.RealtimeConnectRequestEvent]{Id: "pb.security.connect", Func: func(e *core.RealtimeConnectRequestEvent) error {
154
+ s.mu.Lock()
155
+ mode := s.config.Mode
156
+ if mode == "disabled" {
157
+ s.mu.Unlock()
158
+ return e.Next()
159
+ }
160
+ if len(s.clients) >= s.config.MaxRealtime {
161
+ s.mu.Unlock()
162
+ if mode == "enforce" {
163
+ return e.TooManyRequestsError("Realtime client budget exhausted", nil)
164
+ }
165
+ return e.Next()
166
+ }
167
+ s.clients[e.Client.Id()] = &subscriptionState{}
168
+ s.mu.Unlock()
169
+ defer func() {
170
+ s.mu.Lock()
171
+ if state := s.clients[e.Client.Id()]; state != nil {
172
+ s.totalSubscriptions -= state.count
173
+ delete(s.clients, e.Client.Id())
174
+ }
175
+ s.mu.Unlock()
176
+ }()
177
+ return e.Next()
178
+ }})
179
+ app.OnRealtimeSubscribeRequest().Bind(&hook.Handler[*core.RealtimeSubscribeRequestEvent]{Id: "pb.security.subscribe", Func: func(e *core.RealtimeSubscribeRequestEvent) error {
180
+ s.mu.Lock()
181
+ mode := s.config.Mode
182
+ state := s.clients[e.Client.Id()]
183
+ if mode == "disabled" || mode == "observe" && state == nil {
184
+ s.mu.Unlock()
185
+ return e.Next()
186
+ }
187
+ if mode == "observe" && state.updating {
188
+ s.mu.Unlock()
189
+ return e.Next()
190
+ }
191
+ if state == nil || state.updating {
192
+ s.mu.Unlock()
193
+ e.Response.Header().Set("Retry-After", "1")
194
+ return e.TooManyRequestsError("Realtime client unavailable or busy", nil)
195
+ }
196
+ old := state.count
197
+ count := len(e.Subscriptions)
198
+ allowed := count <= s.config.MaxSubscriptions && s.totalSubscriptions-old+count <= s.config.MaxSubscriptionsTotal
199
+ if !allowed {
200
+ e.Set("pb.security.rejected", true)
201
+ }
202
+ if !allowed && mode == "enforce" {
203
+ s.mu.Unlock()
204
+ e.Response.Header().Set("Retry-After", "1")
205
+ return e.TooManyRequestsError("Subscription budget exhausted", nil)
206
+ }
207
+ state.updating = true
208
+ state.count = count
209
+ s.totalSubscriptions += count - old
210
+ s.mu.Unlock()
211
+ defer func() {
212
+ s.mu.Lock()
213
+ if s.clients[e.Client.Id()] == state {
214
+ actual := len(e.Client.Subscriptions())
215
+ s.totalSubscriptions += actual - state.count
216
+ state.count = actual
217
+ state.updating = false
218
+ }
219
+ s.mu.Unlock()
220
+ }()
221
+ return e.Next()
222
+ }})
223
+ app.OnRealtimeMessageSend().Bind(&hook.Handler[*core.RealtimeMessageEvent]{Id: "pb.security.sse-write", Func: func(e *core.RealtimeMessageEvent) error {
224
+ s.mu.Lock()
225
+ enabled := s.config.Mode == "enforce"
226
+ s.mu.Unlock()
227
+ if enabled {
228
+ rc := http.NewResponseController(e.Response)
229
+ if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
230
+ return err
231
+ }
232
+ }
233
+ return e.Next()
234
+ }})
235
+ return nil
236
+ }
237
+ func family(r *http.Request) string {
238
+ p := r.URL.Path
239
+ switch {
240
+ case strings.HasPrefix(p, "/api/security/"):
241
+ return "management"
242
+ case p == "/api/realtime":
243
+ return "realtime"
244
+ case strings.HasPrefix(p, "/api/files/"):
245
+ return "files"
246
+ case p == "/api/batch":
247
+ return "writes"
248
+ case strings.Contains(p, "/auth-") || strings.Contains(p, "/request-") || strings.Contains(p, "/confirm-"):
249
+ return "auth"
250
+ case r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" || r.Method == "DELETE":
251
+ return "writes"
252
+ case strings.HasPrefix(p, "/api/collections/"):
253
+ return "reads"
254
+ default:
255
+ return "other"
256
+ }
257
+ }
258
+ func (s *Service) client(e *core.RequestEvent, c Config) string {
259
+ ip := e.RemoteIP()
260
+ if !inNetworks(ip, c.TrustedPeers) {
261
+ return ip
262
+ }
263
+ parts := strings.Split(e.Request.Header.Get("X-Forwarded-For"), ",")
264
+ if len(parts) > 32 {
265
+ return ip
266
+ }
267
+ for i := len(parts) - 1; i >= 0; i-- {
268
+ a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
269
+ if err != nil {
270
+ return ip
271
+ }
272
+ ip = a.Unmap().String()
273
+ if !inNetworks(ip, c.TrustedPeers) {
274
+ return ip
275
+ }
276
+ }
277
+ return ip
278
+ }
279
+ func (s *Service) fingerprint(value string) string {
280
+ m := hmac.New(sha256.New, s.key[:])
281
+ m.Write([]byte(value))
282
+ return hex.EncodeToString(m.Sum(nil)[:16])
283
+ }
284
+ func consume(b *bucket, rate float64, burst int, now time.Time) bool {
285
+ if b.updated.IsZero() {
286
+ b.tokens = float64(burst)
287
+ } else {
288
+ b.tokens = min(float64(burst), b.tokens+max(0, now.Sub(b.updated).Seconds())*rate)
289
+ }
290
+ b.updated = now
291
+ if b.tokens < 1 {
292
+ return false
293
+ }
294
+ b.tokens--
295
+ return true
296
+ }
297
+ func (s *Service) identityAllowed(key string, c Config, now time.Time) bool {
298
+ b := s.identities[key]
299
+ if b == nil {
300
+ if len(s.identities) >= c.MaxIdentities && now.Sub(s.lastSweep) > time.Minute {
301
+ for k, v := range s.identities {
302
+ if now.Sub(v.updated) > 2*time.Minute {
303
+ delete(s.identities, k)
304
+ }
305
+ }
306
+ s.lastSweep = now
307
+ }
308
+ if len(s.identities) >= c.MaxIdentities {
309
+ return consume(&s.overflow, c.IdentityPerSecond, c.IdentityBurst, now)
310
+ }
311
+ b = &bucket{}
312
+ s.identities[key] = b
313
+ }
314
+ return consume(b, c.IdentityPerSecond, c.IdentityBurst, now)
315
+ }
316
+ func (s *Service) observe(f string, rejected bool, status int, elapsed time.Duration) {
317
+ s.mu.Lock()
318
+ defer s.mu.Unlock()
319
+ o := s.observations[f]
320
+ if o == nil {
321
+ o = &Observation{Family: f}
322
+ s.observations[f] = o
323
+ }
324
+ o.Count++
325
+ s.total++
326
+ if rejected {
327
+ o.Rejected++
328
+ s.rejected++
329
+ }
330
+ if status >= 400 {
331
+ o.Failures++
332
+ }
333
+ if elapsed > time.Second {
334
+ o.Slow++
335
+ }
336
+ }
337
+ func (s *Service) Bind(r *router.Router[*core.RequestEvent]) {
338
+ r.Bind(&hook.Handler[*core.RequestEvent]{Id: "pb.security.admission", Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 5, Func: func(e *core.RequestEvent) (err error) {
339
+ start := time.Now()
340
+ f := family(e.Request)
341
+ s.mu.Lock()
342
+ c := cloneConfig(s.config)
343
+ s.mu.Unlock()
344
+ if c.Mode == "disabled" {
345
+ return e.Next()
346
+ }
347
+ s.mu.Lock()
348
+ operator := e.Request.Header.Get("X-Spink-Operator") == "1" && inNetworks(e.RemoteIP(), c.OperatorPeers)
349
+ if operator {
350
+ f = "management"
351
+ e.Set("pb.security.operator", true)
352
+ }
353
+ management := operator || f == "management" && inNetworks(e.RemoteIP(), c.ManagementPeers)
354
+ allowed := true
355
+ status := 429
356
+ if management {
357
+ allowed = s.active["management"] < 4
358
+ status = 503
359
+ } else {
360
+ allowed = consume(&s.global, c.RequestsPerSecond, c.Burst, start)
361
+ count := s.active["ordinary"]
362
+ laneMax := c.MaxConcurrent
363
+ switch f {
364
+ case "writes":
365
+ laneMax = c.MaxWrites
366
+ case "files":
367
+ laneMax = c.MaxFiles
368
+ case "realtime":
369
+ laneMax = c.MaxRealtime
370
+ count = s.active["realtime"]
371
+ }
372
+ if count >= c.MaxConcurrent && f != "realtime" || s.active[f] >= laneMax {
373
+ allowed = false
374
+ status = 503
375
+ }
376
+ if a, ok := s.actions[f]; ok && start.Before(a.ExpiresAt) {
377
+ allowed = false
378
+ }
379
+ }
380
+ block := !allowed && c.Mode == "enforce"
381
+ if !block {
382
+ s.active[f]++
383
+ if f != "realtime" && f != "management" {
384
+ s.active["ordinary"]++
385
+ }
386
+ }
387
+ s.mu.Unlock()
388
+ if block {
389
+ s.observe(f, true, status, 0)
390
+ e.Response.Header().Set("Retry-After", "1")
391
+ e.Response.Header().Set("X-Content-Type-Options", "nosniff")
392
+ return router.NewApiError(status, "Security capacity limit reached", nil)
393
+ }
394
+ defer func() {
395
+ s.mu.Lock()
396
+ s.active[f]--
397
+ if f != "realtime" && f != "management" {
398
+ s.active["ordinary"]--
399
+ }
400
+ s.mu.Unlock()
401
+ status := e.Status()
402
+ if err != nil && status < 400 {
403
+ status = 400
404
+ }
405
+ s.observe(f, !allowed || e.Get("pb.security.rejected") == true, status, time.Since(start))
406
+ }()
407
+ return e.Next()
408
+ }})
409
+ r.Bind(&hook.Handler[*core.RequestEvent]{Id: "pb.security.identity", Priority: apis.DefaultRateLimitMiddlewarePriority - 5, Func: func(e *core.RequestEvent) error {
410
+ if family(e.Request) == "management" || e.Get("pb.security.operator") == true {
411
+ return e.Next()
412
+ }
413
+ s.mu.Lock()
414
+ c := s.config
415
+ allowed := true
416
+ if c.Mode != "disabled" {
417
+ key := "source:" + s.fingerprint(s.client(e, c))
418
+ if e.Auth != nil {
419
+ key = "user:" + s.fingerprint(e.Auth.Collection().Id+":"+e.Auth.Id)
420
+ }
421
+ allowed = s.identityAllowed(key, c, time.Now())
422
+ }
423
+ s.mu.Unlock()
424
+ if !allowed {
425
+ e.Set("pb.security.rejected", true)
426
+ }
427
+ if !allowed && c.Mode == "enforce" {
428
+ e.Response.Header().Set("Retry-After", "1")
429
+ return e.TooManyRequestsError("Identity budget exhausted", nil)
430
+ }
431
+ return e.Next()
432
+ }})
433
+ s.bindManagement(r)
434
+ s.bindReview(r)
435
+ }
436
+ func (s *Service) evaluate(now time.Time) {
437
+ s.mu.Lock()
438
+ defer s.mu.Unlock()
439
+ if s.config.Mode == "disabled" {
440
+ s.observations = map[string]*Observation{}
441
+ return
442
+ }
443
+ sample := TrafficWindow{At: now}
444
+ for _, o := range s.observations {
445
+ sample.Count += o.Count
446
+ sample.Rejected += o.Rejected
447
+ sample.Failures += o.Failures
448
+ sample.Slow += o.Slow
449
+ }
450
+ s.history = append(s.history, sample)
451
+ if len(s.history) > 60 {
452
+ s.history = append([]TrafficWindow(nil), s.history[len(s.history)-60:]...)
453
+ }
454
+ s.lastEvaluated = now
455
+ for _, f := range []string{"auth", "reads", "writes", "files", "realtime", "other"} {
456
+ o := s.observations[f]
457
+ if o == nil {
458
+ o = &Observation{Family: f}
459
+ }
460
+ suspicious := o.Count >= 20 && ((o.Rejected*2 > o.Count) || (f == "auth" && o.Failures*5 > o.Count*4) || (o.Slow*2 > o.Count && o.Failures*2 > o.Count))
461
+ if suspicious {
462
+ s.streak[f]++
463
+ s.clear[f] = 0
464
+ } else {
465
+ s.streak[f] = 0
466
+ s.clear[f]++
467
+ }
468
+ idx := -1
469
+ for i := len(s.incidents) - 1; i >= 0; i-- {
470
+ if s.incidents[i].Family == f && s.incidents[i].Phase != "resolved" {
471
+ idx = i
472
+ break
473
+ }
474
+ }
475
+ if s.streak[f] >= 3 {
476
+ if idx < 0 {
477
+ s.nextID++
478
+ s.incidents = append(s.incidents, Incident{ID: s.nextID, Family: f, FirstSeen: now.Add(-30 * time.Second), Phase: "active"})
479
+ if len(s.incidents) > 256 {
480
+ s.incidents = s.incidents[1:]
481
+ }
482
+ idx = len(s.incidents) - 1
483
+ }
484
+ s.incidents[idx].LastSeen = now
485
+ s.incidents[idx].Evidence = *o
486
+ s.incidents[idx].Phase = "active"
487
+ } else if idx >= 0 && s.clear[f] > 0 {
488
+ s.incidents[idx].Phase = "recovering"
489
+ if s.clear[f] >= 6 {
490
+ s.incidents[idx].Phase = "resolved"
491
+ }
492
+ }
493
+ }
494
+ s.observations = map[string]*Observation{}
495
+ for k, a := range s.actions {
496
+ if !now.Before(a.ExpiresAt) {
497
+ delete(s.actions, k)
498
+ }
499
+ }
500
+ }
501
+
502
+ // RequestFamily exposes the classifier to the pre-database gateway.
503
+ func RequestFamily(r *http.Request) string { return family(r) }
@@ -0,0 +1,146 @@
1
+ package security
2
+
3
+ import (
4
+ "math"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/pocketbase/pocketbase/apis"
13
+ "github.com/pocketbase/pocketbase/core"
14
+ "github.com/pocketbase/pocketbase/tests"
15
+ )
16
+
17
+ func TestConfig(t *testing.T) {
18
+ for _, rate := range []float64{0, -1, math.NaN(), math.Inf(1)} {
19
+ c := DefaultConfig()
20
+ c.RequestsPerSecond = rate
21
+ if c.Validate() == nil {
22
+ t.Fatal("accepted invalid rate")
23
+ }
24
+ }
25
+ }
26
+ func TestBoundedIdentityState(t *testing.T) {
27
+ c := DefaultConfig()
28
+ c.MaxIdentities = 2
29
+ s, _ := New(c)
30
+ defer s.Close()
31
+ now := time.Now()
32
+ for _, id := range []string{"a", "b", "c", "d"} {
33
+ s.identityAllowed(id, c, now)
34
+ }
35
+ if len(s.identities) != 2 {
36
+ t.Fatal("unbounded identities")
37
+ }
38
+ }
39
+ func TestAdmissionModesAndRelease(t *testing.T) {
40
+ for _, mode := range []string{"disabled", "observe", "enforce"} {
41
+ t.Run(mode, func(t *testing.T) {
42
+ app, _ := tests.NewTestApp()
43
+ defer app.Cleanup()
44
+ c := DefaultConfig()
45
+ c.Mode = mode
46
+ c.Burst = 1
47
+ c.IdentityBurst = 1
48
+ c.RequestsPerSecond = .001
49
+ c.IdentityPerSecond = .001
50
+ s, _ := New(c)
51
+ defer s.Close()
52
+ r, _ := apis.NewRouter(app)
53
+ s.Bind(r)
54
+ r.GET("/probe", func(e *core.RequestEvent) error { return e.String(200, "ok") })
55
+ h, err := r.BuildMux()
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ for i := 0; i < 3; i++ {
60
+ rec := httptest.NewRecorder()
61
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "/probe", nil))
62
+ expected := 200
63
+ if mode == "enforce" && i > 0 {
64
+ expected = 429
65
+ }
66
+ if rec.Code != expected {
67
+ t.Fatalf("got %d expected %d", rec.Code, expected)
68
+ }
69
+ }
70
+ for _, n := range s.active {
71
+ if n != 0 {
72
+ t.Fatal("permit leaked")
73
+ }
74
+ }
75
+ })
76
+ }
77
+ }
78
+ func TestIdentityTrust(t *testing.T) {
79
+ s, _ := New(DefaultConfig())
80
+ defer s.Close()
81
+ e := &core.RequestEvent{}
82
+ e.Request = httptest.NewRequest("GET", "/", nil)
83
+ e.Request.RemoteAddr = "192.0.2.1:1000"
84
+ e.Request.Header.Set("X-Forwarded-For", "203.0.113.8, 10.0.0.2")
85
+ c := DefaultConfig()
86
+ if s.client(e, c) != "192.0.2.1" {
87
+ t.Fatal("trusted unknown peer")
88
+ }
89
+ c.TrustedPeers = []string{"192.0.2.0/24", "10.0.0.0/8"}
90
+ if s.client(e, c) != "203.0.113.8" {
91
+ t.Fatal("wrong proxy chain")
92
+ }
93
+ }
94
+ func TestDetectionRecoveryAndNoAutomaticAction(t *testing.T) {
95
+ s, _ := New(DefaultConfig())
96
+ defer s.Close()
97
+ now := time.Now()
98
+ for i := 0; i < 3; i++ {
99
+ for j := 0; j < 30; j++ {
100
+ s.observe("auth", false, 400, 0)
101
+ }
102
+ s.evaluate(now.Add(time.Duration(i) * 10 * time.Second))
103
+ }
104
+ if len(s.incidents) != 1 || s.incidents[0].Phase != "active" {
105
+ t.Fatal("missing incident")
106
+ }
107
+ if len(s.actions) != 0 {
108
+ t.Fatal("detector automatically blocks")
109
+ }
110
+ for i := 0; i < 6; i++ {
111
+ s.evaluate(now.Add(time.Duration(i+3) * 10 * time.Second))
112
+ }
113
+ if s.incidents[0].Phase != "resolved" {
114
+ t.Fatal("no recovery")
115
+ }
116
+ }
117
+ func TestManagementRequiresAuth(t *testing.T) {
118
+ app, _ := tests.NewTestApp()
119
+ defer app.Cleanup()
120
+ s, _ := New(DefaultConfig())
121
+ defer s.Close()
122
+ r, _ := apis.NewRouter(app)
123
+ s.Bind(r)
124
+ h, _ := r.BuildMux()
125
+ w := httptest.NewRecorder()
126
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodPut, "/api/security/policy", strings.NewReader(`{}`)))
127
+ if w.Code != 401 {
128
+ t.Fatalf("unauthorized policy status %d", w.Code)
129
+ }
130
+ }
131
+ func TestConcurrentObservations(t *testing.T) {
132
+ s, _ := New(DefaultConfig())
133
+ defer s.Close()
134
+ var wg sync.WaitGroup
135
+ for i := 0; i < 20; i++ {
136
+ wg.Go(func() {
137
+ for j := 0; j < 100; j++ {
138
+ s.observe("reads", false, 200, 0)
139
+ }
140
+ })
141
+ }
142
+ wg.Wait()
143
+ if s.total != 2000 {
144
+ t.Fatal(s.total)
145
+ }
146
+ }