@x47base/pocketbase-addon 0.1.0 → 0.2.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 (62) hide show
  1. package/Dockerfile +9 -1
  2. package/FEATURES.md +17 -0
  3. package/MIGRATION.md +51 -2
  4. package/NOTICE.md +2 -0
  5. package/README.md +53 -13
  6. package/SECURITY-REVIEW.md +96 -0
  7. package/VERIFY.md +34 -0
  8. package/backups/concurrency_test.go +43 -0
  9. package/backups/register.go +11 -0
  10. package/bin/launcher.test.mjs +31 -0
  11. package/bin/pocketbase-extension.mjs +17 -3
  12. package/cmd/edge/main.go +28 -0
  13. package/cmd/gateway/main.go +84 -0
  14. package/cmd/hosting/main.go +27 -0
  15. package/cmd/pocketbase/main.go +12 -0
  16. package/deploy/README.md +35 -6
  17. package/deploy/compose.yaml +5 -0
  18. package/deploy/edge.json +6 -2
  19. package/edge/gateway.go +110 -35
  20. package/edge/openapi.json +226 -1
  21. package/edge/policy.go +23 -12
  22. package/edge/telemetry.go +187 -0
  23. package/edge/telemetry_test.go +181 -0
  24. package/hosting/README.md +62 -0
  25. package/hosting/backups.go +381 -0
  26. package/hosting/backups_test.go +81 -0
  27. package/hosting/blueprint.example.json +14 -0
  28. package/hosting/blueprint.go +242 -0
  29. package/hosting/blueprint_test.go +98 -0
  30. package/hosting/config.go +126 -0
  31. package/hosting/deploy/dns.example.json +1 -0
  32. package/hosting/docs/DEPLOYMENT.md +98 -0
  33. package/hosting/hosting.example.json +1 -0
  34. package/hosting/local_target_test.go +29 -0
  35. package/hosting/scripts/dns.mjs +116 -0
  36. package/hosting/scripts/routes.mjs +39 -0
  37. package/hosting/ui/main.js +36 -0
  38. package/hosting/ui/page.css +86 -0
  39. package/hosting/ui/page.js +112 -0
  40. package/multinode/README.md +87 -0
  41. package/multinode/gateway.docker.json +1 -0
  42. package/multinode/gateway.example.json +1 -0
  43. package/multinode/gateway.go +347 -0
  44. package/multinode/gateway_test.go +217 -0
  45. package/multinode/security_regression_test.go +106 -0
  46. package/package.json +11 -6
  47. package/scripts/check-edge.py +21 -4
  48. package/scripts/check.sh +5 -2
  49. package/security/README.md +42 -9
  50. package/security/config.go +4 -0
  51. package/security/edge_telemetry.go +123 -0
  52. package/security/edge_telemetry_test.go +87 -0
  53. package/security/management.go +3 -1
  54. package/security/openapi.json +269 -0
  55. package/security/security.go +37 -13
  56. package/security/security_test.go +54 -0
  57. package/security/state.go +12 -7
  58. package/security/ui/dashboard.css +9 -2
  59. package/security/ui/dashboard.js +68 -20
  60. package/security/ui/main.js +16 -4
  61. package/security/ui/model.js +23 -1
  62. package/security/ui/model.test.mjs +12 -0
