@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.
- package/.dockerignore +9 -0
- package/Dockerfile +26 -0
- package/FEATURES.md +32 -0
- package/LICENSE.md +17 -0
- package/MIGRATION.md +49 -0
- package/NOTICE.md +5 -0
- package/README.md +26 -0
- package/adapter.go +31 -0
- package/admin/register.go +60 -0
- package/admin/register_test.go +37 -0
- package/backups/backup_encryption_test.go +82 -0
- package/backups/encryption.go +138 -0
- package/backups/integration_test.go +51 -0
- package/backups/register.go +120 -0
- package/backups/restore.go +119 -0
- package/backups/s3_test.go +52 -0
- package/backups/swap.go +53 -0
- package/backups/swap_test.go +75 -0
- package/backups/upload.go +52 -0
- package/bin/pocketbase-extension.mjs +25 -0
- package/cmd/edge/main.go +123 -0
- package/cmd/import-fork/main.go +37 -0
- package/cmd/loadtest/main.go +61 -0
- package/cmd/loadtest/sandbox.go +73 -0
- package/cmd/loadtest/sandbox_test.go +20 -0
- package/cmd/pocketbase/main.go +78 -0
- package/deploy/README.md +148 -0
- package/deploy/app/hooks/README.md +2 -0
- package/deploy/app/migrations/1789000000_notes.js +16 -0
- package/deploy/app/public/README.md +2 -0
- package/deploy/compose.secrets.yaml +8 -0
- package/deploy/compose.yaml +68 -0
- package/deploy/edge.json +13 -0
- package/edge/gateway.go +295 -0
- package/edge/gateway_test.go +296 -0
- package/edge/openapi.json +1 -0
- package/edge/policy.go +150 -0
- package/features/collection_singleton.go +13 -0
- package/features/collection_singleton_test.go +46 -0
- package/features/dimensions_test.go +65 -0
- package/features/duplicate.go +178 -0
- package/features/duplicate_test.go +128 -0
- package/features/field_color.go +46 -0
- package/features/field_date_only.go +39 -0
- package/features/field_json_schema.go +92 -0
- package/features/field_scalar_extensions_test.go +66 -0
- package/features/files.go +36 -0
- package/features/filter_has_any_test.go +81 -0
- package/features/generate_test.go +72 -0
- package/features/has_any_visibility_test.go +62 -0
- package/features/json.go +50 -0
- package/features/membership.go +64 -0
- package/features/register.go +45 -0
- package/features/schema_test.go +58 -0
- package/features/ui/main.js +133 -0
- package/features/ui/settings.js +31 -0
- package/go.mod +54 -0
- package/go.sum +159 -0
- package/internal/archive/create.go +91 -0
- package/internal/archive/create_test.go +125 -0
- package/internal/archive/extract.go +99 -0
- package/internal/archive/extract_test.go +88 -0
- package/jsvm/binds.go +1273 -0
- package/jsvm/binds_app_reset_test.go +314 -0
- package/jsvm/binds_test.go +1870 -0
- package/jsvm/form_data.go +149 -0
- package/jsvm/form_data_test.go +225 -0
- package/jsvm/internal/types/generated/embed.go +6 -0
- package/jsvm/internal/types/generated/types.d.ts +24820 -0
- package/jsvm/internal/types/types.go +1408 -0
- package/jsvm/jsvm.go +587 -0
- package/jsvm/mapper.go +67 -0
- package/jsvm/mapper_test.go +42 -0
- package/jsvm/pool.go +73 -0
- package/jsvm/program_source_test.go +24 -0
- package/loadtest/loadtest.go +202 -0
- package/loadtest/loadtest_test.go +84 -0
- package/localization/README.md +23 -0
- package/localization/catalogue.json +483 -0
- package/localization/localization.go +94 -0
- package/localization/localization_test.go +21 -0
- package/mail/register.go +80 -0
- package/mail/register_test.go +49 -0
- package/mail/resolve.go +91 -0
- package/migration/import.go +96 -0
- package/migration/import_test.go +58 -0
- package/otp/otp.go +56 -0
- package/otp/otp_test.go +56 -0
- package/package.json +51 -0
- package/scripts/check-edge.py +42 -0
- package/scripts/check.sh +11 -0
- package/scripts/sync-jsvm-types.sh +10 -0
- package/security/README.md +94 -0
- package/security/assurance_test.go +149 -0
- package/security/compatibility_test.go +128 -0
- package/security/config.go +78 -0
- package/security/dashboard_test.go +103 -0
- package/security/management.go +169 -0
- package/security/openapi.json +508 -0
- package/security/review.go +34 -0
- package/security/security.go +503 -0
- package/security/security_test.go +146 -0
- package/security/state.go +116 -0
- package/security/ui/dashboard.css +4 -0
- package/security/ui/dashboard.js +83 -0
- package/security/ui/main.js +15 -0
- package/security/ui/model.js +32 -0
- package/security/ui/model.test.mjs +25 -0
- package/security/ui/registration.test.mjs +10 -0
- package/settings/env_test.go +41 -0
- package/settings/openapi.json +193 -0
- package/settings/settings.go +155 -0
- package/settings/settings_test.go +31 -0
- package/watcher/watcher.go +192 -0
- package/watcher/watcher_test.go +200 -0
- package/web/static.go +99 -0
- package/web/static_test.go +48 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package security
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http/httptest"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
11
|
+
"github.com/pocketbase/pocketbase/core"
|
|
12
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestNATFairnessAndRejectedAccounting(t *testing.T) {
|
|
16
|
+
app, err := tests.NewTestApp()
|
|
17
|
+
if err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
defer app.Cleanup()
|
|
21
|
+
c := core.NewAuthCollection("security_users")
|
|
22
|
+
if err := app.Save(c); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
tokens := []string{}
|
|
26
|
+
for _, email := range []string{"a@example.com", "b@example.com"} {
|
|
27
|
+
r := core.NewRecord(c)
|
|
28
|
+
r.SetEmail(email)
|
|
29
|
+
r.SetPassword("1234567890")
|
|
30
|
+
if err := app.Save(r); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
token, err := r.NewAuthToken()
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
tokens = append(tokens, token)
|
|
38
|
+
}
|
|
39
|
+
config := DefaultConfig()
|
|
40
|
+
config.Mode = "enforce"
|
|
41
|
+
config.IdentityBurst = 1
|
|
42
|
+
config.IdentityPerSecond = .001
|
|
43
|
+
s, err := New(config)
|
|
44
|
+
if err != nil {
|
|
45
|
+
t.Fatal(err)
|
|
46
|
+
}
|
|
47
|
+
defer s.Close()
|
|
48
|
+
router, _ := apis.NewRouter(app)
|
|
49
|
+
s.Bind(router)
|
|
50
|
+
router.GET("/probe", func(e *core.RequestEvent) error { return e.String(200, "ok") })
|
|
51
|
+
handler, err := router.BuildMux()
|
|
52
|
+
if err != nil {
|
|
53
|
+
t.Fatal(err)
|
|
54
|
+
}
|
|
55
|
+
for _, tc := range []struct {
|
|
56
|
+
token string
|
|
57
|
+
want int
|
|
58
|
+
}{{tokens[0], 200}, {tokens[0], 429}, {"", 200}, {"", 429}, {tokens[1], 200}} {
|
|
59
|
+
req := httptest.NewRequest("GET", "/probe", nil)
|
|
60
|
+
req.RemoteAddr = "192.0.2.1:1000"
|
|
61
|
+
req.Header.Set("Authorization", tc.token)
|
|
62
|
+
w := httptest.NewRecorder()
|
|
63
|
+
handler.ServeHTTP(w, req)
|
|
64
|
+
if w.Code != tc.want {
|
|
65
|
+
t.Fatalf("got %d want %d", w.Code, tc.want)
|
|
66
|
+
}
|
|
67
|
+
if w.Code == 429 && w.Header().Get("Retry-After") == "" {
|
|
68
|
+
t.Fatal("missing retry header")
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if s.total != 5 || s.rejected != 2 {
|
|
72
|
+
t.Fatalf("incorrect accounting: %d/%d", s.total, s.rejected)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
func TestStateRestartAndCorruption(t *testing.T) {
|
|
76
|
+
config := DefaultConfig()
|
|
77
|
+
config.StatePath = filepath.Join(t.TempDir(), "state.json")
|
|
78
|
+
s, err := New(config)
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
s.mu.Lock()
|
|
83
|
+
s.revision = 4
|
|
84
|
+
s.nextID = 1
|
|
85
|
+
s.incidents = []Incident{{ID: 1, Family: "auth", Phase: "active"}}
|
|
86
|
+
s.actions["auth"] = Action{Family: "auth", Reason: "test", ExpiresAt: time.Now().Add(time.Minute)}
|
|
87
|
+
s.mu.Unlock()
|
|
88
|
+
s.Close()
|
|
89
|
+
restored, err := New(config)
|
|
90
|
+
if err != nil {
|
|
91
|
+
t.Fatal(err)
|
|
92
|
+
}
|
|
93
|
+
if restored.revision != 4 || len(restored.incidents) != 1 || len(restored.actions) != 1 {
|
|
94
|
+
t.Fatal("lost persisted state")
|
|
95
|
+
}
|
|
96
|
+
restored.Close()
|
|
97
|
+
os.WriteFile(config.StatePath, []byte("corrupt"), 0600)
|
|
98
|
+
degraded, err := New(config)
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
defer degraded.Close()
|
|
103
|
+
if !degraded.storageError || degraded.config.Mode != config.Mode {
|
|
104
|
+
t.Fatal("invalid state weakened startup policy")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestOperatorLaneRequiresExplicitPeerAndKeepsAuth(t *testing.T) {
|
|
109
|
+
app, err := tests.NewTestApp()
|
|
110
|
+
if err != nil {
|
|
111
|
+
t.Fatal(err)
|
|
112
|
+
}
|
|
113
|
+
defer app.Cleanup()
|
|
114
|
+
c := DefaultConfig()
|
|
115
|
+
c.Mode = "enforce"
|
|
116
|
+
c.OperatorPeers = []string{"192.0.2.10/32"}
|
|
117
|
+
s, err := New(c)
|
|
118
|
+
if err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
defer s.Close()
|
|
122
|
+
s.actions["other"] = Action{Family: "other", ExpiresAt: time.Now().Add(time.Minute)}
|
|
123
|
+
r, _ := apis.NewRouter(app)
|
|
124
|
+
s.Bind(r)
|
|
125
|
+
r.GET("/probe", func(e *core.RequestEvent) error { return e.NoContent(204) })
|
|
126
|
+
r.GET("/private-probe", func(e *core.RequestEvent) error { return e.NoContent(204) }).Bind(apis.RequireSuperuserAuth())
|
|
127
|
+
mux, err := r.BuildMux()
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
for _, tc := range []struct {
|
|
132
|
+
peer, marker, path string
|
|
133
|
+
want int
|
|
134
|
+
}{
|
|
135
|
+
{"192.0.2.10:1", "1", "/probe", 204},
|
|
136
|
+
{"192.0.2.10:1", "", "/probe", 429},
|
|
137
|
+
{"198.51.100.1:1", "1", "/probe", 429},
|
|
138
|
+
{"192.0.2.10:1", "1", "/private-probe", 401},
|
|
139
|
+
} {
|
|
140
|
+
req := httptest.NewRequest("GET", tc.path, nil)
|
|
141
|
+
req.RemoteAddr = tc.peer
|
|
142
|
+
req.Header.Set("X-Spink-Operator", tc.marker)
|
|
143
|
+
w := httptest.NewRecorder()
|
|
144
|
+
mux.ServeHTTP(w, req)
|
|
145
|
+
if w.Code != tc.want {
|
|
146
|
+
t.Fatalf("%+v got %d", tc, w.Code)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package security
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
11
|
+
"github.com/pocketbase/pocketbase/core"
|
|
12
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
13
|
+
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func TestBatchAndSubscriptionReservationRelease(t *testing.T) {
|
|
17
|
+
app, err := tests.NewTestApp()
|
|
18
|
+
if err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
defer app.Cleanup()
|
|
22
|
+
config := DefaultConfig()
|
|
23
|
+
config.Mode = "enforce"
|
|
24
|
+
config.MaxWrites = 2
|
|
25
|
+
config.MaxSubscriptionsTotal = 2
|
|
26
|
+
if err := Register(app, config); err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
s := app.Store().Get("pb.security.service").(*Service)
|
|
30
|
+
defer s.Close()
|
|
31
|
+
event := func() *core.RequestEvent {
|
|
32
|
+
e := &core.RequestEvent{App: app}
|
|
33
|
+
e.Request = httptest.NewRequest("POST", "/api/batch", nil)
|
|
34
|
+
e.Response = httptest.NewRecorder()
|
|
35
|
+
return e
|
|
36
|
+
}
|
|
37
|
+
s.active["writes"] = 1
|
|
38
|
+
called := false
|
|
39
|
+
err = app.OnBatchRequest().Trigger(&core.BatchRequestEvent{RequestEvent: event(), Batch: make([]*core.InternalRequest, 3)}, func(e *core.BatchRequestEvent) error { called = true; return nil })
|
|
40
|
+
if err == nil || called || s.active["writes"] != 1 {
|
|
41
|
+
t.Fatal("oversized weighted batch admitted or permit leaked")
|
|
42
|
+
}
|
|
43
|
+
sentinel := errors.New("handler failed")
|
|
44
|
+
err = app.OnBatchRequest().Trigger(&core.BatchRequestEvent{RequestEvent: event(), Batch: make([]*core.InternalRequest, 2)}, func(e *core.BatchRequestEvent) error {
|
|
45
|
+
if s.active["writes"] != 2 {
|
|
46
|
+
t.Fatal("missing weight")
|
|
47
|
+
}
|
|
48
|
+
return sentinel
|
|
49
|
+
})
|
|
50
|
+
if err != sentinel || s.active["writes"] != 1 {
|
|
51
|
+
t.Fatal("batch failure leaked permits")
|
|
52
|
+
}
|
|
53
|
+
client := subscriptions.NewDefaultClient()
|
|
54
|
+
err = app.OnRealtimeConnectRequest().Trigger(&core.RealtimeConnectRequestEvent{RequestEvent: event(), Client: client}, func(e *core.RealtimeConnectRequestEvent) error {
|
|
55
|
+
subscribe := func(items []string, fail bool) error {
|
|
56
|
+
return app.OnRealtimeSubscribeRequest().Trigger(&core.RealtimeSubscribeRequestEvent{RequestEvent: event(), Client: client, Subscriptions: items}, func(e *core.RealtimeSubscribeRequestEvent) error {
|
|
57
|
+
if fail {
|
|
58
|
+
return sentinel
|
|
59
|
+
}
|
|
60
|
+
client.Unsubscribe()
|
|
61
|
+
client.Subscribe(items...)
|
|
62
|
+
return nil
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
if subscribe([]string{"a", "b"}, false) != nil || s.totalSubscriptions != 2 {
|
|
66
|
+
t.Fatal("subscriptions not reserved")
|
|
67
|
+
}
|
|
68
|
+
if subscribe([]string{"a", "b", "c"}, false) == nil || s.totalSubscriptions != 2 {
|
|
69
|
+
t.Fatal("aggregate limit not enforced")
|
|
70
|
+
}
|
|
71
|
+
if subscribe([]string{"a"}, true) != sentinel || s.totalSubscriptions != 2 {
|
|
72
|
+
t.Fatal("failed update changed reservation")
|
|
73
|
+
}
|
|
74
|
+
if subscribe([]string{"a"}, false) != nil || s.totalSubscriptions != 1 {
|
|
75
|
+
t.Fatal("unsubscribe did not free capacity")
|
|
76
|
+
}
|
|
77
|
+
return sentinel
|
|
78
|
+
})
|
|
79
|
+
if err != sentinel || s.totalSubscriptions != 0 || len(s.clients) != 0 {
|
|
80
|
+
t.Fatal("disconnect leaked subscriptions")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func TestManagementRevisionAndNetwork(t *testing.T) {
|
|
85
|
+
app, err := tests.NewTestApp()
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
defer app.Cleanup()
|
|
90
|
+
admin, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", "test@example.com")
|
|
91
|
+
if err != nil {
|
|
92
|
+
t.Fatal(err)
|
|
93
|
+
}
|
|
94
|
+
token, err := admin.NewAuthToken()
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
s, err := New(DefaultConfig())
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
defer s.Close()
|
|
103
|
+
r, _ := apis.NewRouter(app)
|
|
104
|
+
s.Bind(r)
|
|
105
|
+
h, err := r.BuildMux()
|
|
106
|
+
if err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
body, _ := json.Marshal(DefaultConfig())
|
|
110
|
+
for _, tc := range []struct {
|
|
111
|
+
peer, revision string
|
|
112
|
+
want int
|
|
113
|
+
}{{"192.0.2.1:1234", "1", 403}, {"127.0.0.1:1234", "0", 409}, {"127.0.0.1:1234", "1", 200}, {"127.0.0.1:1234", "1", 409}} {
|
|
114
|
+
req := httptest.NewRequest("PUT", "/api/security/policy", strings.NewReader(string(body)))
|
|
115
|
+
req.RemoteAddr = tc.peer
|
|
116
|
+
req.Header.Set("Content-Type", "application/json")
|
|
117
|
+
req.Header.Set("Authorization", token)
|
|
118
|
+
req.Header.Set("If-Match", tc.revision)
|
|
119
|
+
w := httptest.NewRecorder()
|
|
120
|
+
h.ServeHTTP(w, req)
|
|
121
|
+
if w.Code != tc.want {
|
|
122
|
+
t.Fatalf("got %d want %d", w.Code, tc.want)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if s.revision != 2 {
|
|
126
|
+
t.Fatal("rejected mutation changed policy")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package security
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"math"
|
|
6
|
+
"net/netip"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type Config struct {
|
|
11
|
+
OperatorPeers []string `json:"-"`
|
|
12
|
+
StatePath string `json:"-"`
|
|
13
|
+
Mode string `json:"mode"`
|
|
14
|
+
RequestsPerSecond float64 `json:"requestsPerSecond"`
|
|
15
|
+
Burst int `json:"burst"`
|
|
16
|
+
IdentityPerSecond float64 `json:"identityPerSecond"`
|
|
17
|
+
IdentityBurst int `json:"identityBurst"`
|
|
18
|
+
MaxConcurrent int `json:"maxConcurrent"`
|
|
19
|
+
MaxWrites int `json:"maxWrites"`
|
|
20
|
+
MaxFiles int `json:"maxFiles"`
|
|
21
|
+
MaxRealtime int `json:"maxRealtime"`
|
|
22
|
+
MaxSubscriptionsTotal int `json:"maxSubscriptionsTotal"`
|
|
23
|
+
MaxSubscriptions int `json:"maxSubscriptions"`
|
|
24
|
+
MaxIdentities int `json:"maxIdentities"`
|
|
25
|
+
TrustedPeers []string `json:"trustedPeers"`
|
|
26
|
+
ManagementPeers []string `json:"managementPeers"`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func DefaultConfig() Config {
|
|
30
|
+
return Config{Mode: "observe", RequestsPerSecond: 100, Burst: 200, IdentityPerSecond: 20, IdentityBurst: 40, MaxConcurrent: 64, MaxWrites: 8, MaxFiles: 8, MaxRealtime: 128, MaxSubscriptions: 100, MaxSubscriptionsTotal: 4096, MaxIdentities: 4096, ManagementPeers: []string{"127.0.0.0/8", "::1/128"}}
|
|
31
|
+
}
|
|
32
|
+
func (c Config) Validate() error {
|
|
33
|
+
if c.StatePath != "" && !filepath.IsAbs(c.StatePath) {
|
|
34
|
+
return errors.New("state path must be absolute")
|
|
35
|
+
}
|
|
36
|
+
if c.Mode != "disabled" && c.Mode != "observe" && c.Mode != "enforce" {
|
|
37
|
+
return errors.New("invalid security mode")
|
|
38
|
+
}
|
|
39
|
+
for _, v := range []float64{c.RequestsPerSecond, c.IdentityPerSecond} {
|
|
40
|
+
if math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 || v > 100000 {
|
|
41
|
+
return errors.New("rate must be finite and between 0 and 100000")
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for _, v := range []int{c.Burst, c.IdentityBurst, c.MaxConcurrent, c.MaxWrites, c.MaxFiles, c.MaxRealtime, c.MaxSubscriptions, c.MaxSubscriptionsTotal, c.MaxIdentities} {
|
|
45
|
+
if v < 1 || v > 50000 {
|
|
46
|
+
return errors.New("security limits must be between 1 and 50000")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if len(c.TrustedPeers) > 64 || len(c.ManagementPeers) > 64 || len(c.OperatorPeers) > 64 {
|
|
50
|
+
return errors.New("too many trusted networks")
|
|
51
|
+
}
|
|
52
|
+
for _, v := range append(append(append([]string{}, c.TrustedPeers...), c.ManagementPeers...), c.OperatorPeers...) {
|
|
53
|
+
if _, err := netip.ParsePrefix(v); err != nil {
|
|
54
|
+
return errors.New("invalid trusted network")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
func cloneConfig(c Config) Config {
|
|
60
|
+
c.OperatorPeers = append([]string{}, c.OperatorPeers...)
|
|
61
|
+
c.TrustedPeers = append([]string{}, c.TrustedPeers...)
|
|
62
|
+
c.ManagementPeers = append([]string{}, c.ManagementPeers...)
|
|
63
|
+
return c
|
|
64
|
+
}
|
|
65
|
+
func inNetworks(ip string, networks []string) bool {
|
|
66
|
+
a, err := netip.ParseAddr(ip)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
a = a.Unmap()
|
|
71
|
+
for _, raw := range networks {
|
|
72
|
+
p, err := netip.ParsePrefix(raw)
|
|
73
|
+
if err == nil && p.Contains(a) {
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package security
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
6
|
+
"github.com/pocketbase/pocketbase/core"
|
|
7
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestNativeDashboardExtensionAndSnapshotAuthorization(t *testing.T) {
|
|
15
|
+
app, err := tests.NewTestApp()
|
|
16
|
+
if err != nil {
|
|
17
|
+
t.Fatal(err)
|
|
18
|
+
}
|
|
19
|
+
defer app.Cleanup()
|
|
20
|
+
if err := Register(app, DefaultConfig()); err != nil {
|
|
21
|
+
t.Fatal(err)
|
|
22
|
+
}
|
|
23
|
+
service := app.Store().Get("pb.security.service").(*Service)
|
|
24
|
+
defer service.Close()
|
|
25
|
+
r, err := apis.NewRouter(app)
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
event := &core.ServeEvent{App: app, Router: r}
|
|
30
|
+
if err := app.OnServe().Trigger(event); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
if len(event.UIExtensions) != 1 || event.UIExtensions[0].Name != "security" {
|
|
34
|
+
t.Fatal("native extension missing")
|
|
35
|
+
}
|
|
36
|
+
h, err := r.BuildMux()
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
w := httptest.NewRecorder()
|
|
41
|
+
h.ServeHTTP(w, httptest.NewRequest("GET", "/_/extensions.js", nil))
|
|
42
|
+
if w.Code != 200 || !strings.Contains(w.Body.String(), "superuserOnly('#/security'") {
|
|
43
|
+
t.Fatal("extension is not loadable")
|
|
44
|
+
}
|
|
45
|
+
w = httptest.NewRecorder()
|
|
46
|
+
h.ServeHTTP(w, httptest.NewRequest("GET", "/_security/", nil))
|
|
47
|
+
if w.Code != 307 || w.Header().Get("Location") != "/_/#/security" {
|
|
48
|
+
t.Fatal("legacy link not redirected")
|
|
49
|
+
}
|
|
50
|
+
admin, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", "test@example.com")
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
adminToken, _ := admin.NewAuthToken()
|
|
55
|
+
users, err := app.FindCollectionByNameOrId("users")
|
|
56
|
+
if err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
user := core.NewRecord(users)
|
|
60
|
+
user.SetEmail("adapter@example.com")
|
|
61
|
+
user.SetPassword("fixture-password-123")
|
|
62
|
+
if err := app.Save(user); err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
userToken, _ := user.NewAuthToken()
|
|
66
|
+
for _, tc := range []struct {
|
|
67
|
+
token, peer string
|
|
68
|
+
code int
|
|
69
|
+
}{{"", "127.0.0.1:123", 401}, {userToken, "127.0.0.1:123", 403}, {adminToken, "192.0.2.1:123", 403}, {adminToken, "127.0.0.1:123", 200}} {
|
|
70
|
+
request := httptest.NewRequest("GET", "/api/security/policy/snapshot", nil)
|
|
71
|
+
request.Header.Set("Authorization", tc.token)
|
|
72
|
+
request.RemoteAddr = tc.peer
|
|
73
|
+
w := httptest.NewRecorder()
|
|
74
|
+
h.ServeHTTP(w, request)
|
|
75
|
+
if w.Code != tc.code {
|
|
76
|
+
t.Fatalf("snapshot returned %d want %d", w.Code, tc.code)
|
|
77
|
+
}
|
|
78
|
+
if w.Code == 200 {
|
|
79
|
+
var snapshot struct {
|
|
80
|
+
Revision uint64
|
|
81
|
+
Policy Config
|
|
82
|
+
}
|
|
83
|
+
if json.Unmarshal(w.Body.Bytes(), &snapshot) != nil || snapshot.Revision != 1 || snapshot.Policy.Mode != "observe" {
|
|
84
|
+
t.Fatal("invalid snapshot")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
func TestBoundedTrafficHistory(t *testing.T) {
|
|
90
|
+
s, err := New(DefaultConfig())
|
|
91
|
+
if err != nil {
|
|
92
|
+
t.Fatal(err)
|
|
93
|
+
}
|
|
94
|
+
defer s.Close()
|
|
95
|
+
now := time.Now()
|
|
96
|
+
for i := 0; i < 80; i++ {
|
|
97
|
+
s.observe("reads", true, 429, 0)
|
|
98
|
+
s.evaluate(now.Add(time.Duration(i) * 10 * time.Second))
|
|
99
|
+
}
|
|
100
|
+
if len(s.history) != 60 || s.history[59].Count != 1 || s.history[59].Rejected != 1 {
|
|
101
|
+
t.Fatal("invalid bounded traffic history")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
package security
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"maps"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strconv"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
10
|
+
"github.com/pocketbase/pocketbase/core"
|
|
11
|
+
"github.com/pocketbase/pocketbase/tools/router"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func (s *Service) bindManagement(r *router.Router[*core.RequestEvent]) {
|
|
15
|
+
g := r.Group("/api/security").Bind(apis.RequireSuperuserAuth()).BindFunc(func(e *core.RequestEvent) error {
|
|
16
|
+
s.mu.Lock()
|
|
17
|
+
allowed := inNetworks(e.RemoteIP(), s.config.ManagementPeers)
|
|
18
|
+
s.mu.Unlock()
|
|
19
|
+
if !allowed {
|
|
20
|
+
return e.ForbiddenError("Management network required", nil)
|
|
21
|
+
}
|
|
22
|
+
return e.Next()
|
|
23
|
+
})
|
|
24
|
+
g.GET("/status", func(e *core.RequestEvent) error {
|
|
25
|
+
s.mu.Lock()
|
|
26
|
+
result := map[string]any{"mode": s.config.Mode, "revision": s.revision, "identities": len(s.identities), "active": maps.Clone(s.active), "observed": s.total, "rejected": s.rejected, "subscriptions": s.totalSubscriptions, "storageDegraded": s.storageError, "detector": "heuristic-v1", "actions": currentActions(s.actions, time.Now()), "startedAt": s.startedAt, "lastEvaluated": s.lastEvaluated, "traffic": append([]TrafficWindow{}, s.history...), "checkpointEnabled": s.config.StatePath != ""}
|
|
27
|
+
s.mu.Unlock()
|
|
28
|
+
return e.JSON(http.StatusOK, result)
|
|
29
|
+
})
|
|
30
|
+
g.GET("/incidents", func(e *core.RequestEvent) error {
|
|
31
|
+
s.mu.Lock()
|
|
32
|
+
items := append([]Incident{}, s.incidents...)
|
|
33
|
+
s.mu.Unlock()
|
|
34
|
+
limit := 50
|
|
35
|
+
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
|
36
|
+
n, err := strconv.Atoi(raw)
|
|
37
|
+
if err != nil || n < 1 || n > 100 {
|
|
38
|
+
return e.BadRequestError("Limit must be 1 to 100", nil)
|
|
39
|
+
}
|
|
40
|
+
limit = n
|
|
41
|
+
}
|
|
42
|
+
after := uint64(0)
|
|
43
|
+
if raw := e.Request.URL.Query().Get("after"); raw != "" {
|
|
44
|
+
var err error
|
|
45
|
+
after, err = strconv.ParseUint(raw, 10, 64)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return e.BadRequestError("Invalid cursor", nil)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
page := []Incident{}
|
|
51
|
+
next := uint64(0)
|
|
52
|
+
for _, item := range items {
|
|
53
|
+
if item.ID <= after {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if len(page) == limit {
|
|
57
|
+
next = page[len(page)-1].ID
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
page = append(page, item)
|
|
61
|
+
}
|
|
62
|
+
return e.JSON(http.StatusOK, map[string]any{"items": page, "nextCursor": next})
|
|
63
|
+
})
|
|
64
|
+
g.GET("/policy/snapshot", func(e *core.RequestEvent) error {
|
|
65
|
+
s.mu.Lock()
|
|
66
|
+
result := struct {
|
|
67
|
+
Policy Config `json:"policy"`
|
|
68
|
+
Revision uint64 `json:"revision"`
|
|
69
|
+
}{cloneConfig(s.config), s.revision}
|
|
70
|
+
s.mu.Unlock()
|
|
71
|
+
return e.JSON(200, result)
|
|
72
|
+
})
|
|
73
|
+
g.GET("/policy", func(e *core.RequestEvent) error {
|
|
74
|
+
s.mu.Lock()
|
|
75
|
+
c, revision := cloneConfig(s.config), s.revision
|
|
76
|
+
s.mu.Unlock()
|
|
77
|
+
e.Response.Header().Set("ETag", strconv.FormatUint(revision, 10))
|
|
78
|
+
return e.JSON(http.StatusOK, c)
|
|
79
|
+
})
|
|
80
|
+
g.PUT("/policy", func(e *core.RequestEvent) error {
|
|
81
|
+
c := Config{}
|
|
82
|
+
if err := e.BindBody(&c); err != nil {
|
|
83
|
+
return e.BadRequestError("Invalid policy", err)
|
|
84
|
+
}
|
|
85
|
+
if err := c.Validate(); err != nil {
|
|
86
|
+
return e.BadRequestError("Invalid policy", err)
|
|
87
|
+
}
|
|
88
|
+
s.mu.Lock()
|
|
89
|
+
if e.Request.Header.Get("If-Match") != strconv.FormatUint(s.revision, 10) {
|
|
90
|
+
s.mu.Unlock()
|
|
91
|
+
return router.NewApiError(409, "Policy revision conflict", nil)
|
|
92
|
+
}
|
|
93
|
+
if c.MaxIdentities < len(s.identities) {
|
|
94
|
+
s.mu.Unlock()
|
|
95
|
+
return e.BadRequestError("Cannot shrink below current identity count", nil)
|
|
96
|
+
}
|
|
97
|
+
c.OperatorPeers = s.config.OperatorPeers
|
|
98
|
+
c.StatePath = s.config.StatePath
|
|
99
|
+
s.config = cloneConfig(c)
|
|
100
|
+
s.revision++
|
|
101
|
+
revision := s.revision
|
|
102
|
+
s.mu.Unlock()
|
|
103
|
+
s.persist()
|
|
104
|
+
e.App.Logger().Info("Security policy updated", "revision", revision, "operator", e.Auth.Id)
|
|
105
|
+
return e.JSON(http.StatusOK, map[string]any{"revision": revision})
|
|
106
|
+
})
|
|
107
|
+
g.POST("/incidents/{id}/acknowledge", func(e *core.RequestEvent) error {
|
|
108
|
+
id, err := strconv.ParseUint(e.Request.PathValue("id"), 10, 64)
|
|
109
|
+
if err != nil {
|
|
110
|
+
return e.BadRequestError("Invalid incident", nil)
|
|
111
|
+
}
|
|
112
|
+
s.mu.Lock()
|
|
113
|
+
for i := range s.incidents {
|
|
114
|
+
if s.incidents[i].ID == id {
|
|
115
|
+
s.incidents[i].Acknowledged = true
|
|
116
|
+
s.mu.Unlock()
|
|
117
|
+
s.persist()
|
|
118
|
+
e.App.Logger().Info("Security incident acknowledged", "incident", id, "operator", e.Auth.Id)
|
|
119
|
+
return e.NoContent(204)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
s.mu.Unlock()
|
|
123
|
+
return e.NotFoundError("Incident not found", nil)
|
|
124
|
+
})
|
|
125
|
+
g.POST("/actions", func(e *core.RequestEvent) error {
|
|
126
|
+
var a Action
|
|
127
|
+
if err := e.BindBody(&a); err != nil {
|
|
128
|
+
return e.BadRequestError("Invalid action", err)
|
|
129
|
+
}
|
|
130
|
+
switch a.Family {
|
|
131
|
+
case "auth", "reads", "writes", "files", "realtime", "other":
|
|
132
|
+
default:
|
|
133
|
+
return e.BadRequestError("Invalid action family", nil)
|
|
134
|
+
}
|
|
135
|
+
if len(a.Reason) < 1 || len(a.Reason) > 200 || !a.ExpiresAt.After(time.Now()) || a.ExpiresAt.After(time.Now().Add(15*time.Minute)) {
|
|
136
|
+
return e.BadRequestError("Reason and expiry within 15 minutes required", nil)
|
|
137
|
+
}
|
|
138
|
+
s.mu.Lock()
|
|
139
|
+
s.actions[a.Family] = a
|
|
140
|
+
s.mu.Unlock()
|
|
141
|
+
s.persist()
|
|
142
|
+
e.App.Logger().Info("Temporary security action set", "family", a.Family, "expiresAt", a.ExpiresAt, "operator", e.Auth.Id)
|
|
143
|
+
return e.JSON(http.StatusOK, a)
|
|
144
|
+
})
|
|
145
|
+
g.DELETE("/actions/{family}", func(e *core.RequestEvent) error {
|
|
146
|
+
family := e.Request.PathValue("family")
|
|
147
|
+
s.mu.Lock()
|
|
148
|
+
_, exists := s.actions[family]
|
|
149
|
+
delete(s.actions, family)
|
|
150
|
+
s.mu.Unlock()
|
|
151
|
+
if !exists {
|
|
152
|
+
return e.NotFoundError("Action not found", nil)
|
|
153
|
+
}
|
|
154
|
+
s.persist()
|
|
155
|
+
e.App.Logger().Info("Security action cancelled", "family", family, "operator", e.Auth.Id)
|
|
156
|
+
return e.NoContent(204)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func currentActions(actions map[string]Action, now time.Time) map[string]Action {
|
|
162
|
+
result := maps.Clone(actions)
|
|
163
|
+
for family, action := range result {
|
|
164
|
+
if !action.ExpiresAt.After(now) {
|
|
165
|
+
delete(result, family)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result
|
|
169
|
+
}
|