diff --git a/cmd/dashboard/controller/alertrule.go b/cmd/dashboard/controller/alertrule.go index d85a5cb3..84b957c8 100644 --- a/cmd/dashboard/controller/alertrule.go +++ b/cmd/dashboard/controller/alertrule.go @@ -2,6 +2,7 @@ package controller import ( "maps" + "slices" "strconv" "time" @@ -192,5 +193,15 @@ func validateRule(c *gin.Context, r *model.AlertRule) error { } else { return singleton.Localizer.ErrorT("need to configure at least a single rule") } + + // Trigger task IDs are user-controlled; validate them here so alerts cannot + // reference another user's cron and later execute it from the sentinel path. + if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) { + return singleton.Localizer.ErrorT("permission denied") + } + if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) { + return singleton.Localizer.ErrorT("permission denied") + } + return nil } diff --git a/cmd/dashboard/controller/jwt_test.go b/cmd/dashboard/controller/jwt_test.go index f2b58eac..9c21ac8b 100644 --- a/cmd/dashboard/controller/jwt_test.go +++ b/cmd/dashboard/controller/jwt_test.go @@ -1,12 +1,18 @@ package controller import ( + "net/http/httptest" "testing" "time" jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/i18n" + "github.com/nezhahq/nezha/service/singleton" "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestPayloadFunc(t *testing.T) { @@ -77,3 +83,74 @@ func TestIPBinding(t *testing.T) { assert.Nil(t, claims["ip"]) }) } + +func TestValidateRuleRejectsForeignTriggerTasks(t *testing.T) { + ctx := newMemberValidationContext(t) + + alertRule := &model.AlertRule{ + Common: model.Common{UserID: 200}, + Name: "member alert", + Rules: []*model.Rule{{Type: "offline", Duration: 3}}, + FailTriggerTasks: []uint64{42}, + RecoverTriggerTasks: []uint64{42}, + } + + assert.Error(t, validateRule(ctx, alertRule)) +} + +func TestValidateServersRejectsForeignTriggerTasks(t *testing.T) { + ctx := newMemberValidationContext(t) + + service := &model.Service{ + Common: model.Common{UserID: 200}, + Name: "member service", + EnableTriggerTask: true, + FailTriggerTasks: []uint64{42}, + RecoverTriggerTasks: []uint64{42}, + SkipServers: map[uint64]bool{}, + } + + assert.Error(t, validateServers(ctx, service)) +} + +func newMemberValidationContext(t *testing.T) *gin.Context { + t.Helper() + + originalDB := singleton.DB + originalLoc := singleton.Loc + originalLocalizer := singleton.Localizer + originalCronShared := singleton.CronShared + originalServerShared := singleton.ServerShared + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + assert.NoError(t, err) + assert.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{})) + assert.NoError(t, db.Create(&model.Cron{ + Common: model.Common{ID: 42, UserID: 1}, + Name: "foreign trigger task", + Command: "admin-maintenance", + TaskType: model.CronTypeTriggerTask, + Cover: model.CronCoverAlertTrigger, + }).Error) + + singleton.DB = db + singleton.Loc = time.Local + singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations) + singleton.CronShared = singleton.NewCronClass() + singleton.ServerShared = singleton.NewServerClass() + t.Cleanup(func() { + singleton.DB = originalDB + singleton.Loc = originalLoc + singleton.Localizer = originalLocalizer + singleton.CronShared = originalCronShared + singleton.ServerShared = originalServerShared + }) + + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Set(model.CtxKeyAuthorizedUser, &model.User{ + Common: model.Common{ID: 200}, + Role: model.RoleMember, + }) + return ctx +} diff --git a/cmd/dashboard/controller/service.go b/cmd/dashboard/controller/service.go index 3f97a735..0e953686 100644 --- a/cmd/dashboard/controller/service.go +++ b/cmd/dashboard/controller/service.go @@ -545,5 +545,14 @@ func validateServers(c *gin.Context, ss *model.Service) error { return singleton.Localizer.ErrorT("permission denied") } + // Trigger task IDs are user-controlled; validate them here so services cannot + // reference another user's cron and later execute it from the sentinel path. + if !singleton.CronShared.CheckPermission(c, slices.Values(ss.FailTriggerTasks)) { + return singleton.Localizer.ErrorT("permission denied") + } + if !singleton.CronShared.CheckPermission(c, slices.Values(ss.RecoverTriggerTasks)) { + return singleton.Localizer.ErrorT("permission denied") + } + return nil }