@@ -0,0 +1,347 @@
1
+ // Package multinode routes tenants to a single authoritative origin and caches explicitly public reads.
2
+ package multinode
3
+
4
+ import (
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "io"
9
+ "net"
10
+ "net/http"
11
+ "net/http/httputil"
12
+ "net/url"
13
+ "strconv"
14
+ "strings"
15
+ "sync"
16
+ "sync/atomic"
17
+ "time"
18
+ )
19
+
20
+ type Tenant struct {
21
+ Store string `json:"store,omitempty"`
22
+ Host string `json:"host"`
23
+ Origin string `json:"origin"`
24
+ PublicPaths []string `json:"publicPaths,omitempty"`
25
+ }
26
+ type Config struct {
27
+ Tenants []Tenant `json:"tenants"`
28
+ AllowHTTP bool `json:"allowHTTP"`
29
+ CacheSeconds int `json:"cacheSeconds"`
30
+ CacheEntries int `json:"cacheEntries"`
31
+ MaxConcurrent int `json:"maxConcurrent"`
32
+ MaxBodyBytes int64 `json:"maxBodyBytes"`
33
+ }
34
+ type entry struct {
35
+ status int
36
+ header http.Header
37
+ body []byte
38
+ expires time.Time
39
+ }
40
+ type route struct {
41
+ store string
42
+ proxy *httputil.ReverseProxy
43
+ public map[string]bool
44
+ }
45
+ type Gateway struct {
46
+ transport *http.Transport
47
+ routes map[string]route
48
+ config Config
49
+ slots chan struct{}
50
+ mu sync.Mutex
51
+ cache map[string]entry
52
+ pending map[string]chan struct{}
53
+ hits, misses, rejected atomic.Uint64
54
+ }
55
+
56
+ func New(c Config) (*Gateway, error) {
57
+ if c.CacheSeconds == 0 {
58
+ c.CacheSeconds = 2
59
+ }
60
+ if c.CacheEntries == 0 {
61
+ c.CacheEntries = 128
62
+ }
63
+ if c.MaxConcurrent == 0 {
64
+ c.MaxConcurrent = 128
65
+ }
66
+ if c.MaxBodyBytes == 0 {
67
+ c.MaxBodyBytes = 2 << 20
68
+ }
69
+ if len(c.Tenants) == 0 || len(c.Tenants) > 10000 || c.CacheSeconds < 1 || c.CacheSeconds > 60 || c.CacheEntries < 1 || c.CacheEntries > 4096 || c.MaxConcurrent < 1 || c.MaxConcurrent > 4096 || c.MaxBodyBytes < 1 || c.MaxBodyBytes > 64<<20 {
70
+ return nil, errors.New("invalid gateway limits")
71
+ }
72
+ g := &Gateway{config: c, routes: map[string]route{}, slots: make(chan struct{}, c.MaxConcurrent), cache: map[string]entry{}, pending: map[string]chan struct{}{}}
73
+ transport := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 256, MaxIdleConnsPerHost: 32, IdleConnTimeout: 60 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 15 * time.Second, MaxResponseHeaderBytes: 64 << 10}
74
+ g.transport = transport
75
+ for _, t := range c.Tenants {
76
+ host := strings.ToLower(t.Host)
77
+ if t.Store != "" && !validStore(t.Store) {
78
+ return nil, errors.New("invalid store binding")
79
+ }
80
+ u, err := url.Parse(t.Origin)
81
+ if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || (u.Scheme != "https" && !(c.AllowHTTP && u.Scheme == "http")) || host == "" || strings.ContainsAny(host, " /\\?#@\r\n") || len(host) > 253 {
82
+ return nil, errors.New("invalid tenant host or origin")
83
+ }
84
+ if _, exists := g.routes[host]; exists {
85
+ return nil, errors.New("duplicate tenant host")
86
+ }
87
+ public := map[string]bool{}
88
+ for _, path := range t.PublicPaths {
89
+ if !strings.HasPrefix(path, "/api/commerce/stores/") || strings.ContainsAny(path, "?#%") || strings.HasSuffix(path, "/applications") {
90
+ return nil, errors.New("only explicit public store projection paths may be cached")
91
+ }
92
+ public[path] = true
93
+ }
94
+ proxy := &httputil.ReverseProxy{Transport: transport, FlushInterval: -1, Rewrite: func(pr *httputil.ProxyRequest) {
95
+ pr.SetURL(u)
96
+ pr.Out.Host = u.Host
97
+ if t.Store != "" {
98
+ pr.Out.Host = host
99
+ }
100
+ pr.Out.Header.Del("X-Spink-Operator")
101
+ pr.Out.Header.Del("X-Real-IP")
102
+ pr.SetXForwarded()
103
+ }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
104
+ w.Header().Set("Cache-Control", "no-store")
105
+ http.Error(w, "Origin unavailable; verify the result before repeating a write", 502)
106
+ }}
107
+ g.routes[host] = route{store: t.Store, proxy: proxy, public: public}
108
+ }
109
+ return g, nil
110
+ }
111
+ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
112
+ if r.URL.Path == "/healthz" && r.Method == "GET" {
113
+ w.Header().Set("Cache-Control", "no-store")
114
+ w.Write([]byte("ok\n"))
115
+ return
116
+ }
117
+ if r.URL.Path == "/metrics" && r.Method == "GET" {
118
+ w.Header().Set("Content-Type", "application/json")
119
+ w.Header().Set("Cache-Control", "no-store")
120
+ json.NewEncoder(w).Encode(map[string]uint64{"cacheHits": g.hits.Load(), "cacheMisses": g.misses.Load(), "rejected": g.rejected.Load()})
121
+ return
122
+ }
123
+ host := strings.ToLower(r.Host)
124
+ rt, ok := g.routes[host]
125
+ if !ok {
126
+ http.Error(w, "Unknown tenant host", 421)
127
+ return
128
+ }
129
+ if rt.store != "" && !storePath(r, rt.store) {
130
+ http.Error(w, "Route unavailable on this store domain", 404)
131
+ return
132
+ }
133
+ select {
134
+ case g.slots <- struct{}{}:
135
+ defer func() { <-g.slots }()
136
+ default:
137
+ g.rejected.Add(1)
138
+ w.Header().Set("Retry-After", "1")
139
+ http.Error(w, "Gateway capacity reached", 503)
140
+ return
141
+ }
142
+ if r.ContentLength > g.config.MaxBodyBytes {
143
+ g.rejected.Add(1)
144
+ http.Error(w, "Request body too large", 413)
145
+ return
146
+ }
147
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
148
+ defer cancel()
149
+ r = r.WithContext(ctx)
150
+ deadline, _ := ctx.Deadline()
151
+ controller := http.NewResponseController(w)
152
+ _ = controller.SetReadDeadline(deadline)
153
+ _ = controller.SetWriteDeadline(deadline)
154
+ defer controller.SetReadDeadline(time.Time{})
155
+ defer controller.SetWriteDeadline(time.Time{})
156
+ r.Body = http.MaxBytesReader(w, r.Body, g.config.MaxBodyBytes)
157
+ // Credentials and cache variants always bypass the public projection cache.
158
+ cacheable := r.Method == "GET" && rt.public[r.URL.Path] && r.URL.RawQuery == "" && r.Header.Get("Authorization") == "" && r.Header.Get("Cookie") == "" && r.Header.Get("Range") == "" && r.Header.Get("Origin") == "" && r.Header.Get("Cache-Control") == "" && r.Header.Get("If-None-Match") == "" && r.Header.Get("If-Modified-Since") == "" && r.ContentLength == 0
159
+ if !cacheable {
160
+ rt.proxy.ServeHTTP(w, r)
161
+ return
162
+ }
163
+ key := host + r.URL.EscapedPath()
164
+ for {
165
+ g.mu.Lock()
166
+ if value, ok := g.cache[key]; ok && time.Now().Before(value.expires) {
167
+ g.mu.Unlock()
168
+ g.hits.Add(1)
169
+ writeEntry(w, value)
170
+ return
171
+ }
172
+ if done, ok := g.pending[key]; ok {
173
+ g.mu.Unlock()
174
+ select {
175
+ case <-done:
176
+ continue
177
+ case <-r.Context().Done():
178
+ return
179
+ }
180
+ }
181
+ done := make(chan struct{})
182
+ g.pending[key] = done
183
+ g.mu.Unlock()
184
+ func() {
185
+ defer func() { g.mu.Lock(); delete(g.pending, key); close(done); g.mu.Unlock() }()
186
+ g.misses.Add(1)
187
+ // Request a single representation; never retain compressed variants or Vary responses.
188
+ clone := r.Clone(r.Context())
189
+ clone.Header = r.Header.Clone()
190
+ clone.Header.Del("Accept-Encoding")
191
+ capture := &captureWriter{ResponseWriter: w, limit: 64 << 10}
192
+ rt.proxy.ServeHTTP(capture, clone)
193
+ if value, ok := capture.entry(time.Duration(g.config.CacheSeconds) * time.Second); ok {
194
+ g.mu.Lock()
195
+ if len(g.cache) >= g.config.CacheEntries {
196
+ for k := range g.cache {
197
+ delete(g.cache, k)
198
+ break
199
+ }
200
+ }
201
+ g.cache[key] = value
202
+ g.mu.Unlock()
203
+ }
204
+ }()
205
+ return
206
+ }
207
+ }
208
+ func writeEntry(w http.ResponseWriter, e entry) {
209
+ for k, v := range e.header {
210
+ w.Header()[k] = append([]string(nil), v...)
211
+ }
212
+ w.Header().Set("X-Public-Cache", "hit")
213
+ w.WriteHeader(e.status)
214
+ w.Write(e.body)
215
+ }
216
+
217
+ type captureWriter struct {
218
+ http.ResponseWriter
219
+ header http.Header
220
+ status int
221
+ body []byte
222
+ limit int
223
+ overflow bool
224
+ }
225
+
226
+ func (c *captureWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }
227
+ func (c *captureWriter) WriteHeader(status int) {
228
+ if status < 200 {
229
+ c.ResponseWriter.WriteHeader(status)
230
+ return
231
+ }
232
+ if c.status != 0 {
233
+ return
234
+ }
235
+ c.status = status
236
+ c.header = c.Header().Clone()
237
+ c.ResponseWriter.WriteHeader(status)
238
+ }
239
+ func (c *captureWriter) Write(p []byte) (int, error) {
240
+ if c.status == 0 {
241
+ c.WriteHeader(200)
242
+ }
243
+ if !c.overflow {
244
+ if len(c.body)+len(p) > c.limit {
245
+ c.overflow = true
246
+ c.body = nil
247
+ } else {
248
+ c.body = append(c.body, p...)
249
+ }
250
+ }
251
+ return c.ResponseWriter.Write(p)
252
+ }
253
+ func (c *captureWriter) entry(ttl time.Duration) (entry, bool) {
254
+ if c.status != 200 || c.overflow || c.header.Get("Set-Cookie") != "" || c.header.Get("Vendure-Auth-Token") != "" || !safeVary(c.header) {
255
+ return entry{}, false
256
+ }
257
+ public := false
258
+ maxAge := 0
259
+ for _, part := range strings.Split(strings.ToLower(strings.Join(c.header.Values("Cache-Control"), ",")), ",") {
260
+ part = strings.TrimSpace(part)
261
+ if part == "public" {
262
+ public = true
263
+ }
264
+ if part == "private" || part == "no-store" || part == "no-cache" {
265
+ return entry{}, false
266
+ }
267
+ if strings.HasPrefix(part, "max-age=") {
268
+ maxAge, _ = strconv.Atoi(strings.TrimPrefix(part, "max-age="))
269
+ }
270
+ }
271
+ if !public || maxAge < 1 {
272
+ return entry{}, false
273
+ }
274
+ if limit := time.Duration(maxAge) * time.Second; limit < ttl {
275
+ ttl = limit
276
+ }
277
+ // The local TTL is the only cache lifetime. Do not grant a fresh downstream TTL on a hit.
278
+ c.header.Set("Cache-Control", "no-store")
279
+ return entry{c.status, c.header, c.body, time.Now().Add(ttl)}, true
280
+ }
281
+ func Load(r io.Reader) (Config, error) {
282
+ var c Config
283
+ d := json.NewDecoder(io.LimitReader(r, 2<<20))
284
+ d.DisallowUnknownFields()
285
+ err := d.Decode(&c)
286
+ if err == nil {
287
+ var extra any
288
+ if d.Decode(&extra) != io.EOF {
289
+ err = errors.New("trailing config content")
290
+ }
291
+ }
292
+ return c, err
293
+ }
294
+
295
+ func safeVary(header http.Header) bool {
296
+ for _, value := range header.Values("Vary") {
297
+ for _, field := range strings.Split(value, ",") {
298
+ field = strings.TrimSpace(field)
299
+ if field != "" && !strings.EqualFold(field, "Origin") {
300
+ return false
301
+ }
302
+ }
303
+ }
304
+ return true
305
+ }
306
+
307
+ func validStore(s string) bool {
308
+ if len(s) < 1 || len(s) > 64 {
309
+ return false
310
+ }
311
+ for _, c := range s {
312
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
313
+ return false
314
+ }
315
+ }
316
+ return true
317
+ }
318
+ func storePath(r *http.Request, store string) bool {
319
+ path := r.URL.Path
320
+ if strings.Contains(path, "..") || strings.Contains(path, "\\") || r.URL.RawPath != "" {
321
+ return false
322
+ }
323
+ read := r.Method == "GET" || r.Method == "HEAD"
324
+ if read && (path == "/" || path == "/favicon.ico" || strings.HasPrefix(path, "/assets/")) {
325
+ return true
326
+ }
327
+ if read && path == "/api/storefront/stores" {
328
+ return true
329
+ }
330
+ if path == "/api/storefront/"+store+"/shop" {
331
+ return r.Method == "POST"
332
+ }
333
+ base := "/api/commerce/stores/" + store
334
+ if path == base || path == base+"/offers" {
335
+ return read
336
+ }
337
+ if path == base+"/account" {
338
+ return r.Method == "POST"
339
+ }
340
+ if path == base+"/applications" {
341
+ return r.Method == "GET" || r.Method == "POST"
342
+ }
343
+ return false
344
+ }
345
+
346
+ // CloseIdleConnections releases pooled origin connections after a routing reload.
347
+ func (g *Gateway) CloseIdleConnections() { g.transport.CloseIdleConnections() }
@@ -0,0 +1,217 @@
1
+ package multinode
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "sync"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+ )
14
+
15
+ func fixture(t *testing.T, h http.HandlerFunc) *Gateway {
16
+ t.Helper()
17
+ up := httptest.NewServer(h)
18
+ t.Cleanup(up.Close)
19
+ g, err := New(Config{AllowHTTP: true, Tenants: []Tenant{{Host: "shop.test", Origin: up.URL, PublicPaths: []string{"/api/commerce/stores/main"}}, {Host: "other.test", Origin: up.URL, PublicPaths: []string{"/api/commerce/stores/main"}}}})
20
+ if err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ return g
24
+ }
25
+ func request(g *Gateway, host, auth string) *httptest.ResponseRecorder {
26
+ r := httptest.NewRequest("GET", "http://"+host+"/api/commerce/stores/main", nil)
27
+ if auth != "" {
28
+ r.Header.Set("Authorization", auth)
29
+ }
30
+ w := httptest.NewRecorder()
31
+ g.ServeHTTP(w, r)
32
+ return w
33
+ }
34
+ func TestPublicCollapseTenantIsolationAndPrivateBypass(t *testing.T) {
35
+ var calls atomic.Int32
36
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) {
37
+ calls.Add(1)
38
+ time.Sleep(20 * time.Millisecond)
39
+ if r.Header.Get("Authorization") != "" {
40
+ w.Header().Set("Cache-Control", "private")
41
+ w.Write([]byte("private"))
42
+ return
43
+ }
44
+ w.Header().Set("Cache-Control", "public,max-age=2")
45
+ w.Write([]byte("public"))
46
+ })
47
+ var wg sync.WaitGroup
48
+ for i := 0; i < 20; i++ {
49
+ wg.Add(1)
50
+ go func() {
51
+ defer wg.Done()
52
+ if w := request(g, "shop.test", ""); w.Body.String() != "public" {
53
+ t.Error(w.Body.String())
54
+ }
55
+ }()
56
+ }
57
+ wg.Wait()
58
+ if calls.Load() != 1 {
59
+ t.Fatal("no collapse", calls.Load())
60
+ }
61
+ request(g, "other.test", "")
62
+ if calls.Load() != 2 {
63
+ t.Fatal("tenant cache collision")
64
+ }
65
+ for i := 0; i < 2; i++ {
66
+ if w := request(g, "shop.test", "Bearer private"); w.Body.String() != "private" {
67
+ t.Fatal("private cache leak")
68
+ }
69
+ }
70
+ if calls.Load() != 4 {
71
+ t.Fatal("private response cached")
72
+ }
73
+ if w := request(g, "unknown.test", ""); w.Code != 421 {
74
+ t.Fatal(w.Code)
75
+ }
76
+ }
77
+ func TestNoMutationRetryAndEarlyBodyRejection(t *testing.T) {
78
+ var calls atomic.Int32
79
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(503) })
80
+ for i := 0; i < 2; i++ {
81
+ r := httptest.NewRequest("POST", "http://shop.test/api/orders", strings.NewReader("{}"))
82
+ w := httptest.NewRecorder()
83
+ g.ServeHTTP(w, r)
84
+ if w.Code != 503 {
85
+ t.Fatal(w.Code)
86
+ }
87
+ }
88
+ if calls.Load() != 2 {
89
+ t.Fatal("mutation retried")
90
+ }
91
+ r := httptest.NewRequest("POST", "http://shop.test/api/orders", strings.NewReader("x"))
92
+ r.ContentLength = 1 << 30
93
+ w := httptest.NewRecorder()
94
+ g.ServeHTTP(w, r)
95
+ if w.Code != 413 || calls.Load() != 2 {
96
+ t.Fatal("oversized body reached origin")
97
+ }
98
+ }
99
+ func TestCacheExpiryAndUnsafeResponseExclusion(t *testing.T) {
100
+ for _, header := range []string{"private", "no-store", "public, max-age=0", "public, max-age=1"} {
101
+ t.Run(header, func(t *testing.T) {
102
+ var calls atomic.Int32
103
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) {
104
+ calls.Add(1)
105
+ w.Header().Set("Cache-Control", header)
106
+ if header == "public, max-age=1" {
107
+ w.Header().Set("Set-Cookie", "session=secret")
108
+ }
109
+ w.Write([]byte("data"))
110
+ })
111
+ request(g, "shop.test", "")
112
+ request(g, "shop.test", "")
113
+ if calls.Load() != 2 {
114
+ t.Fatal("unsafe response cached")
115
+ }
116
+ })
117
+ }
118
+ var calls atomic.Int32
119
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) {
120
+ calls.Add(1)
121
+ w.Header().Set("Cache-Control", "public,max-age=1")
122
+ fmt.Fprint(w, "ok")
123
+ })
124
+ request(g, "shop.test", "")
125
+ g.mu.Lock()
126
+ for k, v := range g.cache {
127
+ v.expires = time.Now().Add(-time.Second)
128
+ g.cache[k] = v
129
+ }
130
+ g.mu.Unlock()
131
+ request(g, "shop.test", "")
132
+ if calls.Load() != 2 {
133
+ t.Fatal("expired cache used")
134
+ }
135
+ }
136
+ func TestChunkedLimitAndForwardedHeaders(t *testing.T) {
137
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) {
138
+ if strings.Contains(r.Header.Get("X-Forwarded-For"), "attacker") {
139
+ t.Error("trusted spoofed forwarding")
140
+ }
141
+ _, err := io.ReadAll(r.Body)
142
+ if err != nil {
143
+ w.WriteHeader(400)
144
+ return
145
+ }
146
+ w.WriteHeader(200)
147
+ })
148
+ g.config.MaxBodyBytes = 4
149
+ r := httptest.NewRequest("POST", "http://shop.test/api/orders", strings.NewReader("12345678"))
150
+ r.ContentLength = -1
151
+ r.Header.Set("X-Forwarded-For", "attacker")
152
+ w := httptest.NewRecorder()
153
+ g.ServeHTTP(w, r)
154
+ if w.Code == 200 {
155
+ t.Fatal("unbounded chunked body")
156
+ }
157
+ }
158
+
159
+ func TestPocketBaseOriginVaryAndMultipleCacheDirectives(t *testing.T) {
160
+ var calls atomic.Int32
161
+ g := fixture(t, func(w http.ResponseWriter, r *http.Request) {
162
+ calls.Add(1)
163
+ w.Header().Set("Vary", "Origin")
164
+ w.Header().Set("Cache-Control", "public,max-age=2")
165
+ w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
166
+ w.Write([]byte("store"))
167
+ })
168
+ request(g, "shop.test", "")
169
+ request(g, "shop.test", "")
170
+ if calls.Load() != 1 {
171
+ t.Fatal("PocketBase Vary Origin prevented public cache")
172
+ }
173
+ r := httptest.NewRequest("GET", "http://shop.test/api/commerce/stores/main", nil)
174
+ r.Header.Set("Origin", "https://customer.test")
175
+ w := httptest.NewRecorder()
176
+ g.ServeHTTP(w, r)
177
+ if calls.Load() != 2 || w.Header().Get("X-Public-Cache") != "" {
178
+ t.Fatal("CORS response reused")
179
+ }
180
+ capture := &captureWriter{status: 200, header: http.Header{"Cache-Control": []string{"public,max-age=2", "no-store"}}}
181
+ if _, ok := capture.entry(time.Second); ok {
182
+ t.Fatal("ignored second cache directive")
183
+ }
184
+ if safeVary(http.Header{"Vary": []string{"Origin", "Authorization"}}) {
185
+ t.Fatal("ignored second Vary header")
186
+ }
187
+ }
188
+
189
+ func TestStoreBoundHostRejectsOtherStoresAndAdmin(t *testing.T) {
190
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191
+ if r.Host != "team.example.com" {
192
+ t.Errorf("lost domain host: %s", r.Host)
193
+ }
194
+ w.WriteHeader(200)
195
+ }))
196
+ defer origin.Close()
197
+ g, err := New(Config{AllowHTTP: true, Tenants: []Tenant{{Host: "team.example.com", Store: "team-one", Origin: origin.URL}}})
198
+ if err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ for _, path := range []string{"/_/", "/commerce/", "/api/collections/commerce_stores/records", "/api/storefront/team-two/shop", "/api/commerce/stores/team-two/account"} {
202
+ r := httptest.NewRequest("GET", "https://team.example.com"+path, nil)
203
+ w := httptest.NewRecorder()
204
+ g.ServeHTTP(w, r)
205
+ if w.Code != 404 {
206
+ t.Fatal(path, w.Code)
207
+ }
208
+ }
209
+ for _, path := range []string{"/", "/api/storefront/stores", "/api/commerce/stores/team-one"} {
210
+ r := httptest.NewRequest("GET", "https://team.example.com"+path, nil)
211
+ w := httptest.NewRecorder()
212
+ g.ServeHTTP(w, r)
213
+ if w.Code != 200 {
214
+ t.Fatal(path, w.Code)
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,106 @@
1
+ package multinode
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "net"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func TestGatewayStripsPrivateOperatorIdentity(t *testing.T) {
14
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15
+ if r.Header.Get("X-Spink-Operator") != "" || r.Header.Get("X-Real-IP") != "" {
16
+ t.Error("private identity crossed public gateway")
17
+ }
18
+ if r.Header.Get("X-Forwarded-For") != "192.0.2.10" {
19
+ t.Error("connection identity lost")
20
+ }
21
+ }))
22
+ defer origin.Close()
23
+ g, err := New(Config{AllowHTTP: true, Tenants: []Tenant{{Host: "tenant.example", Origin: origin.URL}}})
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ defer g.CloseIdleConnections()
28
+ req := httptest.NewRequest("GET", "http://tenant.example/api/health", nil)
29
+ req.RemoteAddr = "192.0.2.10:1234"
30
+ req.Header.Set("X-Spink-Operator", "1")
31
+ req.Header.Set("X-Real-IP", "127.0.0.1")
32
+ req.Header.Set("X-Forwarded-For", "127.0.0.1")
33
+ g.ServeHTTP(httptest.NewRecorder(), req)
34
+ }
35
+
36
+ type deadlineWriter struct {
37
+ *httptest.ResponseRecorder
38
+ reads, writes []time.Time
39
+ }
40
+
41
+ func (w *deadlineWriter) SetReadDeadline(d time.Time) error { w.reads = append(w.reads, d); return nil }
42
+ func (w *deadlineWriter) SetWriteDeadline(d time.Time) error {
43
+ w.writes = append(w.writes, d)
44
+ return nil
45
+ }
46
+ func TestGatewaySetsSocketDeadlinesForAdmittedRequests(t *testing.T) {
47
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }))
48
+ defer origin.Close()
49
+ g, err := New(Config{AllowHTTP: true, Tenants: []Tenant{{Host: "tenant.example", Origin: origin.URL}}})
50
+ if err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ defer g.CloseIdleConnections()
54
+ deadline := time.Now().Add(time.Second)
55
+ ctx, cancel := context.WithDeadline(context.Background(), deadline)
56
+ defer cancel()
57
+ req := httptest.NewRequest("GET", "http://tenant.example/", nil).WithContext(ctx)
58
+ w := &deadlineWriter{ResponseRecorder: httptest.NewRecorder()}
59
+ g.ServeHTTP(w, req)
60
+ for _, values := range [][]time.Time{w.reads, w.writes} {
61
+ if len(values) != 2 || !values[0].Equal(deadline) || !values[1].IsZero() {
62
+ t.Fatalf("socket deadline not set/reset: %v", values)
63
+ }
64
+ }
65
+ }
66
+
67
+ func TestSlowReaderReleasesAdmissionSlot(t *testing.T) {
68
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69
+ block := make([]byte, 64<<10)
70
+ for i := 0; i < 2048; i++ {
71
+ if _, err := w.Write(block); err != nil {
72
+ return
73
+ }
74
+ }
75
+ }))
76
+ defer origin.Close()
77
+ g, err := New(Config{AllowHTTP: true, MaxConcurrent: 1, Tenants: []Tenant{{Host: "tenant.example", Origin: origin.URL}}})
78
+ if err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ defer g.CloseIdleConnections()
82
+ done := make(chan struct{})
83
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84
+ defer close(done)
85
+ ctx, cancel := context.WithTimeout(r.Context(), 150*time.Millisecond)
86
+ defer cancel()
87
+ g.ServeHTTP(w, r.WithContext(ctx))
88
+ }))
89
+ defer server.Close()
90
+ conn, err := net.Dial("tcp", server.Listener.Addr().String())
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ defer conn.Close()
95
+ if _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: tenant.example\r\n\r\n"); err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ select {
99
+ case <-done:
100
+ if len(g.slots) != 0 {
101
+ t.Fatal("admission slot leaked")
102
+ }
103
+ case <-time.After(2 * time.Second):
104
+ t.Fatal("non-reading client held admission past request deadline")
105
+ }
106
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@x47base/pocketbase-addon",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "test": "node --test security/ui/*.test.mjs",
7
- "check": "./scripts/check.sh"
6
+ "test": "node --test security/ui/*.test.mjs bin/*.test.mjs",
7
+ "check": "sh scripts/check.sh"
8
8
  },
9
9
  "files": [
10
10
  "*.go",
@@ -35,12 +35,17 @@
35
35
  "edge",
36
36
  "Dockerfile",
37
37
  ".dockerignore",
38
- "deploy"
38
+ "deploy",
39
+ "hosting",
40
+ "multinode",
41
+ "SECURITY-REVIEW.md",
42
+ "VERIFY.md"
39
43
  ],
40
44
  "license": "MIT",
41
- "description": "PocketBase add-on: security dashboard, encrypted backups and development extensions",
45
+ "description": "PocketBase extensions with security controls, encrypted backups, hosting policies and single-owner multinode routing",
42
46
  "bin": {
43
- "pocketbase-extension": "bin/pocketbase-extension.mjs"
47
+ "pocketbase-extension": "bin/pocketbase-extension.mjs",
48
+ "pocketbase-addon": "bin/pocketbase-extension.mjs"
44
49
  },
45
50
  "engines": {
46
51
  "node": ">=22"