1
0

node_credentials_writeonly_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package controller
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "net"
  6. "net/http"
  7. "net/http/httptest"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "testing"
  12. "github.com/gin-gonic/gin"
  13. "gorm.io/gorm"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  16. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  17. "github.com/mhsanaei/3x-ui/v3/internal/web/locale"
  18. )
  19. func newNodeCredentialTestEngine(t *testing.T) *gin.Engine {
  20. t.Helper()
  21. gin.SetMode(gin.TestMode)
  22. dbDir := t.TempDir()
  23. t.Setenv("XUI_DB_FOLDER", dbDir)
  24. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  25. engine := gin.New()
  26. engine.Use(func(c *gin.Context) {
  27. c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
  28. c.Next()
  29. })
  30. NewNodeController(engine.Group("/panel/api/nodes"))
  31. return engine
  32. }
  33. func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
  34. engine := newNodeCredentialTestEngine(t)
  35. if err := database.GetDB().Create(&model.Node{
  36. Name: "stored-node",
  37. Scheme: "https",
  38. Address: "example.com",
  39. Port: 2053,
  40. BasePath: "/",
  41. ApiToken: "stored-secret-token",
  42. Enable: true,
  43. }).Error; err != nil {
  44. t.Fatalf("seed node: %v", err)
  45. }
  46. for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} {
  47. w := httptest.NewRecorder()
  48. engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
  49. if w.Code != http.StatusOK {
  50. t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
  51. }
  52. body := w.Body.String()
  53. if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") {
  54. t.Fatalf("%s leaked api token: %s", path, body)
  55. }
  56. if !strings.Contains(body, `"hasApiToken":true`) {
  57. t.Fatalf("%s did not expose credential presence: %s", path, body)
  58. }
  59. }
  60. }
  61. func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) {
  62. engine := newNodeCredentialTestEngine(t)
  63. remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  64. w.Header().Set("Content-Type", "application/json")
  65. _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
  66. }))
  67. defer remote.Close()
  68. host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
  69. if err != nil {
  70. t.Fatalf("split remote addr: %v", err)
  71. }
  72. port, err := strconv.Atoi(portString)
  73. if err != nil {
  74. t.Fatalf("parse remote port: %v", err)
  75. }
  76. node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true}
  77. if err := database.GetDB().Create(node).Error; err != nil {
  78. t.Fatalf("seed node: %v", err)
  79. }
  80. db := database.GetDB()
  81. const callback = "test:fail_node_heartbeat_update"
  82. errInjected := errors.New("injected heartbeat persistence failure")
  83. if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
  84. if tx.Statement != nil && tx.Statement.Table == "nodes" {
  85. tx.AddError(errInjected)
  86. }
  87. }); err != nil {
  88. t.Fatalf("register update callback: %v", err)
  89. }
  90. t.Cleanup(func() {
  91. if err := db.Callback().Update().Remove(callback); err != nil {
  92. t.Errorf("remove update callback: %v", err)
  93. }
  94. })
  95. w := httptest.NewRecorder()
  96. engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil))
  97. if !strings.Contains(w.Body.String(), `"success":false`) {
  98. t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String())
  99. }
  100. if !strings.Contains(w.Body.String(), errInjected.Error()) {
  101. t.Fatalf("probe response omitted persistence error: %s", w.Body.String())
  102. }
  103. }
  104. func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
  105. engine := newNodeCredentialTestEngine(t)
  106. remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  107. if r.URL.Path != "/panel/api/server/status" {
  108. t.Fatalf("unexpected path: %s", r.URL.Path)
  109. }
  110. if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" {
  111. t.Fatalf("Authorization = %q", got)
  112. }
  113. w.Header().Set("Content-Type", "application/json")
  114. _, _ = 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}}}`))
  115. }))
  116. defer remote.Close()
  117. host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
  118. if err != nil {
  119. t.Fatalf("split remote addr: %v", err)
  120. }
  121. port, err := strconv.Atoi(portString)
  122. if err != nil {
  123. t.Fatalf("parse remote port: %v", err)
  124. }
  125. payload := map[string]any{
  126. "name": "added-node",
  127. "scheme": "http",
  128. "address": host,
  129. "port": port,
  130. "basePath": "/",
  131. "apiToken": "input-secret-token",
  132. "enable": true,
  133. "allowPrivateAddress": true,
  134. }
  135. raw, _ := json.Marshal(payload)
  136. w := httptest.NewRecorder()
  137. req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw)))
  138. req.Header.Set("Content-Type", "application/json")
  139. engine.ServeHTTP(w, req)
  140. if w.Code != http.StatusOK {
  141. t.Fatalf("add status = %d body=%s", w.Code, w.Body.String())
  142. }
  143. body := w.Body.String()
  144. if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") {
  145. t.Fatalf("add response leaked api token: %s", body)
  146. }
  147. if !strings.Contains(body, `"hasApiToken":true`) {
  148. t.Fatalf("add response did not expose credential presence: %s", body)
  149. }
  150. var stored model.Node
  151. if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil {
  152. t.Fatalf("load stored node: %v", err)
  153. }
  154. if stored.ApiToken != "input-secret-token" {
  155. t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken)
  156. }
  157. }
  158. func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) {
  159. engine := newNodeCredentialTestEngine(t)
  160. remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  161. if r.URL.Path != "/panel/api/server/status" {
  162. t.Fatalf("unexpected path: %s", r.URL.Path)
  163. }
  164. if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" {
  165. t.Fatalf("Authorization = %q", got)
  166. }
  167. w.Header().Set("Content-Type", "application/json")
  168. _, _ = 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}}}`))
  169. }))
  170. defer remote.Close()
  171. host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
  172. if err != nil {
  173. t.Fatalf("split remote addr: %v", err)
  174. }
  175. port, err := strconv.Atoi(portString)
  176. if err != nil {
  177. t.Fatalf("parse remote port: %v", err)
  178. }
  179. node := &model.Node{
  180. Name: "stored-node",
  181. Scheme: "http",
  182. Address: host,
  183. Port: port,
  184. BasePath: "/",
  185. ApiToken: "stored-secret-token",
  186. Enable: true,
  187. AllowPrivateAddress: true,
  188. }
  189. if err := database.GetDB().Create(node).Error; err != nil {
  190. t.Fatalf("seed node: %v", err)
  191. }
  192. payload := map[string]any{
  193. "name": "stored-node-renamed",
  194. "scheme": "http",
  195. "address": host,
  196. "port": port,
  197. "basePath": "/",
  198. "apiToken": "",
  199. "enable": true,
  200. "allowPrivateAddress": true,
  201. }
  202. raw, _ := json.Marshal(payload)
  203. w := httptest.NewRecorder()
  204. req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
  205. req.Header.Set("Content-Type", "application/json")
  206. engine.ServeHTTP(w, req)
  207. if w.Code != http.StatusOK {
  208. t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
  209. }
  210. var stored model.Node
  211. if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
  212. t.Fatalf("load stored node: %v", err)
  213. }
  214. if stored.ApiToken != "stored-secret-token" {
  215. t.Fatalf("blank update changed token to %q", stored.ApiToken)
  216. }
  217. if stored.Name != "stored-node-renamed" {
  218. t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
  219. }
  220. }