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>
113 lines
3.3 KiB
Go
113 lines
3.3 KiB
Go
package model
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newMigrationTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&APIToken{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestNormalizeIncomingScope_RewritesReadOnlyMCPVariants(t *testing.T) {
|
|
cases := map[string]string{
|
|
"mcp:fs:read": ScopeServerRead,
|
|
"mcp:server:read": ScopeServerRead,
|
|
"mcp:server:exec": ScopeServerExec,
|
|
}
|
|
for in, want := range cases {
|
|
got, ok := NormalizeIncomingScope(in)
|
|
if !ok {
|
|
t.Fatalf("NormalizeIncomingScope(%q) ok=false; legacy read/exec must remain creatable", in)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("NormalizeIncomingScope(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeIncomingScope_RejectsDangerousLegacyVariants(t *testing.T) {
|
|
for _, in := range []string{"mcp:fs:write", "mcp:fs:delete", "mcp:*", "mcp:unknown"} {
|
|
if got, ok := NormalizeIncomingScope(in); ok {
|
|
t.Errorf("NormalizeIncomingScope(%q) = (%q, true); legacy write/delete/wildcard must be rejected", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeIncomingScope_PassesThroughNezhaScopes(t *testing.T) {
|
|
got, ok := NormalizeIncomingScope(ScopeServerWrite)
|
|
if !ok || got != ScopeServerWrite {
|
|
t.Fatalf("nezha:* must pass through unchanged: got (%q, %v)", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyMCPScopes_RewritesReadOnlyAndDropsDangerous(t *testing.T) {
|
|
db := newMigrationTestDB(t)
|
|
|
|
tokens := []APIToken{
|
|
{UserID: 1, Name: "read-only", TokenHash: HashAPIToken("nzp_a"), ScopesCSV: "mcp:fs:read"},
|
|
{UserID: 2, Name: "mixed", TokenHash: HashAPIToken("nzp_b"), ScopesCSV: "mcp:server:read,mcp:fs:write"},
|
|
{UserID: 3, Name: "purely-dangerous", TokenHash: HashAPIToken("nzp_c"), ScopesCSV: "mcp:fs:write,mcp:*"},
|
|
{UserID: 4, Name: "modern", TokenHash: HashAPIToken("nzp_d"), ScopesCSV: ScopeServerRead},
|
|
}
|
|
for i := range tokens {
|
|
if err := db.Create(&tokens[i]).Error; err != nil {
|
|
t.Fatalf("seed token %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
rewritten, deleted, err := MigrateLegacyMCPScopes(db)
|
|
if err != nil {
|
|
t.Fatalf("MigrateLegacyMCPScopes: %v", err)
|
|
}
|
|
if rewritten < 2 {
|
|
t.Fatalf("expected >=2 rewrites (read-only + mixed); got %d", rewritten)
|
|
}
|
|
if deleted != 1 {
|
|
t.Fatalf("expected 1 deleted token (purely-dangerous); got %d", deleted)
|
|
}
|
|
|
|
var got []APIToken
|
|
if err := db.Order("id ASC").Find(&got).Error; err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 surviving tokens; got %d", len(got))
|
|
}
|
|
for _, tok := range got {
|
|
if strings.Contains(tok.ScopesCSV, "mcp:") {
|
|
t.Fatalf("token %d still carries legacy scope after migration: %q", tok.ID, tok.ScopesCSV)
|
|
}
|
|
}
|
|
|
|
for _, tok := range got {
|
|
switch tok.UserID {
|
|
case 1:
|
|
if tok.ScopesCSV != ScopeServerRead {
|
|
t.Fatalf("uid=1 expected %q, got %q", ScopeServerRead, tok.ScopesCSV)
|
|
}
|
|
case 2:
|
|
if tok.ScopesCSV != ScopeServerRead {
|
|
t.Fatalf("uid=2 should keep only the safe read scope after drop; got %q", tok.ScopesCSV)
|
|
}
|
|
case 4:
|
|
if tok.ScopesCSV != ScopeServerRead {
|
|
t.Fatalf("uid=4 already-modern token must be untouched; got %q", tok.ScopesCSV)
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected surviving token uid=%d", tok.UserID)
|
|
}
|
|
}
|
|
}
|