feat(api): scoped, optionally expiring API tokens (#6201)

* security(api): add scoped expiring API tokens

* security(api): make scoped token lifecycle enforceable

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-15 18:31:49 +05:00
committed by GitHub
parent aecbad3ab1
commit 1230559e69
19 changed files with 785 additions and 94 deletions
+96 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
@@ -42,17 +43,19 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Set("api_token_scope", model.ApiScopeNodeSync)
c.Next()
return
}
auth := c.GetHeader("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
tok := after
if a.apiTokenService.Match(tok) {
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
}
@@ -68,11 +71,103 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
c.Next()
}
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
// Keys are route patterns relative to /panel/api.
var monitorScopeAllow = map[string]struct{}{
"/server/status": {},
"/server/cpuHistory/:bucket": {},
"/server/history/:metric/:bucket": {},
"/server/xrayMetricsState": {},
"/server/xrayMetricsHistory/:metric/:bucket": {},
"/server/xrayObservatory": {},
"/server/xrayObservatoryHistory/:tag/:bucket": {},
"/server/getXrayVersion": {},
"/server/getPanelUpdateInfo": {},
"/nodes/history/:id/:metric/:bucket": {},
}
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
// /panel/api; Gin patterns prevent concrete parameters broadening authority.
var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/server/status": {http.MethodGet: {}},
"/inbounds/list": {http.MethodGet: {}},
"/inbounds/add": {http.MethodPost: {}},
"/inbounds/del/:id": {http.MethodPost: {}},
"/inbounds/update/:id": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}},
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
"/clients/onlinesByGuid": {http.MethodPost: {}},
"/clients/onlines": {http.MethodPost: {}},
"/clients/lastOnline": {http.MethodPost: {}},
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
"/clients/clientIpsByGuid": {http.MethodPost: {}},
"/hosts/list": {http.MethodGet: {}},
}
// enforceTokenScope applies explicit allowlists to monitor and node-sync 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")
if !ok {
c.Next()
return
}
scope, _ := scopeVal.(string)
if scope == model.ApiScopeAdmin {
c.Next()
return
}
deny := func() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"msg": "this API token is not permitted to access this endpoint",
})
}
rel := relAPIPath(c.FullPath())
switch scope {
case model.ApiScopeMonitor:
if _, allowed := monitorScopeAllow[rel]; allowed && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) {
c.Next()
return
}
case model.ApiScopeNodeSync:
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
c.Next()
return
}
}
default:
deny()
return
}
deny()
}
func relAPIPath(fullPath string) string {
const marker = "/panel/api"
i := strings.Index(fullPath, marker)
if i < 0 {
return ""
}
return fullPath[i+len(marker):]
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
api.Use(a.enforceTokenScope)
// Decode + verify the node config envelope (zstd + X-Config-Sha256) and
// advertise support, before CSRF/handlers read the body.
api.Use(middleware.ConfigEnvelopeMiddleware())
+95 -7
View File
@@ -7,6 +7,7 @@ import (
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"reflect"
"testing"
"github.com/gin-contrib/sessions"
@@ -57,9 +58,26 @@ func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
api := engine.Group("/panel/api")
api.Use(a.checkAPIAuth)
api.Use(a.enforceTokenScope)
api.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
})
api.GET("/server/status", func(c *gin.Context) {
scope, _ := c.Get("api_token_scope")
c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed"), "scope": scope})
})
api.POST("/server/updatePanel", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"reached": true})
})
api.POST("/clients/:email/detach", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"reached": true})
})
api.POST("/inbounds/:id/resetTraffic", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"reached": true})
})
api.POST("/clients/clientIpsByGuid", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"reached": true})
})
return engine, a
}
@@ -74,6 +92,7 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
Name: "t1",
Token: crypto.HashTokenSHA256(plaintext),
Enabled: true,
Scope: model.ApiScopeAdmin,
}).Error; err != nil {
t.Fatalf("seed token: %v", err)
}
@@ -91,14 +110,12 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
}
}
// TestCheckAPIAuth_AcceptsVerifiedClientCert asserts that a completed mTLS
// handshake (a non-empty verified client chain) authenticates the request even
// with no bearer token and no session — the equivalent of a valid token — and
// sets api_authed so the CSRF middleware lets mutations through.
// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
// as node-sync rather than bypassing scope checks as admin.
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
engine, _ := newAPIAuthTestEngine(t)
req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
req.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
}
@@ -108,8 +125,79 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != `{"api_authed":true}` {
t.Fatalf("body = %s, want api_authed true", got)
if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` {
t.Fatalf("body = %s, want node-sync scope", got)
}
forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
forbidden.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
}
w = httptest.NewRecorder()
engine.ServeHTTP(w, forbidden)
if w.Code != http.StatusForbidden {
t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
}
}
func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
expected := map[string]map[string]struct{}{
"/server/status": {http.MethodGet: {}},
"/inbounds/list": {http.MethodGet: {}},
"/inbounds/add": {http.MethodPost: {}},
"/inbounds/del/:id": {http.MethodPost: {}},
"/inbounds/update/:id": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}},
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
"/clients/onlinesByGuid": {http.MethodPost: {}},
"/clients/onlines": {http.MethodPost: {}},
"/clients/lastOnline": {http.MethodPost: {}},
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
"/clients/clientIpsByGuid": {http.MethodPost: {}},
"/hosts/list": {http.MethodGet: {}},
}
if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
}
if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
t.Fatal("node-sync must not include /server/updatePanel")
}
}
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
engine, _ := newAPIAuthTestEngine(t)
cases := []struct {
name string
method string
path string
want int
}{
{"detach email parameter", http.MethodPost, "/panel/api/clients/alice@example.com/detach", http.StatusOK},
{"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK},
{"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK},
{"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
req.TLS = &tls.ConnectionState{
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
}
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
}
})
}
}
+17 -5
View File
@@ -216,11 +216,18 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
}
type apiTokenCreateForm struct {
Name string `json:"name" form:"name"`
Name string `json:"name" form:"name"`
Scope string `json:"scope" form:"scope"`
ExpiresAt int64 `json:"expiresAt" form:"expiresAt"`
}
type apiTokenEnabledForm struct {
Enabled bool `json:"enabled" form:"enabled"`
Enabled bool `json:"enabled" form:"enabled"`
ExpectedScope string `json:"expectedScope" form:"expectedScope"`
}
type apiTokenScopeForm struct {
ExpectedScope string `json:"expectedScope" form:"expectedScope"`
}
func (a *SettingController) listApiTokens(c *gin.Context) {
@@ -238,7 +245,7 @@ func (a *SettingController) createApiToken(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
row, err := a.apiTokenService.Create(form.Name)
row, err := a.apiTokenService.Create(form.Name, form.Scope, form.ExpiresAt)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
@@ -252,7 +259,12 @@ func (a *SettingController) deleteApiToken(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
form := &apiTokenScopeForm{}
if bindErr := c.ShouldBind(form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.DeleteExpectedScope(id, form.ExpectedScope))
}
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
@@ -266,7 +278,7 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
}
func (a *SettingController) testSmtp(c *gin.Context) {
+42
View File
@@ -3,10 +3,16 @@ package controller
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
func TestValidateRegex(t *testing.T) {
@@ -44,3 +50,39 @@ func TestValidateRegex(t *testing.T) {
})
}
}
func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync}
if err := database.GetDB().Create(row).Error; err != nil {
t.Fatalf("seed token: %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
NewSettingController(router.Group("/panel/api"))
for _, path := range []string{
"/panel/api/setting/apiTokens/delete/" + strconv.Itoa(row.Id),
"/panel/api/setting/apiTokens/setEnabled/" + strconv.Itoa(row.Id),
} {
body := `{"expectedScope":"admin","enabled":false}`
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if !strings.Contains(resp.Body.String(), `"success":false`) {
t.Fatalf("%s accepted wrong expected scope: %s", path, resp.Body.String())
}
}
var stored model.ApiToken
if err := database.GetDB().First(&stored, row.Id).Error; err != nil {
t.Fatalf("token was deleted by wrong scope: %v", err)
}
if !stored.Enabled {
t.Fatal("token was disabled by wrong scope")
}
}