mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user