mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-24 21:46:07 +00:00
fix(nodes): make node API tokens write-only (#5613)
* fix(nodes): make node API tokens write-only * fix(nodes): keep token optional on edit for write-only API tokens NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales.
This commit is contained in:
@@ -8,7 +8,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@@ -78,7 +77,7 @@ func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *NodeController) list(c *gin.Context) {
|
||||
nodes, err := a.nodeService.GetNodeTree()
|
||||
nodes, err := a.nodeService.GetNodeTreeView()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
|
||||
return
|
||||
@@ -92,7 +91,7 @@ func (a *NodeController) get(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
n, err := a.nodeService.GetById(id)
|
||||
n, err := a.nodeService.GetViewById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
@@ -116,27 +115,32 @@ func (a *NodeController) webCert(c *gin.Context) {
|
||||
jsonObj(c, files, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
|
||||
func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
if _, err := a.nodeService.Probe(ctx, n); err != nil {
|
||||
if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
|
||||
return errors.New(service.FriendlyProbeError(err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NodeController) add(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n.OutboundTag == "" {
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
if err := a.ensureReachable(c, n, 0); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.nodeService.Create(n); err != nil {
|
||||
view, err := a.nodeService.CreateFromRequest(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
@@ -144,12 +148,12 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
if err := a.xrayService.RestartXray(false); err != nil {
|
||||
logger.Warning("apply node outbound bridge failed:", err)
|
||||
}
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) update(c *gin.Context) {
|
||||
@@ -158,7 +162,7 @@ func (a *NodeController) update(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -167,13 +171,13 @@ func (a *NodeController) update(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag == "" && old.OutboundTag == "" {
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.nodeService.Update(id, n); err != nil {
|
||||
if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -181,7 +185,7 @@ func (a *NodeController) update(c *gin.Context) {
|
||||
if err := a.xrayService.RestartXray(false); err != nil {
|
||||
logger.Warning("apply node outbound bridge change failed:", err)
|
||||
}
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -233,58 +237,57 @@ func (a *NodeController) setEnable(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *NodeController) inbounds(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
|
||||
options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
|
||||
jsonObj(c, options, err)
|
||||
}
|
||||
|
||||
func (a *NodeController) test(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
var patch service.HeartbeatPatch
|
||||
var err error
|
||||
if n.OutboundTag != "" {
|
||||
patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag)
|
||||
if runtimeNode.OutboundTag != "" {
|
||||
patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
|
||||
} else {
|
||||
patch, err = a.nodeService.Probe(ctx, n)
|
||||
patch, err = a.nodeService.Probe(ctx, runtimeNode)
|
||||
}
|
||||
jsonObj(c, patch.ToUI(err == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) certFingerprint(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"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/web/locale"
|
||||
)
|
||||
|
||||
func newNodeCredentialTestEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
c.Next()
|
||||
})
|
||||
NewNodeController(engine.Group("/panel/api/nodes"))
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
if err := database.GetDB().Create(&model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "https",
|
||||
Address: "example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("%s leaked api token: %s", path, body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("%s did not expose credential presence: %s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "added-node",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "input-secret-token",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("add response leaked api token: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("add response did not expose credential presence: %s", body)
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "input-secret-token" {
|
||||
t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
node := &model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "http",
|
||||
Address: host,
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "stored-node-renamed",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "stored-secret-token" {
|
||||
t.Fatalf("blank update changed token to %q", stored.ApiToken)
|
||||
}
|
||||
if stored.Name != "stored-node-renamed" {
|
||||
t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user