mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-20 12:55:00 +03:00
fix(auth): add node-admin token scope for panel updates (#6666)
* feat(auth): add node-admin API token scope Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel. * style(docs): format API scope table * fix(auth): preserve mTLS node-sync semantics Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections. * chore(ci): rerun flaky checks --------- Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
This commit is contained in:
@@ -35,30 +35,34 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
|
||||
}
|
||||
|
||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||
// A verified client certificate (a completed mTLS handshake) authenticates
|
||||
// the caller, equivalent to a valid bearer token. api_authed must be set so
|
||||
// the CSRF middleware lets cert-authed mutations through.
|
||||
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
|
||||
setAuthenticated := func(scope string) {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
c.Set("api_authed", true)
|
||||
c.Set("api_token_scope", model.ApiScopeNodeSync)
|
||||
c.Set("api_token_scope", scope)
|
||||
}
|
||||
matchedScope := ""
|
||||
if after, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
|
||||
if row, matched := a.apiTokenService.MatchToken(after); matched {
|
||||
matchedScope = row.Scope
|
||||
}
|
||||
}
|
||||
// Verified mTLS keeps its historical node-sync identity. Only an explicit
|
||||
// node-admin bearer elevates it to software-update authority.
|
||||
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
|
||||
scope := model.ApiScopeNodeSync
|
||||
if matchedScope == model.ApiScopeNodeAdmin {
|
||||
scope = model.ApiScopeNodeAdmin
|
||||
}
|
||||
setAuthenticated(scope)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
tok := after
|
||||
if row, ok := a.apiTokenService.MatchToken(tok); ok {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
c.Set("api_authed", true)
|
||||
c.Set("api_token_scope", row.Scope)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if matchedScope != "" {
|
||||
setAuthenticated(matchedScope)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !session.IsLogin(c) {
|
||||
// A presented Bearer token is not an anonymous scan: return 401 so
|
||||
@@ -120,7 +124,20 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/hosts/list": {http.MethodGet: {}},
|
||||
}
|
||||
|
||||
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
|
||||
var nodeAdminScopeAllow = map[string]map[string]struct{}{
|
||||
"/server/updatePanel": {http.MethodPost: {}},
|
||||
}
|
||||
|
||||
func scopeAllows(allow map[string]map[string]struct{}, path, method string) bool {
|
||||
methods, exists := allow[path]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
_, exists = methods[method]
|
||||
return exists
|
||||
}
|
||||
|
||||
// enforceTokenScope applies explicit allowlists to restricted API tokens.
|
||||
// Admin tokens and session-login users retain their existing behavior.
|
||||
func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
scopeVal, ok := c.Get("api_token_scope")
|
||||
@@ -147,11 +164,14 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case model.ApiScopeNodeSync:
|
||||
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
|
||||
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
case model.ApiScopeNodeAdmin:
|
||||
if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) || scopeAllows(nodeAdminScopeAllow, rel, c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
default:
|
||||
deny()
|
||||
|
||||
@@ -107,6 +107,43 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelUpdateRequiresNodeAdminScope(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scope string
|
||||
wantCode int
|
||||
wantBody string
|
||||
}{
|
||||
{"node sync denied", model.ApiScopeNodeSync, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
|
||||
{"node admin allowed", model.ApiScopeNodeAdmin, http.StatusOK, `{"reached":true}`},
|
||||
{"monitor denied", model.ApiScopeMonitor, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plaintext := tc.name + "-token"
|
||||
if err := database.GetDB().Create(&model.ApiToken{
|
||||
Name: tc.name,
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
Scope: tc.scope,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.wantCode || w.Body.String() != tc.wantBody {
|
||||
t.Fatalf("response = %d %s, want %d %s", w.Code, w.Body.String(), tc.wantCode, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
|
||||
// as node-sync rather than bypassing scope checks as admin.
|
||||
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
||||
@@ -135,6 +172,48 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
for _, legacyScope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync} {
|
||||
plaintext := "mtls-" + legacyScope + "-token"
|
||||
if err := database.GetDB().Create(&model.ApiToken{
|
||||
Name: "mtls-" + legacyScope,
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
Scope: legacyScope,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed %s token: %v", legacyScope, err)
|
||||
}
|
||||
legacy := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
|
||||
legacy.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
legacy.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, legacy)
|
||||
if w.Code != http.StatusOK || w.Body.String() != `{"api_authed":true,"scope":"node-sync"}` {
|
||||
t.Fatalf("mTLS %s response = %d %s, want preserved node-sync scope", legacyScope, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
const plaintext = "mtls-node-admin-token"
|
||||
if err := database.GetDB().Create(&model.ApiToken{
|
||||
Name: "mtls-node-admin",
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
Scope: model.ApiScopeNodeAdmin,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed node-admin token: %v", err)
|
||||
}
|
||||
allowed := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
|
||||
allowed.Header.Set("Authorization", "Bearer "+plaintext)
|
||||
allowed.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, allowed)
|
||||
if w.Code != http.StatusOK || w.Body.String() != `{"reached":true}` {
|
||||
t.Fatalf("mTLS node-admin response = %d %s, want 200 update launch", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
|
||||
|
||||
@@ -64,10 +64,11 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||
// node-sync push, so the node stores the row instead of re-judging it.
|
||||
func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
|
||||
svc := a.inboundService
|
||||
scope, _ := c.Get("api_token_scope")
|
||||
scopeValue, _ := c.Get("api_token_scope")
|
||||
scope, _ := scopeValue.(string)
|
||||
// A master enrolled with an admin token (the -getApiToken default) has no
|
||||
// node-sync scope, so it marks every request it sends instead.
|
||||
svc.FromNodeSync = scope == model.ApiScopeNodeSync || c.GetHeader(wirecodec.MasterPushHeader) != ""
|
||||
svc.FromNodeSync = model.IsNodeSyncApiScope(scope) || c.GetHeader(wirecodec.MasterPushHeader) != ""
|
||||
return &svc
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(prev) })
|
||||
|
||||
for name, scope := range map[string]string{"node-sync": model.ApiScopeNodeSync, "admin": model.ApiScopeAdmin} {
|
||||
for name, scope := range map[string]string{
|
||||
"node-sync": model.ApiScopeNodeSync,
|
||||
"node-admin": model.ApiScopeNodeAdmin,
|
||||
"admin": model.ApiScopeAdmin,
|
||||
} {
|
||||
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(name + "-token"), Enabled: true, Scope: scope}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed %s token: %v", name, err)
|
||||
@@ -79,6 +83,16 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a node admin push keeps node sync semantics", func(t *testing.T) {
|
||||
body := add(t, "node-admin-token", 45003)
|
||||
if !strings.Contains(body, `"success":true`) {
|
||||
t.Fatalf("node-admin add rejected: %s", body)
|
||||
}
|
||||
if got := rows(t, "tls-legacy-45003"); got != 1 {
|
||||
t.Fatalf("stored rows = %d, want 1", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an operator token is still held to the guard", func(t *testing.T) {
|
||||
body := add(t, "admin-token", 45002)
|
||||
if !strings.Contains(body, `"success":false`) || !strings.Contains(body, "TLS") {
|
||||
|
||||
Reference in New Issue
Block a user