fix(security): restrict service monitors to probe tasks

This commit is contained in:
naiba
2026-08-15 05:00:28 +00:00
parent 42d9e4c8c3
commit 38824dbc11
16 changed files with 338 additions and 20 deletions
+5 -2
View File
@@ -26,8 +26,11 @@ import (
// code to their own origin and bind the victim's identity. A request Host is
// trusted only when it is an operator-declared dashboard host (the same
// allowlist that guards NAT routing). Otherwise the redirect is pinned to the
// operator-declared DashboardHost; when DashboardHost is empty the operator has
// not pinned a dashboard origin, so the request Host is passed through.
// operator-declared DashboardHost. Empty DashboardHost intentionally retains
// dynamic/multi-domain deployments by passing through request Host; those
// deployments must validate Host at their trusted proxy and register exact
// redirect URIs at the OAuth provider. GHSA-rf68-8gjr-36q7 documents this
// configuration boundary and must be updated if this compatibility changes.
func getRedirectURL(c *gin.Context) string {
scheme := "http://"
referer := c.Request.Referer()
@@ -261,8 +261,8 @@ func TestShowServiceFiltersCycleTransferStatsLikeServerList(t *testing.T) {
assert.NoError(t, singleton.DB.Create(&model.Server{Common: model.Common{ID: 3, UserID: 200}, Name: "hidden member server", UUID: "hidden-member-server", HideForGuest: true}).Error)
singleton.ServerShared = singleton.NewServerClass()
assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 10, UserID: 1}, Name: "shown service"}).Error)
assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 11, UserID: 1}, Name: "hidden service", HideForGuest: true}).Error)
assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 10, UserID: 1}, Name: "shown service", Type: model.TaskTypeTCPPing}).Error)
assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 11, UserID: 1}, Name: "hidden service", Type: model.TaskTypeTCPPing, HideForGuest: true}).Error)
originalServiceSentinel := singleton.ServiceSentinelShared
serviceSentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 2))
+6
View File
@@ -484,6 +484,9 @@ func createService(c *gin.Context) (uint64, error) {
if err := c.ShouldBindJSON(&mf); err != nil {
return 0, err
}
if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil {
return 0, err
}
if !isValidServiceCover(mf.Cover) {
return 0, singleton.Localizer.ErrorT("permission denied")
@@ -548,6 +551,9 @@ func updateService(c *gin.Context) (any, error) {
if err := c.ShouldBindJSON(&mf); err != nil {
return nil, err
}
if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil {
return nil, err
}
if !isValidServiceCover(mf.Cover) {
return nil, singleton.Localizer.ErrorT("permission denied")
@@ -0,0 +1,91 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func serviceTypeSecurityRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
setAuthUser(c, 100, model.RoleMember)
c.Next()
})
r.POST("/api/v1/service", commonHandler(createService))
r.PATCH("/api/v1/service/:id", commonHandler(updateService))
return r
}
func serviceTypeSecurityBody(taskType uint8) []byte {
body, _ := json.Marshal(model.ServiceForm{
Name: "service-type-security",
Target: "example.invalid:443",
Type: taskType,
Cover: model.ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{1: true},
Duration: 30,
})
return body
}
func TestCreateServiceRejectsNonProbeTaskTypes(t *testing.T) {
setupCoverPATFixture(t)
r := serviceTypeSecurityRouter()
for _, taskType := range []uint8{0, model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/service", bytes.NewReader(serviceTypeSecurityBody(taskType)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
require.False(t, success, "type %d must be rejected", taskType)
require.Contains(t, errMsg, "invalid service monitor type")
}
var count int64
require.NoError(t, singleton.DB.Model(&model.Service{}).Count(&count).Error)
require.Zero(t, count, "rejected task types must not reach persistence")
}
func TestUpdateServiceRejectsNonProbeTaskTypes(t *testing.T) {
setupCoverPATFixture(t)
r := serviceTypeSecurityRouter()
service := &model.Service{
Common: model.Common{UserID: 100},
Name: "valid-service",
Target: "example.invalid:443",
Type: model.TaskTypeTCPPing,
Cover: model.ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{1: true},
Duration: 30,
}
require.NoError(t, singleton.DB.Create(service).Error)
for _, taskType := range []uint8{model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} {
w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/service/%d", service.ID)
req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(serviceTypeSecurityBody(taskType)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
require.False(t, success, "type %d must be rejected", taskType)
require.Contains(t, errMsg, "invalid service monitor type")
var persisted model.Service
require.NoError(t, singleton.DB.First(&persisted, service.ID).Error)
require.Equal(t, uint8(model.TaskTypeTCPPing), persisted.Type)
}
}
+6
View File
@@ -46,6 +46,12 @@ func initSystem(bus chan<- *model.Service) error {
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
return err
}
// Backward-compatible bootstrap state: existing installers and recovery
// procedures expect the first login on an empty database to be admin/admin.
// This is not a permanent credential or an authentication-bypass fallback;
// operators must complete initialization and change it before exposing the
// Dashboard. Replacing it requires a coordinated installer/migration flow so
// existing unattended installations are not locked out.
if usersCount == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
+13 -2
View File
@@ -14,6 +14,17 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
if task == nil {
continue
}
if err := model.ValidateServiceMonitorType(uint64(task.Type)); err != nil {
// Defense in depth for stale database rows and future internal callers:
// Service.Type shares its integer namespace with command/config tasks.
log.Printf("NEZHA>> DispatchTask rejected service %d: %v", task.ID, err)
continue
}
probe := task.PB()
if probe == nil {
log.Printf("NEZHA>> DispatchTask rejected service %d: invalid probe", task.ID)
continue
}
switch task.Cover {
case model.ServiceCoverIgnoreAll:
@@ -29,7 +40,7 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
if !canSendTaskToServer(task, server) {
continue
}
if err := server.SendTask(task.PB()); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) {
if err := server.SendTask(probe); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) {
log.Printf("NEZHA>> DispatchTask send error (server=%d): %v", id, err)
}
}
@@ -41,7 +52,7 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
if !canSendTaskToServer(task, server) {
continue
}
if err := server.SendTask(task.PB()); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) {
if err := server.SendTask(probe); err != nil && !errors.Is(err, model.ErrTaskStreamOffline) {
log.Printf("NEZHA>> DispatchTask send error (server=%d): %v", id, err)
}
}
@@ -0,0 +1,59 @@
package rpc
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestDispatchTaskSendsOnlyProbeTypes(t *testing.T) {
originalServerShared := singleton.ServerShared
originalUserInfo := singleton.UserInfoMap
t.Cleanup(func() {
singleton.ServerShared = originalServerShared
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfo
singleton.UserLock.Unlock()
})
server := &model.Server{Common: model.Common{ID: 1, UserID: 100}}
stream := &serveNATTaskStream{}
server.SetTaskStream(stream)
serverShared := singleton.NewEmptyServerClassForTest()
serverShared.InsertForTest(server)
singleton.ServerShared = serverShared
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
singleton.UserLock.Unlock()
bus := make(chan *model.Service, 8)
done := make(chan struct{})
go func() {
DispatchTask(bus)
close(done)
}()
for _, taskType := range []uint8{model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} {
bus <- &model.Service{
Common: model.Common{ID: uint64(taskType), UserID: 100},
Type: taskType,
Cover: model.ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{1: true},
}
}
bus <- &model.Service{
Common: model.Common{ID: 1000, UserID: 100},
Type: model.TaskTypeTCPPing,
Target: "example.invalid:443",
Cover: model.ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{1: true},
}
close(bus)
<-done
require.Len(t, stream.sent, 1)
require.Equal(t, uint64(model.TaskTypeTCPPing), stream.sent[0].GetType())
require.Equal(t, uint64(1000), stream.sent[0].GetId())
}