mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
fix(security): restrict service monitors to probe tasks
This commit is contained in:
@@ -26,8 +26,11 @@ import (
|
|||||||
// code to their own origin and bind the victim's identity. A request Host is
|
// 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
|
// trusted only when it is an operator-declared dashboard host (the same
|
||||||
// allowlist that guards NAT routing). Otherwise the redirect is pinned to the
|
// allowlist that guards NAT routing). Otherwise the redirect is pinned to the
|
||||||
// operator-declared DashboardHost; when DashboardHost is empty the operator has
|
// operator-declared DashboardHost. Empty DashboardHost intentionally retains
|
||||||
// not pinned a dashboard origin, so the request Host is passed through.
|
// 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 {
|
func getRedirectURL(c *gin.Context) string {
|
||||||
scheme := "http://"
|
scheme := "http://"
|
||||||
referer := c.Request.Referer()
|
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)
|
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()
|
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: 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", HideForGuest: true}).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
|
originalServiceSentinel := singleton.ServiceSentinelShared
|
||||||
serviceSentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 2))
|
serviceSentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 2))
|
||||||
|
|||||||
@@ -484,6 +484,9 @@ func createService(c *gin.Context) (uint64, error) {
|
|||||||
if err := c.ShouldBindJSON(&mf); err != nil {
|
if err := c.ShouldBindJSON(&mf); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
if !isValidServiceCover(mf.Cover) {
|
if !isValidServiceCover(mf.Cover) {
|
||||||
return 0, singleton.Localizer.ErrorT("permission denied")
|
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 {
|
if err := c.ShouldBindJSON(&mf); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := model.ValidateServiceMonitorType(uint64(mf.Type)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if !isValidServiceCover(mf.Cover) {
|
if !isValidServiceCover(mf.Cover) {
|
||||||
return nil, singleton.Localizer.ErrorT("permission denied")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ func initSystem(bus chan<- *model.Service) error {
|
|||||||
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
|
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
|
||||||
return err
|
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 {
|
if usersCount == 0 {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
|
|||||||
if task == nil {
|
if task == nil {
|
||||||
continue
|
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 {
|
switch task.Cover {
|
||||||
case model.ServiceCoverIgnoreAll:
|
case model.ServiceCoverIgnoreAll:
|
||||||
@@ -29,7 +40,7 @@ func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
|
|||||||
if !canSendTaskToServer(task, server) {
|
if !canSendTaskToServer(task, server) {
|
||||||
continue
|
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)
|
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) {
|
if !canSendTaskToServer(task, server) {
|
||||||
continue
|
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)
|
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())
|
||||||
|
}
|
||||||
+6
-1
@@ -43,7 +43,12 @@ type ConfigForGuests struct {
|
|||||||
|
|
||||||
type ConfigDashboard struct {
|
type ConfigDashboard struct {
|
||||||
InstallHost string `koanf:"install_host" json:"install_host,omitempty"`
|
InstallHost string `koanf:"install_host" json:"install_host,omitempty"`
|
||||||
AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // 用于前端判断生成的安装命令是否启用 TLS
|
// AgentTLS controls the transport emitted by Agent installation commands.
|
||||||
|
// false intentionally supports trusted private networks and does not provide
|
||||||
|
// Dashboard peer authentication; Internet-facing control planes must use
|
||||||
|
// verified TLS. Changing this compatibility default belongs in the installer
|
||||||
|
// migration path, not in the gRPC task authorization model.
|
||||||
|
AgentTLS bool `koanf:"tls" json:"tls,omitempty"`
|
||||||
|
|
||||||
// DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。
|
// DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。
|
||||||
// 它与 InstallHost(agent 连接用主机名)解耦:两者可以是不同域名。
|
// 它与 InstallHost(agent 连接用主机名)解耦:两者可以是不同域名。
|
||||||
|
|||||||
+6
-2
@@ -21,8 +21,12 @@ type StreamServerData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerForm struct {
|
type ServerForm struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
|
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
|
||||||
|
// PublicNote is opaque public metadata consumed by independently maintained
|
||||||
|
// user themes. The Dashboard stores/transports it but never renders it as
|
||||||
|
// HTML or navigates URL-like fields. Themes must validate schemes before
|
||||||
|
// using nested values such as customData.orderLink in href/window.open.
|
||||||
PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
|
PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
|
||||||
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
|
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
|
||||||
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
|
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
|
||||||
|
|||||||
+34
-10
@@ -39,6 +39,30 @@ const (
|
|||||||
TaskTypeFsTransfer
|
TaskTypeFsTransfer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IsServiceMonitorType reports whether t is a passive service probe. Service
|
||||||
|
// monitors and privileged Agent-control tasks share the protobuf Task.Type
|
||||||
|
// namespace, so every path that persists, schedules, or dispatches a Service
|
||||||
|
// must use this allowlist instead of accepting an arbitrary task integer.
|
||||||
|
func IsServiceMonitorType(t uint64) bool {
|
||||||
|
switch t {
|
||||||
|
case TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateServiceMonitorType returns an actionable error at API, model, and
|
||||||
|
// scheduler boundaries. Keeping the check in model avoids a future caller
|
||||||
|
// accidentally turning a monitor-only capability into Agent command/config
|
||||||
|
// execution by copying Service.Type into pb.Task.Type.
|
||||||
|
func ValidateServiceMonitorType(t uint64) error {
|
||||||
|
if !IsServiceMonitorType(t) {
|
||||||
|
return fmt.Errorf("invalid service monitor type %d: allowed types are 1 (HTTP GET), 2 (ICMP ping), and 3 (TCP ping)", t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsMCPRPCResult 判定一个 TaskResult.Type 是否属于 MCP 走 RequestTask 通道的
|
// IsMCPRPCResult 判定一个 TaskResult.Type 是否属于 MCP 走 RequestTask 通道的
|
||||||
// 一次性 RPC 类型。dashboard 的 RequestTask 接收循环用它把这些回包路由到
|
// 一次性 RPC 类型。dashboard 的 RequestTask 接收循环用它把这些回包路由到
|
||||||
// Server.inflightRPC 等待方,而不是走 ServiceSentinel。
|
// Server.inflightRPC 等待方,而不是走 ServiceSentinel。
|
||||||
@@ -232,6 +256,9 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Service) PB() *pb.Task {
|
func (m *Service) PB() *pb.Task {
|
||||||
|
if m == nil || !IsServiceMonitorType(uint64(m.Type)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return &pb.Task{
|
return &pb.Task{
|
||||||
Id: m.ID,
|
Id: m.ID,
|
||||||
Type: uint64(m.Type),
|
Type: uint64(m.Type),
|
||||||
@@ -300,6 +327,9 @@ func (m *Service) CronSpec() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Service) BeforeSave(tx *gorm.DB) error {
|
func (m *Service) BeforeSave(tx *gorm.DB) error {
|
||||||
|
if err := ValidateServiceMonitorType(uint64(m.Type)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if data, err := json.Marshal(m.SkipServers); err != nil {
|
if data, err := json.Marshal(m.SkipServers); err != nil {
|
||||||
return err
|
return err
|
||||||
} else {
|
} else {
|
||||||
@@ -336,15 +366,9 @@ func (m *Service) AfterFind(tx *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
|
// IsServiceSentinelNeeded accepts results only for the three probe types. An
|
||||||
|
// unknown or privileged task type must never enter ServiceSentinel merely
|
||||||
|
// because it was not listed in a denylist.
|
||||||
func IsServiceSentinelNeeded(t uint64) bool {
|
func IsServiceSentinelNeeded(t uint64) bool {
|
||||||
switch t {
|
return IsServiceMonitorType(t)
|
||||||
case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade,
|
|
||||||
TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM,
|
|
||||||
TaskTypeReportConfig, TaskTypeApplyConfig,
|
|
||||||
TaskTypeServerTransferApply:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceMonitorTypeAllowlist(t *testing.T) {
|
||||||
|
for _, taskType := range []uint64{TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing} {
|
||||||
|
require.True(t, IsServiceMonitorType(taskType), "probe type %d must remain allowed", taskType)
|
||||||
|
require.NoError(t, ValidateServiceMonitorType(taskType))
|
||||||
|
require.True(t, IsServiceSentinelNeeded(taskType))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, taskType := range []uint64{
|
||||||
|
0,
|
||||||
|
TaskTypeCommand,
|
||||||
|
TaskTypeApplyConfig,
|
||||||
|
TaskTypeServerTransferApply,
|
||||||
|
TaskTypeExec,
|
||||||
|
TaskTypeFsTransfer,
|
||||||
|
255,
|
||||||
|
} {
|
||||||
|
require.False(t, IsServiceMonitorType(taskType), "privileged/unknown type %d must be rejected", taskType)
|
||||||
|
require.Error(t, ValidateServiceMonitorType(taskType))
|
||||||
|
require.False(t, IsServiceSentinelNeeded(taskType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePersistenceAndPBRejectPrivilegedTaskTypes(t *testing.T) {
|
||||||
|
for _, taskType := range []uint8{0, TaskTypeCommand, TaskTypeApplyConfig, TaskTypeExec, 255} {
|
||||||
|
service := &Service{Type: taskType}
|
||||||
|
require.Error(t, service.BeforeSave(nil), "type %d must not be persisted", taskType)
|
||||||
|
require.Nil(t, service.PB(), "type %d must not become an Agent task", taskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{Common: Common{ID: 7}, Type: TaskTypeTCPPing, Target: "example.invalid:443"}
|
||||||
|
require.NoError(t, service.BeforeSave(nil))
|
||||||
|
task := service.PB()
|
||||||
|
require.NotNil(t, task)
|
||||||
|
require.Equal(t, uint64(7), task.GetId())
|
||||||
|
require.Equal(t, uint64(TaskTypeTCPPing), task.GetType())
|
||||||
|
require.Equal(t, service.Target, task.GetData())
|
||||||
|
}
|
||||||
@@ -222,6 +222,9 @@ func authorizeAgentForUUID(userId uint64, clientUUID string) (clientID uint64, h
|
|||||||
if userId == 0 {
|
if userId == 0 {
|
||||||
// The legacy global agent secret maps to user 0. It predates per-user
|
// The legacy global agent secret maps to user 0. It predates per-user
|
||||||
// agent secrets, so keep it compatible by allowing any existing UUID.
|
// agent secrets, so keep it compatible by allowing any existing UUID.
|
||||||
|
// Possession of this deployment-wide master credential is therefore not
|
||||||
|
// a tenant-scoped authorization claim. Removal must follow an inventory and
|
||||||
|
// credential-rotation migration or legacy Agents will be locked out.
|
||||||
return cid, true, nil
|
return cid, true, nil
|
||||||
}
|
}
|
||||||
if server.GetUserID() == userId {
|
if server.GetUserID() == userId {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
repository: "https://github.com/karllao/nezha-pixel"
|
repository: "https://github.com/karllao/nezha-pixel"
|
||||||
author: "karllao"
|
author: "karllao"
|
||||||
version: "v1.6.0"
|
version: "v1.6.0"
|
||||||
|
# Third-party user themes consume the opaque Server.PublicNote field. Theme
|
||||||
|
# maintainers must validate URL schemes (including after decoding) before using
|
||||||
|
# values such as customData.orderLink in href or window.open; the Dashboard
|
||||||
|
# backend and admin frontend do not execute those fields.
|
||||||
- path: "nazhua-dist"
|
- path: "nazhua-dist"
|
||||||
name: "Nazhua"
|
name: "Nazhua"
|
||||||
repository: "https://github.com/hi2shark/nazhua"
|
repository: "https://github.com/hi2shark/nazhua"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceSentinelUpdateRejectsNonProbeTaskTypes(t *testing.T) {
|
||||||
|
ss := &ServiceSentinel{}
|
||||||
|
require.Error(t, ss.Update(nil))
|
||||||
|
for _, taskType := range []uint8{0, model.TaskTypeCommand, model.TaskTypeApplyConfig, model.TaskTypeExec, 255} {
|
||||||
|
require.Error(t, ss.Update(&model.Service{Type: taskType}), "type %d must not be scheduled", taskType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceSentinelQuarantinesInvalidPersistedTypes(t *testing.T) {
|
||||||
|
ss := newServiceMonitorSecurityHarness(t)
|
||||||
|
|
||||||
|
insert := `INSERT INTO services
|
||||||
|
(id, user_id, name, type, target, duration, cover, skip_servers_raw, fail_trigger_tasks_raw, recover_trigger_tasks_raw)
|
||||||
|
VALUES (?, 100, ?, ?, 'example.invalid:443', 3600, ?, '{}', '[]', '[]')`
|
||||||
|
require.NoError(t, DB.Exec(insert, 91, "legacy-command", model.TaskTypeCommand, model.ServiceCoverIgnoreAll).Error)
|
||||||
|
require.NoError(t, DB.Exec(insert, 92, "legacy-apply-config", model.TaskTypeApplyConfig, model.ServiceCoverIgnoreAll).Error)
|
||||||
|
require.NoError(t, DB.Exec(insert, 93, "valid-probe", model.TaskTypeTCPPing, model.ServiceCoverIgnoreAll).Error)
|
||||||
|
|
||||||
|
require.NoError(t, ss.loadServiceHistory())
|
||||||
|
_, commandLoaded := ss.Get(91)
|
||||||
|
_, applyConfigLoaded := ss.Get(92)
|
||||||
|
valid, validLoaded := ss.Get(93)
|
||||||
|
require.False(t, commandLoaded)
|
||||||
|
require.False(t, applyConfigLoaded)
|
||||||
|
require.True(t, validLoaded)
|
||||||
|
require.Equal(t, uint8(model.TaskTypeTCPPing), valid.Type)
|
||||||
|
}
|
||||||
@@ -209,7 +209,15 @@ func (ss *ServiceSentinel) loadServiceHistory() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validServices := services[:0]
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
|
if err := model.ValidateServiceMonitorType(uint64(service.Type)); err != nil {
|
||||||
|
// Existing databases may contain values written before Service.Type was
|
||||||
|
// constrained. Quarantine them in the database for operator review, but
|
||||||
|
// never register a cron job that could dispatch a privileged Agent task.
|
||||||
|
log.Printf("NEZHA>> quarantining service %d: %v", service.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
task := service
|
task := service
|
||||||
// 通过cron定时将服务监控任务传递给任务调度管道
|
// 通过cron定时将服务监控任务传递给任务调度管道
|
||||||
service.CronJobID, err = CronShared.AddFunc(task.CronSpec(), func() {
|
service.CronJobID, err = CronShared.AddFunc(task.CronSpec(), func() {
|
||||||
@@ -222,7 +230,9 @@ func (ss *ServiceSentinel) loadServiceHistory() error {
|
|||||||
ss.serviceCurrentStatusData[service.ID] = new(serviceTaskStatus)
|
ss.serviceCurrentStatusData[service.ID] = new(serviceTaskStatus)
|
||||||
ss.serviceCurrentStatusData[service.ID].result = make([]*pb.TaskResult, 0, _CurrentStatusSize)
|
ss.serviceCurrentStatusData[service.ID].result = make([]*pb.TaskResult, 0, _CurrentStatusSize)
|
||||||
ss.serviceStatusToday[service.ID] = &_TodayStatsOfService{}
|
ss.serviceStatusToday[service.ID] = &_TodayStatsOfService{}
|
||||||
|
validServices = append(validServices, service)
|
||||||
}
|
}
|
||||||
|
services = validServices
|
||||||
ss.serviceList = services
|
ss.serviceList = services
|
||||||
sortServices(ss.serviceList)
|
sortServices(ss.serviceList)
|
||||||
|
|
||||||
@@ -339,6 +349,13 @@ func (ss *ServiceSentinel) loadTodayStats(today time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ss *ServiceSentinel) Update(m *model.Service) error {
|
func (ss *ServiceSentinel) Update(m *model.Service) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("service is nil")
|
||||||
|
}
|
||||||
|
if err := model.ValidateServiceMonitorType(uint64(m.Type)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ss.serviceResponseDataStoreLock.Lock()
|
ss.serviceResponseDataStoreLock.Lock()
|
||||||
defer ss.serviceResponseDataStoreLock.Unlock()
|
defer ss.serviceResponseDataStoreLock.Unlock()
|
||||||
ss.monthlyStatusLock.Lock()
|
ss.monthlyStatusLock.Lock()
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ func initUser() {
|
|||||||
var users []model.User
|
var users []model.User
|
||||||
DB.Find(&users)
|
DB.Find(&users)
|
||||||
|
|
||||||
// for backward compatibility
|
// Backward compatibility for pre-user-scoped Agents. AgentSecretKey is a
|
||||||
|
// deployment-wide migration/master credential, so user 0 is intentionally
|
||||||
|
// not tenant-scoped. Do not remove this mapping until every legacy Agent has
|
||||||
|
// rotated to a per-user/per-Agent credential; doing so would disconnect them.
|
||||||
UserInfoMap[0] = model.UserInfo{
|
UserInfoMap[0] = model.UserInfo{
|
||||||
Role: model.RoleAdmin,
|
Role: model.RoleAdmin,
|
||||||
AgentSecret: Conf.AgentSecretKey,
|
AgentSecret: Conf.AgentSecretKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user