added superuser ips whitelist
This commit is contained in:
+7
-2
@@ -70,8 +70,10 @@ func backupDownload(e *core.RequestEvent) error {
|
|||||||
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
|
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
allowedIPs := e.App.Settings().SuperuserIPs
|
||||||
defer cancel()
|
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||||
|
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
|
||||||
|
}
|
||||||
|
|
||||||
fsys, err := e.App.NewBackupsFilesystem()
|
fsys, err := e.App.NewBackupsFilesystem()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +81,9 @@ func backupDownload(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
defer fsys.Close()
|
defer fsys.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
fsys.SetContext(ctx)
|
fsys.SetContext(ctx)
|
||||||
|
|
||||||
key := e.Request.PathValue("key")
|
key := e.Request.PathValue("key")
|
||||||
|
|||||||
@@ -528,6 +528,58 @@ func TestBackupsDownload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{"*": 0},
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "with valid superuser file token AND whitelisted IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||||
|
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
if err := createTestBackups(app); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||||
|
Headers: []string{"x-test-ip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
|
||||||
|
|
||||||
|
if err := app.Save(app.Settings()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
"storage/",
|
||||||
|
"data.db",
|
||||||
|
"auxiliary.db",
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with valid superuser file token BUT non-whitelisted IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||||
|
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
if err := createTestBackups(app); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||||
|
Headers: []string{"x-test-ip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||||
|
|
||||||
|
if err := app.Save(app.Settings()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
|||||||
pbRouter.Bind(panicRecover())
|
pbRouter.Bind(panicRecover())
|
||||||
pbRouter.Bind(rateLimit())
|
pbRouter.Bind(rateLimit())
|
||||||
pbRouter.Bind(loadAuthToken())
|
pbRouter.Bind(loadAuthToken())
|
||||||
|
pbRouter.Bind(superuserIPsWhitelist())
|
||||||
pbRouter.Bind(securityHeaders())
|
pbRouter.Bind(securityHeaders())
|
||||||
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
||||||
|
|
||||||
|
|||||||
+65
-1
@@ -224,7 +224,7 @@ func TestBatchRequest(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "mixed create/update/delete (rules failure)",
|
Name: "mixed create/update/delete (non-superuser rule failure)",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/batch",
|
URL: "/api/batch",
|
||||||
Body: strings.NewReader(`{
|
Body: strings.NewReader(`{
|
||||||
@@ -284,6 +284,70 @@ func TestBatchRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mixed create/update/delete (superuser rule failure)",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/batch",
|
||||||
|
Headers: map[string]string{
|
||||||
|
// test@example.com, clients
|
||||||
|
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||||
|
},
|
||||||
|
Body: strings.NewReader(`{
|
||||||
|
"requests": [
|
||||||
|
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
|
||||||
|
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}},
|
||||||
|
{"method":"POST", "url":"/api/collections/_superusers/records", "body": {"email":"test_batch@example.com","password":"1234567890"}}
|
||||||
|
]
|
||||||
|
}`),
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{
|
||||||
|
`"data":{`,
|
||||||
|
`"requests":{`,
|
||||||
|
`"2":{"code":"batch_request_failed"`,
|
||||||
|
`403`,
|
||||||
|
},
|
||||||
|
NotExpectedContent: []string{
|
||||||
|
`"0":`,
|
||||||
|
`"1":`,
|
||||||
|
},
|
||||||
|
ExpectedEvents: map[string]int{
|
||||||
|
"*": 0,
|
||||||
|
"OnBatchRequest": 1,
|
||||||
|
"OnModelUpdate": 1,
|
||||||
|
"OnModelUpdateExecute": 1,
|
||||||
|
"OnModelAfterUpdateError": 1,
|
||||||
|
"OnModelDelete": 1,
|
||||||
|
"OnModelDeleteExecute": 1,
|
||||||
|
"OnModelAfterDeleteError": 1,
|
||||||
|
"OnModelValidate": 1,
|
||||||
|
"OnRecordUpdateRequest": 1,
|
||||||
|
"OnRecordUpdate": 1,
|
||||||
|
"OnRecordUpdateExecute": 1,
|
||||||
|
"OnRecordAfterUpdateError": 1,
|
||||||
|
"OnRecordDeleteRequest": 1,
|
||||||
|
"OnRecordDelete": 1,
|
||||||
|
"OnRecordDeleteExecute": 1,
|
||||||
|
"OnRecordAfterDeleteError": 1,
|
||||||
|
"OnRecordEnrich": 1,
|
||||||
|
"OnRecordValidate": 1,
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||||
|
_, err = app.FindRecordById("demo2", "achvryl401bhse3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Expected record to not be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected record to not be updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test_batch@example.com")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected superuser to not be created")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "mixed create/update/delete (rules success)",
|
Name: "mixed create/update/delete (rules success)",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type fileApi struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
||||||
|
// extra check for just in case the handler is called in a different context
|
||||||
if e.Auth == nil {
|
if e.Auth == nil {
|
||||||
return e.UnauthorizedError("Missing auth context.", nil)
|
return e.UnauthorizedError("Missing auth context.", nil)
|
||||||
}
|
}
|
||||||
@@ -114,6 +115,15 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
|||||||
token := e.Request.URL.Query().Get("token")
|
token := e.Request.URL.Query().Get("token")
|
||||||
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
|
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
|
||||||
|
|
||||||
|
// reset the auth state if it is superuser and it is not whitelisted
|
||||||
|
// (not critical because file tokens are short-lived but checked nonetheless as an extra precaution)
|
||||||
|
if authRecord != nil && authRecord.IsSuperuser() {
|
||||||
|
allowedIPs := e.App.Settings().SuperuserIPs
|
||||||
|
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||||
|
authRecord = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
|
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
|
||||||
requestInfo := *originalRequestInfo
|
requestInfo := *originalRequestInfo
|
||||||
requestInfo.Context = core.RequestInfoContextProtectedFile
|
requestInfo.Context = core.RequestInfoContextProtectedFile
|
||||||
|
|||||||
@@ -353,6 +353,50 @@ func TestFileDownload(t *testing.T) {
|
|||||||
"OnFileDownloadRequest": 1,
|
"OnFileDownloadRequest": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "protected file - superuser with non-whitelisted IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||||
|
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||||
|
Headers: []string{"x-test-ip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||||
|
|
||||||
|
err := app.Save(app.Settings())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "protected file - superuser with whitelisted IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||||
|
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||||
|
Headers: []string{"x-test-ip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
|
||||||
|
|
||||||
|
if err := app.Save(app.Settings()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"PNG"},
|
||||||
|
ExpectedEvents: map[string]int{
|
||||||
|
"*": 0,
|
||||||
|
"OnFileDownloadRequest": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "protected file - guest without view access",
|
Name: "protected file - guest without view access",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const (
|
|||||||
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
|
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
|
||||||
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
|
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
|
||||||
|
|
||||||
|
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
|
||||||
|
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
|
||||||
|
|
||||||
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
|
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
|
||||||
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
|
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
|
||||||
|
|
||||||
@@ -299,6 +302,28 @@ func securityHeaders() *hook.Handler[*core.RequestEvent] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// superuserIPsWhitelist middleware checks the current authenticated superuser IP
|
||||||
|
// against the configured SuperuserIPs whitelist setting.
|
||||||
|
//
|
||||||
|
// This middleware is registered by default for all routes.
|
||||||
|
func superuserIPsWhitelist() *hook.Handler[*core.RequestEvent] {
|
||||||
|
return &hook.Handler[*core.RequestEvent]{
|
||||||
|
Id: DefaultSuperuserIPsWhitelistMiddlewareId,
|
||||||
|
Priority: DefaultSuperuserIPsWhitelistMiddlewarePriority,
|
||||||
|
Func: func(e *core.RequestEvent) error {
|
||||||
|
if e.HasSuperuserAuth() {
|
||||||
|
ips := e.App.Settings().SuperuserIPs
|
||||||
|
|
||||||
|
if len(ips) > 0 && !isIPInList(ips, e.RealIP()) {
|
||||||
|
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SkipSuccessActivityLog is a helper middleware that instructs the global
|
// SkipSuccessActivityLog is a helper middleware that instructs the global
|
||||||
// activity logger to log only requests that have failed/returned an error.
|
// activity logger to log only requests that have failed/returned an error.
|
||||||
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
|
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package apis
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -106,6 +107,41 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIPInList checks if the specified IP is in a list of other individual IPs or subnets.
|
||||||
|
func isIPInList(ipsOrSubnets []string, ip string) bool {
|
||||||
|
if ip == "" || len(ipsOrSubnets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize
|
||||||
|
searchAddr, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range ipsOrSubnets {
|
||||||
|
// subnet?
|
||||||
|
prefix, err := netip.ParsePrefix(item)
|
||||||
|
if err == nil {
|
||||||
|
if prefix.Contains(searchAddr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// individual ip?
|
||||||
|
addr, err := netip.ParseAddr(item)
|
||||||
|
if err == nil {
|
||||||
|
if addr == searchAddr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
// @todo consider exporting as helper?
|
// @todo consider exporting as helper?
|
||||||
|
|||||||
@@ -553,3 +553,96 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
|
|||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSuperuserIPsWhitelist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
setupWhitelist := func(superuserIPs ...string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||||
|
// allow loading a mock IP from the test scenario
|
||||||
|
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||||
|
Headers: []string{"x-test-ip"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().SuperuserIPs = superuserIPs
|
||||||
|
|
||||||
|
err := app.Save(app.Settings())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||||
|
return e.String(200, "test123")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []tests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "guest with non-matching IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/my/test",
|
||||||
|
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||||
|
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test123"},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "regular user with non-matching IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/my/test",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"x-test-ip": "127.0.0.1",
|
||||||
|
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||||
|
},
|
||||||
|
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test123"},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "superuser with non-matching IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/my/test",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"x-test-ip": "127.0.0.1",
|
||||||
|
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||||
|
},
|
||||||
|
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{`"data":{}`},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "superuser with matching IP",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/my/test",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"x-test-ip": "127.0.0.1",
|
||||||
|
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||||
|
},
|
||||||
|
BeforeTestFunc: setupWhitelist("0.0.0.0", "127.0.0.1"),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test123"},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "superuser with no whitelisted IPs",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/my/test",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"x-test-ip": "127.0.0.1",
|
||||||
|
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||||
|
},
|
||||||
|
BeforeTestFunc: setupWhitelist(),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test123"},
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMetho
|
|||||||
}
|
}
|
||||||
|
|
||||||
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error {
|
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error {
|
||||||
|
if authRecord.IsSuperuser() {
|
||||||
|
allowedIPs := e.App.Settings().SuperuserIPs
|
||||||
|
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||||
|
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
originalRequestInfo, err := e.RequestInfo()
|
originalRequestInfo, err := e.RequestInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -759,3 +759,39 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordAuthResponseSuperuserIPsWhitelistCheck(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||||
|
|
||||||
|
event := new(core.RequestEvent)
|
||||||
|
event.App = app
|
||||||
|
event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
event.Request.Header.Set("x-test-ip", "127.0.0.1")
|
||||||
|
event.Response = httptest.NewRecorder()
|
||||||
|
|
||||||
|
t.Run("non-whitelisted", func(t *testing.T) {
|
||||||
|
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||||
|
|
||||||
|
err = apis.RecordAuthResponse(event, superuser, "example", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected response error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("whitelisted", func(t *testing.T) {
|
||||||
|
app.Settings().SuperuserIPs = []string{"0.0.0.0", "127.0.0.1"}
|
||||||
|
|
||||||
|
err = apis.RecordAuthResponse(event, superuser, "example", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func NewSuperuserCommand(app core.App) *cobra.Command {
|
|||||||
command.AddCommand(superuserUpdateCommand(app))
|
command.AddCommand(superuserUpdateCommand(app))
|
||||||
command.AddCommand(superuserDeleteCommand(app))
|
command.AddCommand(superuserDeleteCommand(app))
|
||||||
command.AddCommand(superuserOTPCommand(app))
|
command.AddCommand(superuserOTPCommand(app))
|
||||||
|
command.AddCommand(superuserIPsCommand(app))
|
||||||
|
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
@@ -209,3 +210,38 @@ func superuserOTPCommand(app core.App) *cobra.Command {
|
|||||||
|
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func superuserIPsCommand(app core.App) *cobra.Command {
|
||||||
|
command := &cobra.Command{
|
||||||
|
Use: "ips",
|
||||||
|
Example: "superuser ips 127.0.0.1 10.0.0.0/24",
|
||||||
|
Short: "Updates the superuser IPs whitelist setting (the IPs/subnets arguments must be space separated; leave empty to clear the whitelist restriction)",
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
|
settings := app.Settings()
|
||||||
|
settings.SuperuserIPs = args
|
||||||
|
|
||||||
|
if err := app.Save(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
color.Green("Successfully cleared SuperuserIPs setting!")
|
||||||
|
} else {
|
||||||
|
color.New(color.BgGreen, color.FgBlack).Println("Successfully updated SuperuserIPs setting:")
|
||||||
|
superuserIPs := app.Settings().SuperuserIPs
|
||||||
|
for i, ip := range superuserIPs {
|
||||||
|
if i == len(superuserIPs)-1 {
|
||||||
|
color.Green("└─ %s", ip)
|
||||||
|
} else {
|
||||||
|
color.Green("├─ %s", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd_test
|
package cmd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/cmd"
|
"github.com/pocketbase/pocketbase/cmd"
|
||||||
@@ -401,3 +402,63 @@ func TestSuperuserOTPCommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSuperuserIPsCommand(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
ips []string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"no ips",
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invalid ips",
|
||||||
|
[]string{"127.0.0.1", "invalid"},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"valid ips",
|
||||||
|
[]string{"127.0.0.1", "::1", "127.0.0.1/24"},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
t.Run(s.name, func(t *testing.T) {
|
||||||
|
args := []string{"ips"}
|
||||||
|
args = append(args, s.ips...)
|
||||||
|
|
||||||
|
command := cmd.NewSuperuserCommand(app)
|
||||||
|
command.SetArgs(args)
|
||||||
|
|
||||||
|
err := command.Execute()
|
||||||
|
|
||||||
|
hasErr := err != nil
|
||||||
|
if s.expectError != hasErr {
|
||||||
|
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingIPs := app.Settings().SuperuserIPs
|
||||||
|
|
||||||
|
if len(settingIPs) != len(s.ips) {
|
||||||
|
t.Fatalf("Expected %d ips, got %d (%v)", len(s.ips), len(settingIPs), settingIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range settingIPs {
|
||||||
|
if !slices.Contains(s.ips, ip) {
|
||||||
|
t.Fatalf("Missing expected ip %q (%v)", ip, settingIPs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -38,8 +38,9 @@ const (
|
|||||||
|
|
||||||
LocalStorageDirName string = "storage"
|
LocalStorageDirName string = "storage"
|
||||||
LocalBackupsDirName string = "backups"
|
LocalBackupsDirName string = "backups"
|
||||||
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
|
||||||
LocalAutocertCacheDirName string = ".autocert_cache"
|
LocalAutocertCacheDirName string = ".autocert_cache"
|
||||||
|
LocalNotifyDirName string = ".notify" // optional watched directory that is used as a cross-platform workaround for synchronizing various runtime states between multiple PocketBase instances pointing to the same pb_data
|
||||||
|
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
||||||
|
|
||||||
// @todo consider removing after backups refactoring
|
// @todo consider removing after backups refactoring
|
||||||
lostFoundDirName string = "lost+found"
|
lostFoundDirName string = "lost+found"
|
||||||
@@ -1382,6 +1383,7 @@ func (app *BaseApp) registerBaseHooks() {
|
|||||||
app.registerMFAHooks()
|
app.registerMFAHooks()
|
||||||
app.registerOTPHooks()
|
app.registerOTPHooks()
|
||||||
app.registerAuthOriginHooks()
|
app.registerAuthOriginHooks()
|
||||||
|
app.registerNotifyWatcherHooks()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLoggerMinLevel returns the logger min level based on the
|
// getLoggerMinLevel returns the logger min level based on the
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemHookIdNotifyWatcher = "__pbNotifyWatcherSystemHook__"
|
||||||
|
|
||||||
|
func (app *BaseApp) registerNotifyWatcherHooks() {
|
||||||
|
var notifyWatcher *fsnotify.Watcher
|
||||||
|
|
||||||
|
instanceId := "@" + security.PseudorandomString(10)
|
||||||
|
|
||||||
|
localNotifyDirPath := filepath.Join(app.DataDir(), LocalNotifyDirName)
|
||||||
|
settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
|
||||||
|
collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
|
||||||
|
|
||||||
|
// init
|
||||||
|
app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: func(e *BootstrapEvent) error {
|
||||||
|
err := e.Next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifyWatcher != nil {
|
||||||
|
_ = notifyWatcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
|
||||||
|
if err != nil {
|
||||||
|
e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Priority: -998,
|
||||||
|
})
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: func(e *TerminateEvent) error {
|
||||||
|
if notifyWatcher != nil {
|
||||||
|
_ = notifyWatcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(settingsFile)
|
||||||
|
_ = os.Remove(collectionsFile)
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
},
|
||||||
|
Priority: -998,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
settingsNotify := func(e *ModelEvent) error {
|
||||||
|
err := e.Next()
|
||||||
|
if err != nil || e.Model.PK() != paramsKeySettings {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifyWatcher != nil {
|
||||||
|
if err := os.WriteFile(settingsFile, nil, 0644); err != nil {
|
||||||
|
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
|
||||||
|
}
|
||||||
|
_ = os.Remove(settingsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: settingsNotify,
|
||||||
|
Priority: 999,
|
||||||
|
})
|
||||||
|
app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: settingsNotify,
|
||||||
|
Priority: 999,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
collectionsNotify := func(e *CollectionEvent) error {
|
||||||
|
if err := e.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifyWatcher != nil {
|
||||||
|
if err := os.WriteFile(collectionsFile, nil, 0644); err != nil {
|
||||||
|
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
|
||||||
|
}
|
||||||
|
_ = os.Remove(collectionsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*CollectionEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: collectionsNotify,
|
||||||
|
Priority: 999,
|
||||||
|
})
|
||||||
|
app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*CollectionEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: collectionsNotify,
|
||||||
|
Priority: 999,
|
||||||
|
})
|
||||||
|
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*CollectionEvent]{
|
||||||
|
Id: systemHookIdNotifyWatcher,
|
||||||
|
Func: collectionsNotify,
|
||||||
|
Priority: 999,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNotifyDirWatcher(app App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
|
||||||
|
// create the notify dir (if not already)
|
||||||
|
err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create a notify dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = watcher.Add(localNotifyDirPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = watcher.Close()
|
||||||
|
return nil, fmt.Errorf("unable to watch notify dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var debounceTimer *time.Timer
|
||||||
|
|
||||||
|
stopDebounceTimer := func() {
|
||||||
|
if debounceTimer != nil {
|
||||||
|
debounceTimer.Stop()
|
||||||
|
debounceTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch
|
||||||
|
go func() {
|
||||||
|
defer stopDebounceTimer()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// modified from within the current app instance or cleanup event
|
||||||
|
if strings.HasSuffix(event.Name, instanceId) || event.Has(fsnotify.Remove) || !app.IsBootstrapped() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDebounceTimer()
|
||||||
|
|
||||||
|
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
||||||
|
filename := filepath.Base(event.Name)
|
||||||
|
|
||||||
|
// settings changed
|
||||||
|
if strings.HasPrefix(filename, "settings@") {
|
||||||
|
app.Logger().Debug("Reloading settings after notify event")
|
||||||
|
|
||||||
|
err := app.ReloadSettings()
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Warn("Failed to reload app settings after notify", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// collections changed
|
||||||
|
if strings.HasPrefix(filename, "collections@") {
|
||||||
|
app.Logger().Debug("Reloading cached collections after notify event")
|
||||||
|
|
||||||
|
err := app.ReloadCachedCollections()
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Warn("Failed to reload cached collections after notify", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if app.IsDev() && err != nil {
|
||||||
|
color.Red("Notify dir watch error:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return watcher, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
|
"golang.org/x/sync/semaphore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotifyWatcher_SettingsUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testEvents := store.New[core.App, int](nil)
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
app1 := core.NewBaseApp(core.BaseAppConfig{
|
||||||
|
DataDir: tmpDir,
|
||||||
|
})
|
||||||
|
if err := app1.Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app2 := core.NewBaseApp(core.BaseAppConfig{
|
||||||
|
DataDir: tmpDir,
|
||||||
|
})
|
||||||
|
if err := app2.Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancelCtx()
|
||||||
|
|
||||||
|
sem := semaphore.NewWeighted(1)
|
||||||
|
sem.Acquire(ctx, 1)
|
||||||
|
|
||||||
|
app1.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
|
||||||
|
testEvents.SetFunc(app1, func(old int) int {
|
||||||
|
return old + 1
|
||||||
|
})
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app2.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
|
||||||
|
testEvents.SetFunc(app2, func(old int) int {
|
||||||
|
sem.Release(1)
|
||||||
|
|
||||||
|
return old + 1
|
||||||
|
})
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// updating app1 settings should trigger a reload in app2
|
||||||
|
app1.Settings().SuperuserIPs = []string{"127.0.0.1"}
|
||||||
|
if err := app1.Save(app1.Settings()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// block until released or timeouted
|
||||||
|
sem.Acquire(ctx, 1)
|
||||||
|
|
||||||
|
if app1Total := testEvents.Get(app1); app1Total != 1 {
|
||||||
|
t.Fatalf("Expected 1 app1 event, got %d", app1Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
if app2Total := testEvents.Get(app2); app2Total != 1 {
|
||||||
|
t.Fatalf("Expected 1 app2 event, got %d", app2Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
app2SuperuserIPs := app2.Settings().SuperuserIPs
|
||||||
|
if len(app2SuperuserIPs) != 1 || app2SuperuserIPs[0] != "127.0.0.1" {
|
||||||
|
t.Fatalf("Expected exactly 127.0.0.1 superuser IP in app2 settings event, got %v", app2SuperuserIPs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyWatcher_CollectionsUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
app1 := core.NewBaseApp(core.BaseAppConfig{
|
||||||
|
DataDir: tmpDir,
|
||||||
|
})
|
||||||
|
if err := app1.Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app2 := core.NewBaseApp(core.BaseAppConfig{
|
||||||
|
DataDir: tmpDir,
|
||||||
|
})
|
||||||
|
if err := app2.Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testQueries := store.New[string, []string](nil)
|
||||||
|
app2.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||||
|
testQueries.SetFunc("concurrent", func(old []string) []string {
|
||||||
|
return append(old, sql)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app2.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||||
|
testQueries.SetFunc("concurrent", func(old []string) []string {
|
||||||
|
return append(old, sql)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app2.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||||
|
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
|
||||||
|
return append(old, sql)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
app2.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||||
|
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
|
||||||
|
return append(old, sql)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancelCtx()
|
||||||
|
|
||||||
|
sem := semaphore.NewWeighted(1)
|
||||||
|
sem.Acquire(ctx, 1)
|
||||||
|
|
||||||
|
// currently there is no hook for the collections cache reload so we pool instead
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if len(testQueries.Get("concurrent")) == 1 {
|
||||||
|
sem.Release(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// create/update/delete app1 collections should trigger a reload in app2
|
||||||
|
dummyCollection := core.NewBaseCollection("test")
|
||||||
|
if err := app1.Save(dummyCollection); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
|
||||||
|
if err := app1.Save(dummyCollection); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app1.Delete(dummyCollection); err != nil {
|
||||||
|
}
|
||||||
|
|
||||||
|
// block until released or timeouted
|
||||||
|
sem.Acquire(ctx, 1)
|
||||||
|
ticker.Stop()
|
||||||
|
done <- true
|
||||||
|
|
||||||
|
nonconcurrentQueries := testQueries.Get("nonconcurrent")
|
||||||
|
concurrentQueries := testQueries.Get("concurrent")
|
||||||
|
|
||||||
|
if len(nonconcurrentQueries) != 0 {
|
||||||
|
t.Fatalf("Expected 0 concurrent queries, got %d (%v)", len(nonconcurrentQueries), nonconcurrentQueries)
|
||||||
|
}
|
||||||
|
if len(concurrentQueries) != 1 {
|
||||||
|
t.Fatalf("Expected 1 concurrent query, got %d (%v)", len(concurrentQueries), concurrentQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedQuery := "SELECT {{_collections}}.* FROM `_collections` ORDER BY `rowid` ASC"
|
||||||
|
if concurrentQueries[0] != expectedQuery {
|
||||||
|
t.Fatalf("Expected query\n%s\ngot\n%s", expectedQuery, concurrentQueries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,10 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type settings struct {
|
type settings struct {
|
||||||
|
// SuperuserIPs defines an optional list of the superuser allowed
|
||||||
|
// individual IPs and subnets (in CIDR notation).
|
||||||
|
SuperuserIPs []string `form:"superuserIPs" json:"superuserIPs"`
|
||||||
|
|
||||||
SMTP SMTPConfig `form:"smtp" json:"smtp"`
|
SMTP SMTPConfig `form:"smtp" json:"smtp"`
|
||||||
Backups BackupsConfig `form:"backups" json:"backups"`
|
Backups BackupsConfig `form:"backups" json:"backups"`
|
||||||
S3 S3Config `form:"s3" json:"s3"`
|
S3 S3Config `form:"s3" json:"s3"`
|
||||||
@@ -253,6 +257,12 @@ func (s *Settings) DBExport(app App) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
result["updated"] = now
|
result["updated"] = now
|
||||||
|
|
||||||
|
// @todo remove with encoding/json/2
|
||||||
|
// serialize as empty array
|
||||||
|
if s.settings.SuperuserIPs == nil {
|
||||||
|
s.settings.SuperuserIPs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
encoded, err := json.Marshal(s.settings)
|
encoded, err := json.Marshal(s.settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -280,6 +290,7 @@ func (s *Settings) PostValidate(ctx context.Context, app App) error {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
return validation.ValidateStructWithContext(ctx, s,
|
return validation.ValidateStructWithContext(ctx, s,
|
||||||
|
validation.Field(&s.SuperuserIPs, validation.Each(validation.By(validators.IPOrSubnet))),
|
||||||
validation.Field(&s.Meta),
|
validation.Field(&s.Meta),
|
||||||
validation.Field(&s.Logs),
|
validation.Field(&s.Logs),
|
||||||
validation.Field(&s.SMTP),
|
validation.Field(&s.SMTP),
|
||||||
@@ -343,6 +354,12 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @todo remove with encoding/json/2
|
||||||
|
// serialize as empty array
|
||||||
|
if copy.SuperuserIPs == nil {
|
||||||
|
copy.SuperuserIPs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
return json.Marshal(copy)
|
return json.Marshal(copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
|
|||||||
valueStr = string(export["value"].([]byte))
|
valueStr = string(export["value"].([]byte))
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||||
if valueStr != expected {
|
if valueStr != expected {
|
||||||
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
|
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rawStr := string(raw)
|
rawStr := string(raw)
|
||||||
|
|
||||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||||
|
|
||||||
if rawStr != expected {
|
if rawStr != expected {
|
||||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
||||||
@@ -196,6 +196,7 @@ func TestSettingsValidate(t *testing.T) {
|
|||||||
s := app.Settings()
|
s := app.Settings()
|
||||||
|
|
||||||
// set invalid settings data
|
// set invalid settings data
|
||||||
|
s.SuperuserIPs = []string{"127.0.0.1", "invalid"}
|
||||||
s.Meta.AppName = ""
|
s.Meta.AppName = ""
|
||||||
s.Logs.MaxDays = -10
|
s.Logs.MaxDays = -10
|
||||||
s.SMTP.Enabled = true
|
s.SMTP.Enabled = true
|
||||||
@@ -217,6 +218,7 @@ func TestSettingsValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectations := []string{
|
expectations := []string{
|
||||||
|
`"superuserIPs":{`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
`"logs":{`,
|
`"logs":{`,
|
||||||
`"smtp":{`,
|
`"smtp":{`,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
|
|||||||
"validation_file_size_limit",
|
"validation_file_size_limit",
|
||||||
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
|
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
|
||||||
).SetParams(map[string]any{
|
).SetParams(map[string]any{
|
||||||
"file": v.OriginalName,
|
"file": cutStr(v.OriginalName, 300),
|
||||||
"maxSize": maxBytes,
|
"maxSize": maxBytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
|||||||
|
|
||||||
baseErr := validation.NewError(
|
baseErr := validation.NewError(
|
||||||
"validation_invalid_mime_type",
|
"validation_invalid_mime_type",
|
||||||
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
|
fmt.Sprintf("Failed to upload %q due to unsupported file type.", cutStr(v.OriginalName, 300)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(validTypes) == 0 {
|
if len(validTypes) == 0 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package validators
|
package validators
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
@@ -27,3 +28,30 @@ func IsRegex(value any) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IPOrSubnet checks whether the validated value is an individual
|
||||||
|
// IPv4/IPv6 or CIDR subnet.
|
||||||
|
func IPOrSubnet(value any) error {
|
||||||
|
v, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrUnsupportedValueType
|
||||||
|
}
|
||||||
|
|
||||||
|
if v == "" {
|
||||||
|
return nil // nothing to check
|
||||||
|
}
|
||||||
|
|
||||||
|
// subnet
|
||||||
|
_, err := netip.ParsePrefix(v)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// individual IP
|
||||||
|
_, err = netip.ParseAddr(v)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return validation.NewError("validation_invlaid_ip_or_subnet", "invalid IP or CIDR subnet")
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,32 @@ func TestIsRegex(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIPOrSubnet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
val string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"", false},
|
||||||
|
{`invalid`, true},
|
||||||
|
{`127.0`, true}, // incomplete
|
||||||
|
{`127.0.0.1`, false},
|
||||||
|
{`::1`, false},
|
||||||
|
{`0000:0000:0000:0000:0000:0000:0000:0001`, false},
|
||||||
|
{`127.0.0.1/24`, false},
|
||||||
|
{`::/128`, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) {
|
||||||
|
err := validators.IPOrSubnet(s.val)
|
||||||
|
|
||||||
|
hasErr := err != nil
|
||||||
|
if hasErr != s.expectError {
|
||||||
|
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,3 +38,10 @@ func JoinValidationErrors(errA, errB error) error {
|
|||||||
|
|
||||||
return errors.Join(errA, errB)
|
return errors.Join(errA, errB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cutStr(str string, max int) string {
|
||||||
|
if len(str) > max {
|
||||||
|
return str[:max] + "..."
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# all environments should start with 'PB_' prefix
|
# all environments should start with 'PB_' prefix
|
||||||
PB_BACKEND_URL = "../"
|
PB_BACKEND_URL = "../"
|
||||||
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
|
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
|
||||||
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
|
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
|
||||||
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
|
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
|
||||||
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
|
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
|
||||||
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
|
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
|
||||||
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
|
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
|
||||||
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
|
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
|
||||||
PB_DOCS_URL = "https://pocketbase.io/docs"
|
PB_SUPERUSER_IPS_RESET_DOCS = "https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets"
|
||||||
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
|
PB_DOCS_URL = "https://pocketbase.io/docs"
|
||||||
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
|
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
|
||||||
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
|
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
|
||||||
PB_VERSION = "v0.37.6-dev"
|
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
|
||||||
|
PB_VERSION = "v0.37.6-dev"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
<!-- prism -->
|
<!-- prism -->
|
||||||
<script src="./libs/prism/prism.js" data-manual></script>
|
<script src="./libs/prism/prism.js" data-manual></script>
|
||||||
<script type="module" crossorigin src="./assets/index-CsbGEmKA.js"></script>
|
<script type="module" crossorigin src="./assets/index-B5eik2f8.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
|
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-e0-DEGQo.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-ZC1aK-6Z.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -832,7 +832,7 @@ function deleteDropdownItem(data, modalSettings) {
|
|||||||
try {
|
try {
|
||||||
await app.pb.collections.delete(data.originalCollection.name);
|
await app.pb.collections.delete(data.originalCollection.name);
|
||||||
|
|
||||||
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
|
modalSettings?.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
|
||||||
|
|
||||||
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);
|
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -513,7 +513,9 @@ hr {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: var(--borderRadius);
|
border-radius: var(--borderRadius);
|
||||||
color: var(--linkColor);
|
color: var(--linkColor);
|
||||||
transition: color var(--animationSpeed);
|
transition:
|
||||||
|
color var(--animationSpeed),
|
||||||
|
opacity var(--animationSpeed);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
|
|||||||
@@ -235,12 +235,4 @@ window.app.checkApiError = function(err, showToast = true) {
|
|||||||
app.pb.cancelAllRequests();
|
app.pb.cancelAllRequests();
|
||||||
return app.pb.authStore.clear();
|
return app.pb.authStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// forbidden
|
|
||||||
if (statusCode === 403) {
|
|
||||||
app.pb.cancelAllRequests();
|
|
||||||
if (window.location.hash != LOGIN_PATH) {
|
|
||||||
window.location.hash = LOGIN_PATH;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function batchAccordion(pageData) {
|
|||||||
t.summary(
|
t.summary(
|
||||||
null,
|
null,
|
||||||
t.i({ className: "ri-archive-stack-line", ariaHidden: true }),
|
t.i({ className: "ri-archive-stack-line", ariaHidden: true }),
|
||||||
t.span({ className: "txt" }, "Batch API"),
|
t.span({ className: "txt" }, "Batch Web API"),
|
||||||
t.div({ className: "flex-fill" }),
|
t.div({ className: "flex-fill" }),
|
||||||
() => {
|
() => {
|
||||||
if (pageData.formSettings.batch.enabled) {
|
if (pageData.formSettings.batch.enabled) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { settingsSidebar } from "../settingsSidebar";
|
import { settingsSidebar } from "../settingsSidebar";
|
||||||
import { batchAccordion } from "./batchAccordion";
|
import { batchAccordion } from "./batchAccordion";
|
||||||
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
|
import { rateLimitAccordion, sortRules } from "./rateLimitAccordion";
|
||||||
|
import { superuserAccordion } from "./superuserAccordion";
|
||||||
import { trustedProxyAccordion } from "./trustedProxyAccordion";
|
import { trustedProxyAccordion } from "./trustedProxyAccordion";
|
||||||
|
|
||||||
export function pageApplicationSettings() {
|
export function pageApplicationSettings() {
|
||||||
@@ -37,6 +38,53 @@ export function pageApplicationSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSuperuserIPsChanged() {
|
||||||
|
return JSON.stringify(data.formSettings?.superuserIPs)
|
||||||
|
!= JSON.stringify(data.originalFormSettings?.superuserIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWithConfirm() {
|
||||||
|
const superuserIPs = app.utils.toArray(data.formSettings?.superuserIPs);
|
||||||
|
|
||||||
|
if (!superuserIPs.length) {
|
||||||
|
return save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.modals.confirm(
|
||||||
|
t.div(
|
||||||
|
{ className: "txt-center" },
|
||||||
|
t.h6(
|
||||||
|
null,
|
||||||
|
"The ONLY allowed superuser IPs will change to: ",
|
||||||
|
t.br(),
|
||||||
|
t.strong(null, superuserIPs.join(", ")),
|
||||||
|
),
|
||||||
|
t.p(null, "Please make sure that your IP is in the list or you'll be locked."),
|
||||||
|
t.p(
|
||||||
|
{ className: "txt-hint" },
|
||||||
|
"In case of lockout, you can reset the setting with the ",
|
||||||
|
t.a(
|
||||||
|
{
|
||||||
|
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
className: "link-primary txt-bold txt-sm",
|
||||||
|
},
|
||||||
|
t.code(
|
||||||
|
null,
|
||||||
|
"superuser ips",
|
||||||
|
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
" console command.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() => save(),
|
||||||
|
null,
|
||||||
|
{ yesButton: "Yes, save changes" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (data.isSaving || !data.hasChanges) {
|
if (data.isSaving || !data.hasChanges) {
|
||||||
return;
|
return;
|
||||||
@@ -48,8 +96,19 @@ export function pageApplicationSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const redacted = app.utils.filterRedactedProps(data.formSettings);
|
const redacted = app.utils.filterRedactedProps(data.formSettings);
|
||||||
const settings = await app.pb.settings.update(redacted);
|
|
||||||
init(settings);
|
const updatedSettings = await app.pb.settings.update(redacted);
|
||||||
|
|
||||||
|
// reauthenticate to ensure that the superuser has still access
|
||||||
|
if (hasSuperuserIPsChanged()) {
|
||||||
|
try {
|
||||||
|
await app.pb.collection("_superusers").authRefresh();
|
||||||
|
} catch (_) {
|
||||||
|
app.pb.authStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(updatedSettings);
|
||||||
|
|
||||||
app.toasts.success("Successfully saved application settings.");
|
app.toasts.success("Successfully saved application settings.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -73,6 +132,7 @@ export function pageApplicationSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.originalFormSettings = {
|
data.originalFormSettings = {
|
||||||
|
superuserIPs: settings.superuserIPs || [],
|
||||||
meta: settings.meta || {},
|
meta: settings.meta || {},
|
||||||
batch: settings.batch || {},
|
batch: settings.batch || {},
|
||||||
trustedProxy: settings.trustedProxy || { headers: [] },
|
trustedProxy: settings.trustedProxy || { headers: [] },
|
||||||
@@ -118,7 +178,7 @@ export function pageApplicationSettings() {
|
|||||||
inert: () => data.isSaving,
|
inert: () => data.isSaving,
|
||||||
onsubmit: (e) => {
|
onsubmit: (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
save();
|
saveWithConfirm();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
t.div(
|
t.div(
|
||||||
@@ -158,9 +218,10 @@ export function pageApplicationSettings() {
|
|||||||
),
|
),
|
||||||
t.div(
|
t.div(
|
||||||
{ className: "col-lg-12" },
|
{ className: "col-lg-12" },
|
||||||
|
() => batchAccordion(data),
|
||||||
() => trustedProxyAccordion(data),
|
() => trustedProxyAccordion(data),
|
||||||
() => rateLimitAccordion(data),
|
() => rateLimitAccordion(data),
|
||||||
() => batchAccordion(data),
|
() => superuserAccordion(data),
|
||||||
),
|
),
|
||||||
t.div(
|
t.div(
|
||||||
{ className: "col-lg-12" },
|
{ className: "col-lg-12" },
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ export function rateLimitAccordion(pageData) {
|
|||||||
t.label(
|
t.label(
|
||||||
{ htmlFor: "rateLimits.enabled" },
|
{ htmlFor: "rateLimits.enabled" },
|
||||||
t.span({ className: "txt" }, "Enable"),
|
t.span({ className: "txt" }, "Enable"),
|
||||||
t.small({ className: "txt-hint" }, " (experimental)"),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
export function superuserAccordion(pageData) {
|
||||||
|
const info = store({
|
||||||
|
isLoading: false,
|
||||||
|
realIP: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadInfo() {
|
||||||
|
info.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await app.pb.health.check({ requestKey: "loadSuperuserIPsInfo" });
|
||||||
|
|
||||||
|
info.realIP = health.data?.realIP || "";
|
||||||
|
info.isLoading = false;
|
||||||
|
} catch (err) {
|
||||||
|
if (!err.isAbort) {
|
||||||
|
app.checkApiError(err);
|
||||||
|
info.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.details(
|
||||||
|
{
|
||||||
|
pbEvent: "superuserAccordion",
|
||||||
|
className: "accordion superuser-accordion",
|
||||||
|
name: "settingsAccordion",
|
||||||
|
onmount: (el) => {
|
||||||
|
el._ipwatcher?.unwatch();
|
||||||
|
el._ipwatcher = watch(
|
||||||
|
() => JSON.stringify(app.store.settings?.trustedProxy?.headers),
|
||||||
|
(newHash, oldHash) => {
|
||||||
|
if (newHash != oldHash) {
|
||||||
|
loadInfo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onunmount: (el) => {
|
||||||
|
el._ipwatcher?.unwatch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
t.summary(
|
||||||
|
null,
|
||||||
|
t.i({ className: "ri-fingerprint-2-line", ariaHidden: true }),
|
||||||
|
t.span({ className: "txt" }, "Superuser IPs"),
|
||||||
|
t.div({ className: "flex-fill" }),
|
||||||
|
() => {
|
||||||
|
if (pageData.formSettings?.superuserIPs?.length) {
|
||||||
|
return t.span({ className: "label success" }, "Enabled");
|
||||||
|
}
|
||||||
|
return t.span({ className: "label" }, "Disabled");
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!app.utils.isEmpty(app.store.errors?.batch)) {
|
||||||
|
return t.i({
|
||||||
|
className: "ri-error-warning-fill txt-danger",
|
||||||
|
ariaDescription: app.attrs.tooltip("Has errors", "left"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
t.div(
|
||||||
|
{ className: "content m-b-sm" },
|
||||||
|
t.p(null, "A comma separated list of superusers allowed IPs and subnets."),
|
||||||
|
t.p(
|
||||||
|
null,
|
||||||
|
"Enabling this option greatly helps hardening the security of your application because even if someone manage to get their hands on a superuser auth token they will not be able to use it.",
|
||||||
|
),
|
||||||
|
t.p(
|
||||||
|
null,
|
||||||
|
"In case your IP changes, you can always reset the field value with the ",
|
||||||
|
t.a(
|
||||||
|
{
|
||||||
|
href: import.meta.env.PB_SUPERUSER_IPS_RESET_DOCS,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
className: "link-primary txt-bold txt-sm",
|
||||||
|
},
|
||||||
|
t.code(
|
||||||
|
null,
|
||||||
|
"superuser ips",
|
||||||
|
t.i({ ariaHidden: true, className: "ri-arrow-right-up-line txt-sm" }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
" console command.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
t.div(
|
||||||
|
{ className: "fields" },
|
||||||
|
t.div(
|
||||||
|
{ className: "field" },
|
||||||
|
t.label(
|
||||||
|
{ htmlFor: "superuserIPs" },
|
||||||
|
t.span({ className: "txt" }, "Superuser IPs and subnets"),
|
||||||
|
),
|
||||||
|
t.input({
|
||||||
|
id: "superuserIPs",
|
||||||
|
name: "superuserIPs",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Leave empty for no restriction",
|
||||||
|
value: () => app.utils.joinNonEmpty(pageData.formSettings.superuserIPs),
|
||||||
|
oninput: (e) => {
|
||||||
|
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
|
||||||
|
const newStr = app.utils.joinNonEmpty(newValue);
|
||||||
|
const oldStr = app.utils.joinNonEmpty(pageData.formSettings.superuserIPs);
|
||||||
|
|
||||||
|
// has an actual change
|
||||||
|
if (oldStr != newStr) {
|
||||||
|
pageData.formSettings.superuserIPs = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
t.div(
|
||||||
|
{ className: "field addon" },
|
||||||
|
t.button(
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
className: () =>
|
||||||
|
`btn sm secondary transparent ${
|
||||||
|
app.utils.isEmpty(pageData.formSettings.superuserIPs) ? "hidden" : ""
|
||||||
|
}`,
|
||||||
|
onclick: () => {
|
||||||
|
pageData.formSettings.superuserIPs = [];
|
||||||
|
|
||||||
|
if (app.store.errors?.superuserIPs) {
|
||||||
|
delete app.store.errors.superuserIPs;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
t.span({ className: "txt" }, "Clear"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
t.div(
|
||||||
|
{ className: "field-help" },
|
||||||
|
"Comma separated list of IPs and subnets such as: ",
|
||||||
|
t.div(
|
||||||
|
{ className: "inline-flex gap-5" },
|
||||||
|
t.div({
|
||||||
|
role: "button",
|
||||||
|
className: "label sm link-primary",
|
||||||
|
onclick: () => {
|
||||||
|
if (info.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ips = app.utils.toArray(pageData.formSettings.superuserIPs);
|
||||||
|
app.utils.pushUnique(ips, info.realIP);
|
||||||
|
pageData.formSettings.superuserIPs = ips;
|
||||||
|
},
|
||||||
|
textContent: () => info.isLoading ? "..." : (info.realIP + " (you)"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-1
@@ -375,7 +375,20 @@ watch(
|
|||||||
|
|
||||||
removeErrorState(input, container);
|
removeErrorState(input, container);
|
||||||
|
|
||||||
const errMsg = app.utils.getByPath(errs, name)?.message;
|
const errData = app.utils.getByPath(errs, name);
|
||||||
|
let errMsg = errData?.message || "";
|
||||||
|
|
||||||
|
// merge one level nested errors into a single message
|
||||||
|
if (!errMsg && !app.utils.isEmpty(errData)) {
|
||||||
|
const combinedErrs = [];
|
||||||
|
for (let key in errData) {
|
||||||
|
if (errData[key]?.message) {
|
||||||
|
combinedErrs.push(`${key}: ${errData[key]?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errMsg = combinedErrs.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
if (!errMsg) {
|
if (!errMsg) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user