3x-ui/internal/web/controller/node_credentials_writeonly_test.go

124 lines
3.9 KiB
Go

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)
}
}