@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.
- package/Dockerfile +9 -1
- package/FEATURES.md +17 -0
- package/MIGRATION.md +51 -2
- package/NOTICE.md +2 -0
- package/README.md +53 -13
- package/SECURITY-REVIEW.md +96 -0
- package/VERIFY.md +34 -0
- package/backups/concurrency_test.go +43 -0
- package/backups/register.go +11 -0
- package/bin/launcher.test.mjs +31 -0
- package/bin/pocketbase-extension.mjs +17 -3
- package/cmd/edge/main.go +28 -0
- package/cmd/gateway/main.go +84 -0
- package/cmd/hosting/main.go +27 -0
- package/cmd/pocketbase/main.go +12 -0
- package/deploy/README.md +35 -6
- package/deploy/compose.yaml +5 -0
- package/deploy/edge.json +6 -2
- package/edge/gateway.go +110 -35
- package/edge/openapi.json +226 -1
- package/edge/policy.go +23 -12
- package/edge/telemetry.go +187 -0
- package/edge/telemetry_test.go +181 -0
- package/hosting/README.md +62 -0
- package/hosting/backups.go +381 -0
- package/hosting/backups_test.go +81 -0
- package/hosting/blueprint.example.json +14 -0
- package/hosting/blueprint.go +242 -0
- package/hosting/blueprint_test.go +98 -0
- package/hosting/config.go +126 -0
- package/hosting/deploy/dns.example.json +1 -0
- package/hosting/docs/DEPLOYMENT.md +98 -0
- package/hosting/hosting.example.json +1 -0
- package/hosting/local_target_test.go +29 -0
- package/hosting/scripts/dns.mjs +116 -0
- package/hosting/scripts/routes.mjs +39 -0
- package/hosting/ui/main.js +36 -0
- package/hosting/ui/page.css +86 -0
- package/hosting/ui/page.js +112 -0
- package/multinode/README.md +87 -0
- package/multinode/gateway.docker.json +1 -0
- package/multinode/gateway.example.json +1 -0
- package/multinode/gateway.go +347 -0
- package/multinode/gateway_test.go +217 -0
- package/multinode/security_regression_test.go +106 -0
- package/package.json +11 -6
- package/scripts/check-edge.py +21 -4
- package/scripts/check.sh +5 -2
- package/security/README.md +42 -9
- package/security/config.go +4 -0
- package/security/edge_telemetry.go +123 -0
- package/security/edge_telemetry_test.go +87 -0
- package/security/management.go +3 -1
- package/security/openapi.json +269 -0
- package/security/security.go +37 -13
- package/security/security_test.go +54 -0
- package/security/state.go +12 -7
- package/security/ui/dashboard.css +9 -2
- package/security/ui/dashboard.js +68 -20
- package/security/ui/main.js +16 -4
- package/security/ui/model.js +23 -1
- 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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"test": "node --test security/ui/*.test.mjs",
|
|
7
|
-
"check": "
|
|
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
|
|
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"
|