mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
104 lines
3.5 KiB
Go
104 lines
3.5 KiB
Go
package controller
|
|
|
|
import (
|
|
"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"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/pkg/i18n"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
func setupCronUpdateOwnerUIDFixture(t *testing.T) {
|
|
t.Helper()
|
|
|
|
originalCache := singleton.Cache
|
|
originalLoc := singleton.Loc
|
|
originalLocalizer := singleton.Localizer
|
|
originalServer := singleton.ServerShared
|
|
|
|
singleton.Loc = time.UTC
|
|
singleton.Cache = cache.New(time.Minute, time.Minute)
|
|
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
|
|
|
|
sc := singleton.NewEmptyServerClassForTest()
|
|
for _, id := range []uint64{1, 2} {
|
|
s := &model.Server{}
|
|
s.ID = id
|
|
s.SetUserID(100)
|
|
sc.InsertForTest(s)
|
|
}
|
|
adminServer := &model.Server{}
|
|
adminServer.ID = 5
|
|
adminServer.SetUserID(200)
|
|
sc.InsertForTest(adminServer)
|
|
singleton.ServerShared = sc
|
|
|
|
t.Cleanup(func() {
|
|
singleton.Cache = originalCache
|
|
singleton.Loc = originalLoc
|
|
singleton.Localizer = originalLocalizer
|
|
singleton.ServerShared = originalServer
|
|
})
|
|
}
|
|
|
|
func newCtxAsAdminWithLimitedPAT(t *testing.T, callerUID uint64, whitelist []uint64) *gin.Context {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{
|
|
Common: model.Common{ID: callerUID},
|
|
Role: model.RoleAdmin,
|
|
})
|
|
tok := &model.APIToken{ID: 33, UserID: callerUID}
|
|
tok.SetServerIDs(whitelist)
|
|
c.Set(apiTokenCtxKey, tok)
|
|
c.Set(model.CtxKeyAPIToken, tok)
|
|
return c
|
|
}
|
|
|
|
// Threat: updateCron currently calls
|
|
//
|
|
// rejectImplicitCoverForLimitedPAT(c, cf.Cover, cf.Servers)
|
|
//
|
|
// which internally resolves the owner UID via getUid(c) (caller id). When an
|
|
// admin uses a server-limited PAT to flip a *foreign* cron to CoverAll with
|
|
// an under-specified deny-list, the helper validates the deny-list against
|
|
// the admin's own servers, not the cron owner's. The admin's only owned
|
|
// server is 5 and it's already in the whitelist, so the guard returns nil
|
|
// even though CronTrigger will fan out to the cron owner's servers 1 and 2
|
|
// — both outside the PAT whitelist. The correct owner is the existing
|
|
// cron.UserID, not the caller. This test calls the helper directly with the
|
|
// cron owner uid and pins the safe behaviour.
|
|
func TestRejectImplicitCoverForLimitedPAT_RejectsCallerWhenCronOwnerHasUncoveredServers(t *testing.T) {
|
|
setupCronUpdateOwnerUIDFixture(t)
|
|
|
|
c := newCtxAsAdminWithLimitedPAT(t, 200, []uint64{5})
|
|
|
|
const cronOwnerUID = uint64(100)
|
|
err := rejectImplicitCoverForLimitedPATWithOwner(c, model.CronCoverAll, nil, cronOwnerUID)
|
|
require.Error(t, err,
|
|
"limited PAT must NOT pass cover-all check when the cron owner has servers outside the PAT whitelist; caller uid must not be used as owner")
|
|
assert.Contains(t, err.Error(), "permission denied")
|
|
}
|
|
|
|
// Pins the safe path: same helper, but caller uid happens to equal the cron
|
|
// owner and the deny-list covers every owner-visible server outside the
|
|
// whitelist. Prevents regressing the helper into a blanket "always deny
|
|
// limited PAT" form.
|
|
func TestRejectImplicitCoverForLimitedPAT_AllowsCallerWhenDenyListCoversEveryOwnerServerOutsideWhitelist(t *testing.T) {
|
|
setupCronUpdateOwnerUIDFixture(t)
|
|
|
|
c := newCtxAsAdminWithLimitedPAT(t, 100, []uint64{1})
|
|
|
|
err := rejectImplicitCoverForLimitedPATWithOwner(c, model.CronCoverAll, []uint64{2}, 100)
|
|
require.NoError(t, err,
|
|
"deny-list [2] covers every server uid 100 owns outside the PAT whitelist [1]; must pass")
|
|
}
|