@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
package/edge/gateway.go
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
package edge
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
_ "embed"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"io"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httputil"
|
|
12
|
+
"net/netip"
|
|
13
|
+
"net/url"
|
|
14
|
+
"path"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
"sync/atomic"
|
|
18
|
+
"time"
|
|
19
|
+
|
|
20
|
+
"github.com/spink-dev/pocketbase-extension/security"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
//go:embed openapi.json
|
|
24
|
+
var contract []byte
|
|
25
|
+
|
|
26
|
+
type operatorKey struct{}
|
|
27
|
+
|
|
28
|
+
type bucket struct {
|
|
29
|
+
tokens float64
|
|
30
|
+
updated time.Time
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (b *bucket) take(now time.Time, rate, burst int) bool {
|
|
34
|
+
if b.updated.IsZero() {
|
|
35
|
+
b.tokens = float64(burst)
|
|
36
|
+
} else {
|
|
37
|
+
b.tokens = min(float64(burst), b.tokens+now.Sub(b.updated).Seconds()*float64(rate))
|
|
38
|
+
}
|
|
39
|
+
b.updated = now
|
|
40
|
+
if b.tokens < 1 {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
b.tokens--
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Gateway struct {
|
|
48
|
+
mu sync.Mutex
|
|
49
|
+
policy Policy
|
|
50
|
+
snapshot Snapshot
|
|
51
|
+
valid bool
|
|
52
|
+
global bucket
|
|
53
|
+
clients map[netip.Addr]*bucket
|
|
54
|
+
active int
|
|
55
|
+
admin chan struct{}
|
|
56
|
+
proxy *httputil.ReverseProxy
|
|
57
|
+
upstreamHealthy atomic.Bool
|
|
58
|
+
accepted atomic.Uint64
|
|
59
|
+
rejected atomic.Uint64
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func New(upstream *url.URL, p Policy) (*Gateway, error) {
|
|
63
|
+
if err := p.Validate(); err != nil {
|
|
64
|
+
return nil, err
|
|
65
|
+
}
|
|
66
|
+
g := &Gateway{policy: p, clients: map[netip.Addr]*bucket{}, admin: make(chan struct{}, 8)}
|
|
67
|
+
g.proxy = &httputil.ReverseProxy{
|
|
68
|
+
Rewrite: func(r *httputil.ProxyRequest) {
|
|
69
|
+
r.SetURL(upstream)
|
|
70
|
+
// Only our sanitized peer identity crosses the private network.
|
|
71
|
+
r.Out.Header.Del("X-Spink-Operator")
|
|
72
|
+
if r.In.Context().Value(operatorKey{}) == true {
|
|
73
|
+
r.Out.Header.Set("X-Spink-Operator", "1")
|
|
74
|
+
}
|
|
75
|
+
r.Out.Header.Del("Forwarded")
|
|
76
|
+
r.Out.Header.Del("X-Real-IP")
|
|
77
|
+
r.Out.Header.Del("X-Forwarded-Host")
|
|
78
|
+
r.Out.Header.Del("X-Forwarded-Proto")
|
|
79
|
+
r.Out.Header.Del("X-Forwarded-For")
|
|
80
|
+
host, _, _ := net.SplitHostPort(r.In.RemoteAddr)
|
|
81
|
+
r.Out.Header.Set("X-Forwarded-For", host)
|
|
82
|
+
},
|
|
83
|
+
Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext, MaxIdleConns: 128, MaxIdleConnsPerHost: 128, IdleConnTimeout: 60 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxResponseHeaderBytes: 64 << 10},
|
|
84
|
+
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
|
85
|
+
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
|
86
|
+
},
|
|
87
|
+
FlushInterval: -1,
|
|
88
|
+
}
|
|
89
|
+
return g, nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Apply never resets budgets during reload, including unchanged polling results.
|
|
93
|
+
func (g *Gateway) Apply(p Policy, s Snapshot, valid bool) {
|
|
94
|
+
g.mu.Lock()
|
|
95
|
+
defer g.mu.Unlock()
|
|
96
|
+
if valid {
|
|
97
|
+
g.policy = p
|
|
98
|
+
g.snapshot = s
|
|
99
|
+
}
|
|
100
|
+
g.valid = valid
|
|
101
|
+
}
|
|
102
|
+
func (g *Gateway) SetHealthy(v bool) { g.upstreamHealthy.Store(v) }
|
|
103
|
+
func (g *Gateway) reject(w http.ResponseWriter, code int) {
|
|
104
|
+
g.rejected.Add(1)
|
|
105
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
106
|
+
if code == 429 || code == 503 {
|
|
107
|
+
w.Header().Set("Retry-After", "1")
|
|
108
|
+
}
|
|
109
|
+
http.Error(w, http.StatusText(code), code)
|
|
110
|
+
}
|
|
111
|
+
func canonical(r *http.Request) bool {
|
|
112
|
+
// Reject ambiguous decoding instead of normalizing differently from the backend.
|
|
113
|
+
return r.URL.RawPath == "" && !strings.ContainsAny(r.URL.Path, "\\%\x00") && path.Clean(r.URL.Path) == strings.TrimSuffix(r.URL.Path, "/") && !strings.Contains(r.URL.Path, "//")
|
|
114
|
+
}
|
|
115
|
+
func client(r *http.Request, p Policy) (netip.Addr, bool) {
|
|
116
|
+
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
117
|
+
if err != nil {
|
|
118
|
+
return netip.Addr{}, false
|
|
119
|
+
}
|
|
120
|
+
ip, err := netip.ParseAddr(host)
|
|
121
|
+
if err != nil {
|
|
122
|
+
return ip, false
|
|
123
|
+
}
|
|
124
|
+
ip = ip.Unmap()
|
|
125
|
+
if !contains(p.trusted, ip) {
|
|
126
|
+
return ip, true
|
|
127
|
+
}
|
|
128
|
+
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
|
129
|
+
if len(parts) > 16 {
|
|
130
|
+
return ip, false
|
|
131
|
+
}
|
|
132
|
+
for i := len(parts) - 1; i >= 0; i-- {
|
|
133
|
+
next, e := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
|
134
|
+
if e != nil {
|
|
135
|
+
return ip, false
|
|
136
|
+
}
|
|
137
|
+
ip = next.Unmap()
|
|
138
|
+
if !contains(p.trusted, ip) {
|
|
139
|
+
return ip, true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return ip, true
|
|
143
|
+
}
|
|
144
|
+
func publicPath(r *http.Request, p Policy) bool {
|
|
145
|
+
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
146
|
+
if len(parts) == 2 && parts[0] == "api" && (parts[1] == "health" || parts[1] == "realtime") {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
if len(parts) < 3 || parts[0] != "api" || (parts[1] != "collections" && parts[1] != "files") {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
allowed := false
|
|
153
|
+
for _, c := range p.Collections {
|
|
154
|
+
if parts[2] == c {
|
|
155
|
+
allowed = true
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if !allowed {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
if parts[1] == "files" {
|
|
163
|
+
return len(parts) >= 5
|
|
164
|
+
}
|
|
165
|
+
// Collection schema/settings APIs never cross the public listener.
|
|
166
|
+
return len(parts) >= 4 && (parts[3] == "records" || strings.HasPrefix(parts[3], "auth-") || strings.HasPrefix(parts[3], "request-") || strings.HasPrefix(parts[3], "confirm-"))
|
|
167
|
+
}
|
|
168
|
+
func (g *Gateway) Public(w http.ResponseWriter, r *http.Request) {
|
|
169
|
+
if !canonical(r) {
|
|
170
|
+
g.reject(w, 400)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
g.mu.Lock()
|
|
174
|
+
p := g.policy
|
|
175
|
+
ip, ok := client(r, p)
|
|
176
|
+
code := 0
|
|
177
|
+
now := time.Now()
|
|
178
|
+
switch {
|
|
179
|
+
case !g.valid:
|
|
180
|
+
code = 503
|
|
181
|
+
case !ok:
|
|
182
|
+
code = 400
|
|
183
|
+
case contains(p.blocked, ip):
|
|
184
|
+
code = 403
|
|
185
|
+
case !publicPath(r, p):
|
|
186
|
+
code = 403
|
|
187
|
+
}
|
|
188
|
+
if code == 0 {
|
|
189
|
+
for _, prefix := range p.BlockedPaths {
|
|
190
|
+
if strings.HasPrefix(r.URL.Path, prefix) {
|
|
191
|
+
code = 403
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if code == 0 && g.snapshot.Policy.Mode == "enforce" {
|
|
197
|
+
if a, exists := g.snapshot.Actions[security.RequestFamily(r)]; exists && a.ExpiresAt.After(now) {
|
|
198
|
+
code = 403
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if code == 0 && !g.global.take(now, p.Rate, p.Burst) {
|
|
202
|
+
code = 429
|
|
203
|
+
}
|
|
204
|
+
if code == 0 {
|
|
205
|
+
b := g.clients[ip]
|
|
206
|
+
if b == nil {
|
|
207
|
+
if len(g.clients) >= p.MaxClients {
|
|
208
|
+
for key, old := range g.clients {
|
|
209
|
+
if now.Sub(old.updated) > time.Minute {
|
|
210
|
+
delete(g.clients, key)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if len(g.clients) >= p.MaxClients {
|
|
215
|
+
code = 429
|
|
216
|
+
} else {
|
|
217
|
+
b = &bucket{}
|
|
218
|
+
g.clients[ip] = b
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if b != nil && !b.take(now, p.ClientRate, p.ClientBurst) {
|
|
222
|
+
code = 429
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if code == 0 {
|
|
226
|
+
if g.active >= p.MaxConcurrent {
|
|
227
|
+
code = 503
|
|
228
|
+
} else {
|
|
229
|
+
g.active++
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
g.mu.Unlock()
|
|
233
|
+
if code != 0 {
|
|
234
|
+
g.reject(w, code)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
defer func() { g.mu.Lock(); g.active--; g.mu.Unlock() }()
|
|
238
|
+
if r.ContentLength > p.MaxBodyBytes {
|
|
239
|
+
g.reject(w, 413)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
// Fully bound and validate chunked bodies before any upstream side effect.
|
|
243
|
+
if r.Body != nil {
|
|
244
|
+
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, p.MaxBodyBytes))
|
|
245
|
+
r.Body.Close()
|
|
246
|
+
if err != nil {
|
|
247
|
+
g.reject(w, 413)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
r.Body = io.NopCloser(bytes.NewReader(b))
|
|
251
|
+
r.ContentLength = int64(len(b))
|
|
252
|
+
}
|
|
253
|
+
r.RemoteAddr = net.JoinHostPort(ip.String(), "0")
|
|
254
|
+
g.accepted.Add(1)
|
|
255
|
+
g.proxy.ServeHTTP(w, r)
|
|
256
|
+
}
|
|
257
|
+
func (g *Gateway) Operator(w http.ResponseWriter, r *http.Request) {
|
|
258
|
+
switch r.URL.Path {
|
|
259
|
+
case "/edge/openapi.json":
|
|
260
|
+
w.Header().Set("Content-Type", "application/json")
|
|
261
|
+
w.Write(contract)
|
|
262
|
+
return
|
|
263
|
+
case "/edge/healthz":
|
|
264
|
+
w.WriteHeader(204)
|
|
265
|
+
return
|
|
266
|
+
case "/edge/readyz":
|
|
267
|
+
g.mu.Lock()
|
|
268
|
+
valid := g.valid
|
|
269
|
+
g.mu.Unlock()
|
|
270
|
+
if !valid || !g.upstreamHealthy.Load() {
|
|
271
|
+
http.Error(w, "not ready", 503)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
w.WriteHeader(204)
|
|
275
|
+
return
|
|
276
|
+
case "/edge/status":
|
|
277
|
+
g.mu.Lock()
|
|
278
|
+
state := map[string]any{"policyReady": g.valid, "upstreamHealthy": g.upstreamHealthy.Load(), "active": g.active, "trackedClients": len(g.clients), "accepted": g.accepted.Load(), "rejected": g.rejected.Load()}
|
|
279
|
+
g.mu.Unlock()
|
|
280
|
+
w.Header().Set("Content-Type", "application/json")
|
|
281
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
282
|
+
json.NewEncoder(w).Encode(state)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
select {
|
|
286
|
+
case g.admin <- struct{}{}:
|
|
287
|
+
defer func() { <-g.admin }()
|
|
288
|
+
default:
|
|
289
|
+
g.reject(w, 503)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
// Operator uploads retain native limits without buffering large backups.
|
|
293
|
+
r = r.WithContext(context.WithValue(r.Context(), operatorKey{}, true))
|
|
294
|
+
g.proxy.ServeHTTP(w, r)
|
|
295
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
package edge
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"net/url"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync/atomic"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/spink-dev/pocketbase-extension/security"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func policy() Policy {
|
|
19
|
+
return Policy{Collections: []string{"notes"}, Rate: 100, Burst: 200, ClientRate: 20, ClientBurst: 40, MaxClients: 16, MaxConcurrent: 2, MaxBodyBytes: 1024}
|
|
20
|
+
}
|
|
21
|
+
func snap() Snapshot {
|
|
22
|
+
c := security.DefaultConfig()
|
|
23
|
+
c.Mode = "enforce"
|
|
24
|
+
return Snapshot{Version: 1, Revision: 1, Policy: c, Actions: map[string]security.Action{}}
|
|
25
|
+
}
|
|
26
|
+
func fixture(t *testing.T, p Policy, h http.HandlerFunc) (*Gateway, *atomic.Int64) {
|
|
27
|
+
t.Helper()
|
|
28
|
+
count := new(atomic.Int64)
|
|
29
|
+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
30
|
+
count.Add(1)
|
|
31
|
+
if h != nil {
|
|
32
|
+
h(w, r)
|
|
33
|
+
} else {
|
|
34
|
+
w.WriteHeader(204)
|
|
35
|
+
}
|
|
36
|
+
}))
|
|
37
|
+
t.Cleanup(s.Close)
|
|
38
|
+
u, _ := url.Parse(s.URL)
|
|
39
|
+
g, err := New(u, p)
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
g.Apply(g.policy, snap(), true)
|
|
44
|
+
return g, count
|
|
45
|
+
}
|
|
46
|
+
func req(target string) *http.Request {
|
|
47
|
+
r := httptest.NewRequest("GET", target, nil)
|
|
48
|
+
r.RemoteAddr = "192.0.2.10:1234"
|
|
49
|
+
return r
|
|
50
|
+
}
|
|
51
|
+
func TestRejectBeforeUpstream(t *testing.T) {
|
|
52
|
+
cases := []struct {
|
|
53
|
+
name, path string
|
|
54
|
+
code int
|
|
55
|
+
alter func(*Gateway, *http.Request)
|
|
56
|
+
}{
|
|
57
|
+
{"admin", "/_/", 403, nil},
|
|
58
|
+
{"schema", "/api/collections/notes", 403, nil},
|
|
59
|
+
{"superuser", "/api/collections/_superusers/auth-with-password", 403, nil},
|
|
60
|
+
{"superuserID", "/api/collections/pbc_3142635823/auth-with-password", 403, nil},
|
|
61
|
+
{"management", "/api/security/status", 403, nil},
|
|
62
|
+
{"batch", "/api/batch", 403, nil},
|
|
63
|
+
{"unknownCollection", "/api/collections/hidden/records", 403, nil},
|
|
64
|
+
{"ambiguous", "/api/collections/notes/../_superusers/records", 400, nil},
|
|
65
|
+
{"encoded", "/api/collections/%6eotes/records", 400, nil},
|
|
66
|
+
{"CIDR", "/api/health", 403, func(g *Gateway, r *http.Request) {
|
|
67
|
+
p := g.policy
|
|
68
|
+
p.BlockedCIDRs = []string{"192.0.2.0/24"}
|
|
69
|
+
p.Validate()
|
|
70
|
+
g.Apply(p, snap(), true)
|
|
71
|
+
}},
|
|
72
|
+
{"path", "/api/health", 403, func(g *Gateway, r *http.Request) {
|
|
73
|
+
p := g.policy
|
|
74
|
+
p.BlockedPaths = []string{"/api/health"}
|
|
75
|
+
g.Apply(p, snap(), true)
|
|
76
|
+
}},
|
|
77
|
+
{"action", "/api/collections/notes/records", 403, func(g *Gateway, r *http.Request) {
|
|
78
|
+
s := snap()
|
|
79
|
+
s.Actions["reads"] = security.Action{Family: "reads", ExpiresAt: time.Now().Add(time.Minute)}
|
|
80
|
+
g.Apply(g.policy, s, true)
|
|
81
|
+
}},
|
|
82
|
+
{"unready", "/api/health", 503, func(g *Gateway, r *http.Request) { g.Apply(Policy{}, Snapshot{}, false) }},
|
|
83
|
+
{"body", "/api/collections/notes/records", 413, func(g *Gateway, r *http.Request) {
|
|
84
|
+
*r = *httptest.NewRequest("POST", r.URL.String(), strings.NewReader(strings.Repeat("a", 1025)))
|
|
85
|
+
r.RemoteAddr = "192.0.2.10:1234"
|
|
86
|
+
}},
|
|
87
|
+
{"chunked", "/api/collections/notes/records", 413, func(g *Gateway, r *http.Request) {
|
|
88
|
+
*r = *httptest.NewRequest("POST", r.URL.String(), strings.NewReader(strings.Repeat("a", 1025)))
|
|
89
|
+
r.RemoteAddr = "192.0.2.10:1234"
|
|
90
|
+
r.ContentLength = -1
|
|
91
|
+
}},
|
|
92
|
+
}
|
|
93
|
+
for _, tc := range cases {
|
|
94
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
95
|
+
g, n := fixture(t, policy(), nil)
|
|
96
|
+
r := req(tc.path)
|
|
97
|
+
if tc.alter != nil {
|
|
98
|
+
tc.alter(g, r)
|
|
99
|
+
}
|
|
100
|
+
w := httptest.NewRecorder()
|
|
101
|
+
g.Public(w, r)
|
|
102
|
+
if w.Code != tc.code || n.Load() != 0 {
|
|
103
|
+
t.Fatalf("status=%d upstream=%d", w.Code, n.Load())
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
func TestForwardedIdentity(t *testing.T) {
|
|
109
|
+
g, _ := fixture(t, policy(), func(w http.ResponseWriter, r *http.Request) {
|
|
110
|
+
if r.Header.Get("X-Forwarded-For") != "192.0.2.10" || r.Header.Get("X-Real-IP") != "" || r.Header.Get("Forwarded") != "" {
|
|
111
|
+
t.Errorf("spoofed forwarding headers: %v", r.Header)
|
|
112
|
+
}
|
|
113
|
+
w.WriteHeader(204)
|
|
114
|
+
})
|
|
115
|
+
r := req("/api/health")
|
|
116
|
+
r.Header.Set("X-Forwarded-For", "127.0.0.1")
|
|
117
|
+
r.Header.Set("X-Real-IP", "127.0.0.1")
|
|
118
|
+
r.Header.Set("Forwarded", "for=127.0.0.1")
|
|
119
|
+
w := httptest.NewRecorder()
|
|
120
|
+
g.Public(w, r)
|
|
121
|
+
if w.Code != 204 {
|
|
122
|
+
t.Fatal(w.Code)
|
|
123
|
+
}
|
|
124
|
+
p := policy()
|
|
125
|
+
p.TrustedProxies = []string{"192.0.2.0/24"}
|
|
126
|
+
p.Validate()
|
|
127
|
+
r.Header.Set("X-Forwarded-For", "198.51.100.3, 192.0.2.20")
|
|
128
|
+
ip, ok := client(r, p)
|
|
129
|
+
if !ok || ip.String() != "198.51.100.3" {
|
|
130
|
+
t.Fatal(ip, ok)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
func TestPublicCapacityDoesNotConsumeOperator(t *testing.T) {
|
|
134
|
+
entered := make(chan struct{}, 1)
|
|
135
|
+
release := make(chan struct{})
|
|
136
|
+
p := policy()
|
|
137
|
+
p.MaxConcurrent = 1
|
|
138
|
+
g, n := fixture(t, p, func(w http.ResponseWriter, r *http.Request) {
|
|
139
|
+
if r.URL.Path == "/api/health" {
|
|
140
|
+
entered <- struct{}{}
|
|
141
|
+
<-release
|
|
142
|
+
}
|
|
143
|
+
w.WriteHeader(204)
|
|
144
|
+
})
|
|
145
|
+
done := make(chan struct{})
|
|
146
|
+
go func() { defer close(done); g.Public(httptest.NewRecorder(), req("/api/health")) }()
|
|
147
|
+
<-entered
|
|
148
|
+
w := httptest.NewRecorder()
|
|
149
|
+
g.Public(w, req("/api/health"))
|
|
150
|
+
if w.Code != 503 || n.Load() != 1 {
|
|
151
|
+
t.Fatal(w.Code, n.Load())
|
|
152
|
+
}
|
|
153
|
+
for _, path := range []string{"/edge/healthz", "/_/"} {
|
|
154
|
+
w = httptest.NewRecorder()
|
|
155
|
+
g.Operator(w, req(path))
|
|
156
|
+
if w.Code != 204 {
|
|
157
|
+
t.Fatal(path, w.Code)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
close(release)
|
|
161
|
+
<-done
|
|
162
|
+
}
|
|
163
|
+
func TestRateReloadAndBoundedClients(t *testing.T) {
|
|
164
|
+
p := policy()
|
|
165
|
+
p.ClientBurst = 1
|
|
166
|
+
p.MaxClients = 1
|
|
167
|
+
g, n := fixture(t, p, nil)
|
|
168
|
+
w := httptest.NewRecorder()
|
|
169
|
+
g.Public(w, req("/api/health"))
|
|
170
|
+
if w.Code != 204 {
|
|
171
|
+
t.Fatal(w.Code)
|
|
172
|
+
}
|
|
173
|
+
g.Apply(g.policy, snap(), true)
|
|
174
|
+
w = httptest.NewRecorder()
|
|
175
|
+
g.Public(w, req("/api/health"))
|
|
176
|
+
if w.Code != 429 {
|
|
177
|
+
t.Fatal(w.Code)
|
|
178
|
+
}
|
|
179
|
+
r := req("/api/health")
|
|
180
|
+
r.RemoteAddr = "198.51.100.1:1234"
|
|
181
|
+
w = httptest.NewRecorder()
|
|
182
|
+
g.Public(w, r)
|
|
183
|
+
if w.Code != 429 || n.Load() != 1 || len(g.clients) != 1 {
|
|
184
|
+
t.Fatal(w.Code, n.Load(), len(g.clients))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
func TestExpiredAndObserveActions(t *testing.T) {
|
|
188
|
+
for _, observe := range []bool{false, true} {
|
|
189
|
+
g, _ := fixture(t, policy(), nil)
|
|
190
|
+
s := snap()
|
|
191
|
+
expiry := time.Now().Add(-time.Minute)
|
|
192
|
+
if observe {
|
|
193
|
+
s.Policy.Mode = "observe"
|
|
194
|
+
expiry = time.Now().Add(time.Minute)
|
|
195
|
+
}
|
|
196
|
+
s.Actions["reads"] = security.Action{Family: "reads", ExpiresAt: expiry}
|
|
197
|
+
g.Apply(g.policy, s, true)
|
|
198
|
+
w := httptest.NewRecorder()
|
|
199
|
+
g.Public(w, req("/api/collections/notes/records"))
|
|
200
|
+
if w.Code != 204 {
|
|
201
|
+
t.Fatal(w.Code)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
func TestPolicyAndSnapshotFiles(t *testing.T) {
|
|
206
|
+
dir := t.TempDir()
|
|
207
|
+
file := filepath.Join(dir, "policy.json")
|
|
208
|
+
b, _ := json.Marshal(policy())
|
|
209
|
+
os.WriteFile(file, b, 0600)
|
|
210
|
+
if _, err := LoadPolicy(file); err != nil {
|
|
211
|
+
t.Fatal(err)
|
|
212
|
+
}
|
|
213
|
+
os.WriteFile(file, append(b, []byte(" {}")...), 0600)
|
|
214
|
+
if _, err := LoadPolicy(file); err == nil {
|
|
215
|
+
t.Fatal("accepted trailing JSON")
|
|
216
|
+
}
|
|
217
|
+
os.WriteFile(file, []byte(`{"unknown":1}`), 0600)
|
|
218
|
+
if _, err := LoadPolicy(file); err == nil {
|
|
219
|
+
t.Fatal("accepted unknown field")
|
|
220
|
+
}
|
|
221
|
+
b, _ = json.Marshal(snap())
|
|
222
|
+
os.WriteFile(file, b, 0600)
|
|
223
|
+
if _, err := LoadSnapshot(file); err != nil {
|
|
224
|
+
t.Fatal(err)
|
|
225
|
+
}
|
|
226
|
+
s := snap()
|
|
227
|
+
s.Version = 2
|
|
228
|
+
b, _ = json.Marshal(s)
|
|
229
|
+
os.WriteFile(file, b, 0600)
|
|
230
|
+
if _, err := LoadSnapshot(file); err == nil {
|
|
231
|
+
t.Fatal("accepted wrong checkpoint version")
|
|
232
|
+
}
|
|
233
|
+
p := policy()
|
|
234
|
+
p.MaxConcurrent = 1024
|
|
235
|
+
p.MaxBodyBytes = 32 << 20
|
|
236
|
+
if p.Validate() == nil {
|
|
237
|
+
t.Fatal("unbounded memory budget")
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
func TestReadinessIsNotLiveness(t *testing.T) {
|
|
241
|
+
g, _ := fixture(t, policy(), nil)
|
|
242
|
+
w := httptest.NewRecorder()
|
|
243
|
+
g.Operator(w, req("/edge/readyz"))
|
|
244
|
+
if w.Code != 503 {
|
|
245
|
+
t.Fatal(w.Code)
|
|
246
|
+
}
|
|
247
|
+
g.SetHealthy(true)
|
|
248
|
+
w = httptest.NewRecorder()
|
|
249
|
+
g.Operator(w, req("/edge/readyz"))
|
|
250
|
+
if w.Code != 204 {
|
|
251
|
+
t.Fatal(w.Code)
|
|
252
|
+
}
|
|
253
|
+
g.Apply(Policy{}, Snapshot{}, false)
|
|
254
|
+
w = httptest.NewRecorder()
|
|
255
|
+
g.Operator(w, req("/edge/readyz"))
|
|
256
|
+
if w.Code != 503 {
|
|
257
|
+
t.Fatal(w.Code)
|
|
258
|
+
}
|
|
259
|
+
w = httptest.NewRecorder()
|
|
260
|
+
g.Operator(w, req("/edge/healthz"))
|
|
261
|
+
if w.Code != 204 {
|
|
262
|
+
t.Fatal(w.Code)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func TestStaleCheckpointFailsClosed(t *testing.T) {
|
|
267
|
+
file := filepath.Join(t.TempDir(), "state.json")
|
|
268
|
+
b, _ := json.Marshal(snap())
|
|
269
|
+
if err := os.WriteFile(file, b, 0600); err != nil {
|
|
270
|
+
t.Fatal(err)
|
|
271
|
+
}
|
|
272
|
+
old := time.Now().Add(-time.Minute)
|
|
273
|
+
os.Chtimes(file, old, old)
|
|
274
|
+
if _, err := LoadSnapshot(file); err == nil {
|
|
275
|
+
t.Fatal("accepted stale checkpoint")
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func TestOperatorMarkerCannotCrossPublicListener(t *testing.T) {
|
|
280
|
+
operator := false
|
|
281
|
+
g, _ := fixture(t, policy(), func(w http.ResponseWriter, r *http.Request) {
|
|
282
|
+
want := ""
|
|
283
|
+
if operator {
|
|
284
|
+
want = "1"
|
|
285
|
+
}
|
|
286
|
+
if r.Header.Get("X-Spink-Operator") != want {
|
|
287
|
+
t.Error("wrong operator marker")
|
|
288
|
+
}
|
|
289
|
+
w.WriteHeader(204)
|
|
290
|
+
})
|
|
291
|
+
r := req("/api/health")
|
|
292
|
+
r.Header.Set("X-Spink-Operator", "1")
|
|
293
|
+
g.Public(httptest.NewRecorder(), r)
|
|
294
|
+
operator = true
|
|
295
|
+
g.Operator(httptest.NewRecorder(), req("/_/"))
|
|
296
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"openapi":"3.1.0","info":{"title":"Spink edge local diagnostics","version":"0.1.0"},"servers":[{"url":"http://127.0.0.1:8081"}],"paths":{"/edge/healthz":{"get":{"summary":"Gateway process liveness","responses":{"204":{"description":"Alive"}}}},"/edge/readyz":{"get":{"summary":"Validated policy and cached upstream health","responses":{"204":{"description":"Ready"},"503":{"description":"Not ready"}}}},"/edge/status":{"get":{"summary":"Local-only aggregate counters; no database credentials","responses":{"200":{"description":"Status","content":{"application/json":{"schema":{"type":"object","properties":{"policyReady":{"type":"boolean"},"upstreamHealthy":{"type":"boolean"},"active":{"type":"integer"},"trackedClients":{"type":"integer"},"accepted":{"type":"integer"},"rejected":{"type":"integer"}}}}}}}}}}}
|