mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50: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>
227 lines
7.3 KiB
Go
227 lines
7.3 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/metadata"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
pb "github.com/nezhahq/nezha/proto"
|
|
"github.com/nezhahq/nezha/service/rpc"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// killSwitchStream is a minimal RequestTask stream that just records sent
|
|
// tasks; it never replies. CallAgent under this stream blocks until the
|
|
// kill switch wakes it up, which is exactly the behaviour these tests
|
|
// pin down.
|
|
type killSwitchStream struct {
|
|
sent chan *pb.Task
|
|
}
|
|
|
|
func newKillSwitchStream() *killSwitchStream {
|
|
return &killSwitchStream{sent: make(chan *pb.Task, 4)}
|
|
}
|
|
|
|
func (s *killSwitchStream) Send(t *pb.Task) error { s.sent <- t; return nil }
|
|
func (s *killSwitchStream) Recv() (*pb.TaskResult, error) { return nil, context.Canceled }
|
|
func (s *killSwitchStream) SetHeader(metadata.MD) error { return nil }
|
|
func (s *killSwitchStream) SendHeader(metadata.MD) error { return nil }
|
|
func (s *killSwitchStream) SetTrailer(metadata.MD) {}
|
|
func (s *killSwitchStream) Context() context.Context { return context.Background() }
|
|
func (s *killSwitchStream) SendMsg(any) error { return nil }
|
|
func (s *killSwitchStream) RecvMsg(any) error { return context.Canceled }
|
|
|
|
func TestRevalidateTransferEntry_BlocksWhenMCPDisabled(t *testing.T) {
|
|
cleanup, uid := setupMCPTest(t)
|
|
defer cleanup()
|
|
singleton.Conf.SetMCPEnabled(false)
|
|
|
|
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
|
entry := &transferEntry{
|
|
UserID: uid,
|
|
TokenID: tok.ID,
|
|
ServerID: 7,
|
|
Path: "/srv/file",
|
|
Direction: transferDirDownload,
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
}
|
|
|
|
err := revalidateTransferEntry(entry)
|
|
require.Error(t, err, "revalidate must reject when EnableMCP=false")
|
|
require.Contains(t, err.Error(), "MCP is disabled",
|
|
"error message must surface kill switch reason, not look like a transient agent fault")
|
|
}
|
|
|
|
func TestPurgeTransferEntries_DropsMintedTokens(t *testing.T) {
|
|
cleanup, uid := setupMCPTest(t)
|
|
defer cleanup()
|
|
|
|
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
|
for i := 0; i < 3; i++ {
|
|
_, err := mintTransferToken(transferEntry{
|
|
UserID: uid,
|
|
TokenID: tok.ID,
|
|
ServerID: 7,
|
|
Path: "/srv/blob",
|
|
Direction: transferDirDownload,
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
purged := PurgeTransferEntries()
|
|
require.GreaterOrEqual(t, purged, 3, "all minted entries must be dropped")
|
|
|
|
count := 0
|
|
transferEntries.Range(func(_, _ any) bool { count++; return true })
|
|
require.Equal(t, 0, count, "transferEntries must be empty after purge")
|
|
}
|
|
|
|
func TestRevokeStreamsForPurpose_OnlyTouchesMatchingPurpose(t *testing.T) {
|
|
h := rpc.NewNezhaHandler()
|
|
h.CreateStreamWithPurpose("legacy-1", 0, 1, rpc.PurposeLegacy)
|
|
h.CreateStreamWithPurpose("mcp-1", 0, 1, rpc.PurposeMCPTransfer)
|
|
h.CreateStreamWithPurpose("mcp-2", 0, 2, rpc.PurposeMCPTransfer)
|
|
|
|
revoked := h.RevokeStreamsForPurpose(rpc.PurposeMCPTransfer)
|
|
require.Equal(t, 2, revoked, "kill switch must take down both MCP streams")
|
|
|
|
_, legacyErr := h.GetStream("legacy-1")
|
|
require.NoError(t, legacyErr,
|
|
"legacy purpose streams (terminal/fm/nat) must NOT be revoked by the MCP kill switch")
|
|
_, mcp1Err := h.GetStream("mcp-1")
|
|
require.Error(t, mcp1Err, "mcp-1 must be gone after revoke")
|
|
_, mcp2Err := h.GetStream("mcp-2")
|
|
require.Error(t, mcp2Err, "mcp-2 must be gone after revoke")
|
|
}
|
|
|
|
func TestCancelAllMCPInflight_UnblocksCallAgent(t *testing.T) {
|
|
stream := newKillSwitchStream()
|
|
original := singleton.ServerShared
|
|
sc := singleton.NewEmptyServerClassForTest()
|
|
srv := &model.Server{}
|
|
srv.ID = 88
|
|
srv.SetTaskStream(stream)
|
|
sc.InsertForTest(srv)
|
|
singleton.ServerShared = sc
|
|
t.Cleanup(func() { singleton.ServerShared = original })
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := rpc.CallAgent(context.Background(), 88, model.TaskTypeExec,
|
|
model.ExecRequest{Cmd: "sleep"}, 30*time.Second)
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case <-stream.sent:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("CallAgent never reached stream.Send within 1s")
|
|
}
|
|
|
|
rpc.CancelAllMCPInflight()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.ErrorIs(t, err, rpc.ErrMCPDisabled,
|
|
"CallAgent must surface ErrMCPDisabled when kill switch fires; got %v", err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("CallAgent did not return after CancelAllMCPInflight; kill switch is broken")
|
|
}
|
|
}
|
|
|
|
func TestUpdateConfig_DisablingMCPInvokesKillSwitch(t *testing.T) {
|
|
cleanup, uid := setupMCPTest(t)
|
|
defer cleanup()
|
|
installTestConfig(t)
|
|
singleton.Conf.SetMCPEnabled(true)
|
|
|
|
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
|
_, err := mintTransferToken(transferEntry{
|
|
UserID: uid,
|
|
TokenID: tok.ID,
|
|
ServerID: 7,
|
|
Path: "/srv/blob",
|
|
Direction: transferDirDownload,
|
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
originalHandler := rpc.NezhaHandlerSingleton
|
|
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
|
|
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
|
|
rpc.NezhaHandlerSingleton.CreateStreamWithPurpose("mcp-active", 0, 7, rpc.PurposeMCPTransfer)
|
|
|
|
stream := newKillSwitchStream()
|
|
sc := singleton.NewEmptyServerClassForTest()
|
|
srv := &model.Server{}
|
|
srv.ID = 7
|
|
srv.SetTaskStream(stream)
|
|
sc.InsertForTest(srv)
|
|
originalShared := singleton.ServerShared
|
|
singleton.ServerShared = sc
|
|
t.Cleanup(func() { singleton.ServerShared = originalShared })
|
|
|
|
rpcDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := rpc.CallAgent(context.Background(), 7, model.TaskTypeFsRead,
|
|
model.FsReadRequest{Path: "/x"}, 30*time.Second)
|
|
rpcDone <- err
|
|
}()
|
|
select {
|
|
case <-stream.sent:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("background CallAgent never reached the stream")
|
|
}
|
|
|
|
origTemplates := singleton.FrontendTemplates
|
|
singleton.FrontendTemplates = []model.FrontendTemplate{{Path: "user-dist", IsAdmin: false}}
|
|
defer func() { singleton.FrontendTemplates = origTemplates }()
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
setAuthUser(c, uid, model.RoleAdmin)
|
|
c.Next()
|
|
})
|
|
r.PATCH("/api/v1/setting", commonHandler(updateConfig))
|
|
settingBody := map[string]any{
|
|
"site_name": "test",
|
|
"language": "en_US",
|
|
"user_template": "user-dist",
|
|
"enable_mcp": false,
|
|
}
|
|
raw, _ := json.Marshal(settingBody)
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(raw))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
|
|
require.True(t, success, "PATCH /setting must succeed: %s", errMsg)
|
|
require.False(t, singleton.Conf.EnableMCP, "config must reflect kill switch state")
|
|
|
|
count := 0
|
|
transferEntries.Range(func(_, _ any) bool { count++; return true })
|
|
require.Equal(t, 0, count, "unconsumed transfer URLs must be purged")
|
|
|
|
_, streamErr := rpc.NezhaHandlerSingleton.GetStream("mcp-active")
|
|
require.Error(t, streamErr, "active MCP IOStream must be revoked")
|
|
|
|
select {
|
|
case err := <-rpcDone:
|
|
require.True(t, errors.Is(err, rpc.ErrMCPDisabled),
|
|
"in-flight CallAgent must wake up with ErrMCPDisabled, got %v", err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("in-flight CallAgent did not wake up after kill switch")
|
|
}
|
|
}
|