Files
nezha_domains/cmd/dashboard/controller/tenant_isolation_test.go
T
2026-07-20 04:32:16 +00:00

319 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
// 通用租户隔离测试夹具:在 in-memory DB 上挂载所需 model 并塞两个用户,
// 用户 10member)和用户 999foreign owner)。
//
// 每个测试在两条路径上验证 member 不能跨租户:
// - create 时即使请求体里包含 user_id 字段也不会越权
// - update / delete 时不会改写或读取到 foreign owner 的资源
func setupTenancyTest(t *testing.T) func() {
t.Helper()
originalDB := singleton.DB
originalLocalizer := singleton.Localizer
originalServer := singleton.ServerShared
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.User{},
&model.Cron{},
&model.DDNSProfile{},
&model.Notification{},
&model.AlertRule{},
&model.NotificationGroup{},
))
originalDDNS := singleton.DDNSShared
originalNotif := singleton.NotificationShared
singleton.DB = db
singleton.ServerShared = singleton.NewEmptyServerClassForTest()
singleton.DDNSShared = singleton.NewEmptyDDNSClassForTest()
singleton.NotificationShared = singleton.NewEmptyNotificationClassForTest()
return func() {
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Localizer = originalLocalizer
singleton.ServerShared = originalServer
singleton.DDNSShared = originalDDNS
singleton.NotificationShared = originalNotif
}
}
func ctxAs(uid uint64, role model.Role) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", nil)
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: role})
return c
}
func ctxAsMemberWithBody(uid uint64, body any) *gin.Context {
c := ctxAs(uid, model.RoleMember)
b, _ := json.Marshal(body)
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(b))
c.Request.Header.Set("Content-Type", "application/json")
return c
}
// 设计说明:create 路径的"手工 user_id 注入"防护通过两点联合保证:
// 1. CronForm/DDNSForm/NotificationForm 等 form struct 不嵌入 Common
// 绑定时不会 unmarshal "user_id" 字段
// 2. handler 第一行 `xxx.UserID = getUid(c)` 显式覆盖
// 因为 create 路径还会依赖 ServerShared / Localizer 等外部 singleton
// 在单元测试中难以无副作用地完整运行;改用代码静态约束:在 form_no_userid_test.go
// 里用 reflect 验证所有 *Form 结构无 UserID 字段(next step)。
// 这里只测真正的所有权防线:update / delete。
// ---------- Cron ----------
func TestTenancy_UpdateCron_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.Cron{
Common: model.Common{UserID: 999},
Name: "foreign-cron",
TaskType: model.CronTypeCronTask,
Scheduler: "@every 5m",
Command: "echo",
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "hijacked",
"task_type": model.CronTypeCronTask,
"scheduler": "@every 1m",
"command": "echo pwned",
"servers": []uint64{},
"cover": model.CronCoverAll,
})
c.Params = gin.Params{{Key: "id", Value: itoa(foreign.ID)}}
_, err := updateCron(c)
require.Error(t, err, "member 10 must not be able to update foreign-owned cron")
var after model.Cron
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error)
require.Equal(t, "foreign-cron", after.Name, "foreign cron must not be modified")
require.Equal(t, uint64(999), after.UserID, "ownership must remain")
}
// ---------- DDNS ----------
func TestTenancy_CreateDDNS_InjectedUserIDIgnored(t *testing.T) {
defer setupTenancyTest(t)()
body := map[string]any{
"name": "evil-ddns",
"provider": "webhook",
"access_id": "x",
"access_secret": "y",
"webhook_url": "http://127.0.0.1/",
"webhook_method": "GET",
"webhook_request_type": "json",
"webhook_request_body": "",
"webhook_headers": "",
"user_id": 999, // attacker
}
c := ctxAsMemberWithBody(10, body)
_, err := createDDNS(c)
if err == nil {
var stored model.DDNSProfile
require.NoError(t, singleton.DB.First(&stored, "name = ?", "evil-ddns").Error)
require.Equal(t, uint64(10), stored.UserID,
"createDDNS must overwrite UserID with caller")
}
}
func TestTenancy_UpdateDDNS_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.DDNSProfile{
Common: model.Common{UserID: 999},
Name: "foreign-ddns",
Provider: "webhook",
AccessID: "x",
AccessSecret: "y",
WebhookURL: "http://127.0.0.1/",
WebhookMethod: 1,
WebhookRequestType: 1,
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "hijacked",
"provider": "webhook",
"access_id": "x",
"access_secret": "y",
"webhook_url": "http://attacker/",
"webhook_method": "GET",
"webhook_request_type": "json",
})
c.Params = gin.Params{{Key: "id", Value: itoa(foreign.ID)}}
_, err := updateDDNS(c)
require.Error(t, err, "member must not be able to update foreign-owned DDNS")
var after model.DDNSProfile
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error)
require.Equal(t, "foreign-ddns", after.Name, "foreign DDNS must not be modified")
require.Equal(t, "http://127.0.0.1/", after.WebhookURL, "webhook URL must not be hijacked")
}
func TestTenancy_DeleteDDNS_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.DDNSProfile{
Common: model.Common{UserID: 999},
Name: "foreign-ddns-del",
Provider: "webhook",
WebhookURL: "http://127.0.0.1/",
WebhookMethod: 1,
WebhookRequestType: 1,
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
singleton.DDNSShared.InsertForTest(&foreign)
c := ctxAsMemberWithBody(10, []uint64{foreign.ID})
_, err := batchDeleteDDNS(c)
require.Error(t, err, "member must not be able to batch-delete foreign DDNS")
var after model.DDNSProfile
require.NoErrorf(t, singleton.DB.First(&after, foreign.ID).Error,
"foreign DDNS must still exist after member's failed batch-delete (handler err=%v)", err)
}
// ---------- Notification ----------
func TestTenancy_UpdateNotification_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.Notification{
Common: model.Common{UserID: 999},
Name: "foreign-notify",
URL: "http://127.0.0.1/",
RequestMethod: 1,
RequestType: 1,
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "hijacked",
"url": "http://attacker/",
"request_method": 1,
"request_type": 1,
})
c.Params = gin.Params{{Key: "id", Value: itoa(foreign.ID)}}
_, err := updateNotification(c)
require.Error(t, err)
var after model.Notification
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error)
require.Equal(t, "http://127.0.0.1/", after.URL)
}
// ---------- NotificationGroup ----------
func TestTenancy_UpdateNotificationGroup_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.NotificationGroup{
Common: model.Common{UserID: 999},
Name: "foreign-ng",
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "hijacked",
"notifications": []uint64{},
})
c.Params = gin.Params{{Key: "id", Value: itoa(foreign.ID)}}
_, err := updateNotificationGroup(c)
require.Error(t, err)
var after model.NotificationGroup
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error)
require.Equal(t, "foreign-ng", after.Name)
}
// ---------- AlertRule ----------
func TestTenancy_UpdateAlertRule_ForeignOwnerRejected(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.AlertRule{
Common: model.Common{UserID: 999},
Name: "foreign-rule",
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, map[string]any{
"name": "hijacked",
})
c.Params = gin.Params{{Key: "id", Value: itoa(foreign.ID)}}
_, err := updateAlertRule(c)
require.Error(t, err)
var after model.AlertRule
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error)
require.Equal(t, "foreign-rule", after.Name)
}
func TestTenancy_BatchDeleteAlertRule_ForeignOwnerSilentlySkipped(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.AlertRule{Common: model.Common{UserID: 999}, Name: "foreign-rule"}
require.NoError(t, singleton.DB.Create(&foreign).Error)
c := ctxAsMemberWithBody(10, []uint64{foreign.ID})
_, err := batchDeleteAlertRule(c)
_ = err
var after model.AlertRule
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error,
"member's batch-delete must not be able to remove foreign alert rule")
require.Equal(t, uint64(999), after.UserID)
}
// Cron batch-delete 的所有权保护与 updateCron 共用 cr.HasPermission 检查路径
// cron.go:127 vs cron.go:207),updateCron 用例已经覆盖该路径。这里不复测
// 是因为 batchDeleteCron 调 CronShared.CheckPermission,需要完整 CronShared
// 在内存中注册,会让单测 fixture 显著膨胀,性价比低。
// ---------- Notification batch-delete ----------
func TestTenancy_BatchDeleteNotification_ForeignOwnerSilentlySkipped(t *testing.T) {
defer setupTenancyTest(t)()
foreign := model.Notification{
Common: model.Common{UserID: 999},
Name: "foreign-notify-del",
URL: "http://127.0.0.1/",
}
require.NoError(t, singleton.DB.Create(&foreign).Error)
singleton.NotificationShared.InsertForTest(&foreign)
c := ctxAsMemberWithBody(10, []uint64{foreign.ID})
_, _ = batchDeleteNotification(c)
var after model.Notification
require.NoError(t, singleton.DB.First(&after, foreign.ID).Error,
"member must not be able to batch-delete foreign notification")
}