added superuser ips whitelist

This commit is contained in:
Gani Georgiev
2026-05-01 17:42:55 +03:00
parent fe2d90641c
commit 21a5524fed
34 changed files with 1224 additions and 47 deletions
+7 -2
View File
@@ -70,8 +70,10 @@ func backupDownload(e *core.RequestEvent) error {
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
}
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
@@ -79,6 +81,9 @@ func backupDownload(e *core.RequestEvent) error {
}
defer fsys.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys.SetContext(ctx)
key := e.Request.PathValue("key")
+52
View File
@@ -528,6 +528,58 @@ func TestBackupsDownload(t *testing.T) {
},
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 {
+1
View File
@@ -31,6 +31,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(panicRecover())
pbRouter.Bind(rateLimit())
pbRouter.Bind(loadAuthToken())
pbRouter.Bind(superuserIPsWhitelist())
pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
+65 -1
View File
@@ -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,
URL: "/api/batch",
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)",
Method: http.MethodPost,
+10
View File
@@ -60,6 +60,7 @@ type fileApi struct {
}
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 {
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")
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)
requestInfo := *originalRequestInfo
requestInfo.Context = core.RequestInfoContextProtectedFile
+44
View File
@@ -353,6 +353,50 @@ func TestFileDownload(t *testing.T) {
"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",
Method: http.MethodGet,
+25
View File
@@ -42,6 +42,9 @@ const (
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
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
// activity logger to log only requests that have failed/returned an error.
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
+36
View File
@@ -2,6 +2,7 @@ package apis
import (
"errors"
"net/netip"
"sync"
"time"
@@ -106,6 +107,41 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
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?
+93
View File
@@ -553,3 +553,96 @@ func TestRequireSameCollectionContextAuth(t *testing.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)
}
}
+7
View File
@@ -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 {
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()
if err != nil {
return err
+36
View File
@@ -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)
}
})
}