mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
fix(cron): switch manual trigger to POST to defeat CSRF
GHSA-8qhj-4f8c-j8qg: GET /api/v1/cron/:id/manual changed shared state on the agent stream, and the JWT cookie is SameSite=Lax so a victim's browser would send the cookie on a top-level cross-site GET. An attacker could trick a logged-in user into firing any of their own cron commands. Switch the route to POST: SameSite=Lax cookies are not sent on cross- site POST, closing the CSRF window without introducing a new token. Tests: - TestCronManualTriggerRejectsCrossSiteGET locks in that GET no longer resolves. - TestCronManualTriggerAcceptsSameSitePOST locks in the legitimate POST still works. Frontend (admin-frontend) updated in a follow-up commit. Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
@@ -135,7 +135,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
auth.GET("/cron", listHandler(listCron))
|
auth.GET("/cron", listHandler(listCron))
|
||||||
auth.POST("/cron", commonHandler(createCron))
|
auth.POST("/cron", commonHandler(createCron))
|
||||||
auth.PATCH("/cron/:id", commonHandler(updateCron))
|
auth.PATCH("/cron/:id", commonHandler(updateCron))
|
||||||
auth.GET("/cron/:id/manual", commonHandler(manualTriggerCron))
|
auth.POST("/cron/:id/manual", commonHandler(manualTriggerCron))
|
||||||
auth.POST("/batch-delete/cron", commonHandler(batchDeleteCron))
|
auth.POST("/batch-delete/cron", commonHandler(batchDeleteCron))
|
||||||
|
|
||||||
auth.GET("/ddns", listHandler(listDDNS))
|
auth.GET("/ddns", listHandler(listDDNS))
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ func updateCron(c *gin.Context) (any, error) {
|
|||||||
// @param id path uint true "Task ID"
|
// @param id path uint true "Task ID"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} model.CommonResponse[any]
|
// @Success 200 {object} model.CommonResponse[any]
|
||||||
// @Router /cron/{id}/manual [get]
|
// @Router /cron/{id}/manual [post]
|
||||||
func manualTriggerCron(c *gin.Context) (any, error) {
|
func manualTriggerCron(c *gin.Context) (any, error) {
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupCronManualTriggerFixture(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
originalDB := singleton.DB
|
||||||
|
originalCache := singleton.Cache
|
||||||
|
originalLoc := singleton.Loc
|
||||||
|
originalLocalizer := singleton.Localizer
|
||||||
|
originalCron := singleton.CronShared
|
||||||
|
originalServer := singleton.ServerShared
|
||||||
|
originalUserInfo := singleton.UserInfoMap
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.AutoMigrate(&model.Cron{}, &model.Server{}, &model.User{}))
|
||||||
|
|
||||||
|
singleton.DB = db
|
||||||
|
singleton.Loc = time.UTC
|
||||||
|
singleton.Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
|
||||||
|
singleton.CronShared = singleton.NewCronClass()
|
||||||
|
singleton.ServerShared = singleton.NewServerClass()
|
||||||
|
singleton.UserLock.Lock()
|
||||||
|
singleton.UserInfoMap = map[uint64]model.UserInfo{100: {Role: model.RoleMember}}
|
||||||
|
singleton.UserLock.Unlock()
|
||||||
|
|
||||||
|
cr := &model.Cron{
|
||||||
|
Common: model.Common{ID: 7, UserID: 100},
|
||||||
|
Name: "victim cron",
|
||||||
|
TaskType: model.CronTypeCronTask,
|
||||||
|
Command: "echo csrf-poc",
|
||||||
|
Cover: model.CronCoverIgnoreAll,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(cr).Error)
|
||||||
|
singleton.CronShared.Update(cr)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
singleton.DB = originalDB
|
||||||
|
singleton.Cache = originalCache
|
||||||
|
singleton.Loc = originalLoc
|
||||||
|
singleton.Localizer = originalLocalizer
|
||||||
|
singleton.CronShared = originalCron
|
||||||
|
singleton.ServerShared = originalServer
|
||||||
|
singleton.UserLock.Lock()
|
||||||
|
singleton.UserInfoMap = originalUserInfo
|
||||||
|
singleton.UserLock.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCronManualRouter() *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{
|
||||||
|
Common: model.Common{ID: 100},
|
||||||
|
Role: model.RoleMember,
|
||||||
|
})
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
r.POST("/api/v1/cron/:id/manual", commonHandler(manualTriggerCron))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronManualTriggerRejectsCrossSiteGET(t *testing.T) {
|
||||||
|
setupCronManualTriggerFixture(t)
|
||||||
|
r := newCronManualRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/cron/7/manual", nil)
|
||||||
|
req.Header.Set("Origin", "https://attacker.example")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, "manual trigger must reject cross-site GET — the route is POST-only after the CSRF fix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronManualTriggerAcceptsSameSitePOST(t *testing.T) {
|
||||||
|
setupCronManualTriggerFixture(t)
|
||||||
|
r := newCronManualRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/cron/7/manual", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
|
||||||
|
assert.True(t, success, "owner POST must succeed: error=%q", errMsg)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user