api_auth_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. package controller
  2. import (
  3. "crypto/tls"
  4. "crypto/x509"
  5. "net/http"
  6. "net/http/cookiejar"
  7. "net/http/httptest"
  8. "path/filepath"
  9. "reflect"
  10. "testing"
  11. "github.com/gin-contrib/sessions"
  12. "github.com/gin-contrib/sessions/cookie"
  13. "github.com/gin-gonic/gin"
  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/util/crypto"
  18. "github.com/mhsanaei/3x-ui/v3/internal/web/session"
  19. )
  20. // newAPIAuthTestEngine builds a gin engine that mirrors the production auth
  21. // wiring: the sessions middleware, then checkAPIAuth guarding a sentinel
  22. // handler that reports whether c.Next() was reached and whether api_authed was
  23. // set. The APIController is the zero value, exactly as NewAPIController leaves
  24. // its service fields (they query the global DB), so this exercises the real
  25. // auth path. A fresh temp DB is initialised per test.
  26. func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
  27. t.Helper()
  28. gin.SetMode(gin.TestMode)
  29. dbDir := t.TempDir()
  30. t.Setenv("XUI_DB_FOLDER", dbDir)
  31. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  32. engine := gin.New()
  33. store := cookie.NewStore([]byte("api-auth-test-secret"))
  34. engine.Use(sessions.Sessions("3x-ui", store))
  35. a := &APIController{}
  36. // Logs in as the first user so the session path can be exercised over a
  37. // cookie round-trip without reaching into checkAPIAuth's internals.
  38. engine.GET("/test-login", func(c *gin.Context) {
  39. u, err := a.userService.GetFirstUser()
  40. if err != nil {
  41. c.Status(http.StatusInternalServerError)
  42. return
  43. }
  44. if err := session.SetLoginUser(c, u); err != nil {
  45. c.Status(http.StatusInternalServerError)
  46. return
  47. }
  48. c.Status(http.StatusOK)
  49. })
  50. api := engine.Group("/panel/api")
  51. api.Use(a.checkAPIAuth)
  52. api.Use(a.enforceTokenScope)
  53. api.GET("/ping", func(c *gin.Context) {
  54. c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
  55. })
  56. api.GET("/server/status", func(c *gin.Context) {
  57. scope, _ := c.Get("api_token_scope")
  58. c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed"), "scope": scope})
  59. })
  60. api.POST("/server/updatePanel", func(c *gin.Context) {
  61. c.JSON(http.StatusOK, gin.H{"reached": true})
  62. })
  63. api.POST("/clients/:email/detach", func(c *gin.Context) {
  64. c.JSON(http.StatusOK, gin.H{"reached": true})
  65. })
  66. api.POST("/inbounds/:id/resetTraffic", func(c *gin.Context) {
  67. c.JSON(http.StatusOK, gin.H{"reached": true})
  68. })
  69. api.POST("/clients/clientIpsByGuid", func(c *gin.Context) {
  70. c.JSON(http.StatusOK, gin.H{"reached": true})
  71. })
  72. return engine, a
  73. }
  74. // TestCheckAPIAuth_BearerSuccess characterizes the bearer-token path: a valid
  75. // token reaches the handler and sets api_authed (the contract the later
  76. // client-cert branch must match).
  77. func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
  78. engine, _ := newAPIAuthTestEngine(t)
  79. const plaintext = "characterization-token-value"
  80. if err := database.GetDB().Create(&model.ApiToken{
  81. Name: "t1",
  82. Token: crypto.HashTokenSHA256(plaintext),
  83. Enabled: true,
  84. Scope: model.ApiScopeAdmin,
  85. }).Error; err != nil {
  86. t.Fatalf("seed token: %v", err)
  87. }
  88. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  89. req.Header.Set("Authorization", "Bearer "+plaintext)
  90. w := httptest.NewRecorder()
  91. engine.ServeHTTP(w, req)
  92. if w.Code != http.StatusOK {
  93. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  94. }
  95. if got := w.Body.String(); got != `{"api_authed":true}` {
  96. t.Fatalf("body = %s, want api_authed true", got)
  97. }
  98. }
  99. // TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
  100. // as node-sync rather than bypassing scope checks as admin.
  101. func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
  102. engine, _ := newAPIAuthTestEngine(t)
  103. req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
  104. req.TLS = &tls.ConnectionState{
  105. VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
  106. }
  107. w := httptest.NewRecorder()
  108. engine.ServeHTTP(w, req)
  109. if w.Code != http.StatusOK {
  110. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  111. }
  112. if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` {
  113. t.Fatalf("body = %s, want node-sync scope", got)
  114. }
  115. forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
  116. forbidden.TLS = &tls.ConnectionState{
  117. VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
  118. }
  119. w = httptest.NewRecorder()
  120. engine.ServeHTTP(w, forbidden)
  121. if w.Code != http.StatusForbidden {
  122. t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
  123. }
  124. }
  125. func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
  126. expected := map[string]map[string]struct{}{
  127. "/server/status": {http.MethodGet: {}},
  128. "/inbounds/list": {http.MethodGet: {}},
  129. "/inbounds/add": {http.MethodPost: {}},
  130. "/inbounds/del/:id": {http.MethodPost: {}},
  131. "/inbounds/update/:id": {http.MethodPost: {}},
  132. "/clients/add": {http.MethodPost: {}},
  133. "/clients/del/:email": {http.MethodPost: {}},
  134. "/clients/:email/detach": {http.MethodPost: {}},
  135. "/clients/update/:email": {http.MethodPost: {}},
  136. "/server/restartXrayService": {http.MethodPost: {}},
  137. "/server/getWebCertFiles": {http.MethodGet: {}},
  138. "/server/descendants": {http.MethodGet: {}},
  139. "/clients/resetTraffic/:email": {http.MethodPost: {}},
  140. "/inbounds/resetAllTraffics": {http.MethodPost: {}},
  141. "/inbounds/:id/resetTraffic": {http.MethodPost: {}},
  142. "/clients/onlinesByGuid": {http.MethodPost: {}},
  143. "/clients/onlines": {http.MethodPost: {}},
  144. "/clients/lastOnline": {http.MethodPost: {}},
  145. "/inbounds/pushClientTraffics": {http.MethodPost: {}},
  146. "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
  147. "/clients/clientIpsByGuid": {http.MethodPost: {}},
  148. "/hosts/list": {http.MethodGet: {}},
  149. }
  150. if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
  151. t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
  152. }
  153. if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
  154. t.Fatal("node-sync must not include /server/updatePanel")
  155. }
  156. }
  157. func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
  158. engine, _ := newAPIAuthTestEngine(t)
  159. cases := []struct {
  160. name string
  161. method string
  162. path string
  163. want int
  164. }{
  165. {"detach email parameter", http.MethodPost, "/panel/api/clients/[email protected]/detach", http.StatusOK},
  166. {"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK},
  167. {"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK},
  168. {"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden},
  169. }
  170. for _, tc := range cases {
  171. t.Run(tc.name, func(t *testing.T) {
  172. req := httptest.NewRequest(tc.method, tc.path, nil)
  173. req.TLS = &tls.ConnectionState{
  174. VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
  175. }
  176. w := httptest.NewRecorder()
  177. engine.ServeHTTP(w, req)
  178. if w.Code != tc.want {
  179. t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
  180. }
  181. })
  182. }
  183. }
  184. // TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no
  185. // verified client chain is NOT treated as authenticated (it falls through to the
  186. // bearer/session paths) — so the cert branch can't accidentally authorize plain
  187. // browser HTTPS.
  188. func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) {
  189. engine, _ := newAPIAuthTestEngine(t)
  190. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  191. req.TLS = &tls.ConnectionState{} // handshake done, but no client cert verified
  192. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  193. w := httptest.NewRecorder()
  194. engine.ServeHTTP(w, req)
  195. if w.Code != http.StatusUnauthorized {
  196. t.Fatalf("status = %d, want 401 (unauthenticated, no verified chain)", w.Code)
  197. }
  198. }
  199. // TestCheckAPIAuth_RejectsUnauthenticated characterizes the reject paths:
  200. // no credential → 404 (masking); XHR or a presented (but invalid) Bearer → 401
  201. // so script authors can tell auth failure from a wrong base path.
  202. func TestCheckAPIAuth_RejectsUnauthenticated(t *testing.T) {
  203. engine, _ := newAPIAuthTestEngine(t)
  204. cases := []struct {
  205. name string
  206. xhr bool
  207. bearer string // empty = omit Authorization header
  208. want int
  209. }{
  210. {"xhr gets 401", true, "", http.StatusUnauthorized},
  211. {"non-xhr gets 404", false, "", http.StatusNotFound},
  212. {"invalid bearer gets 401", false, "definitely-not-a-token", http.StatusUnauthorized},
  213. {"invalid bearer xhr gets 401", true, "definitely-not-a-token", http.StatusUnauthorized},
  214. }
  215. for _, c := range cases {
  216. t.Run(c.name, func(t *testing.T) {
  217. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  218. if c.xhr {
  219. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  220. }
  221. if c.bearer != "" {
  222. req.Header.Set("Authorization", "Bearer "+c.bearer)
  223. }
  224. w := httptest.NewRecorder()
  225. engine.ServeHTTP(w, req)
  226. if w.Code != c.want {
  227. t.Fatalf("status = %d, want %d", w.Code, c.want)
  228. }
  229. })
  230. }
  231. }
  232. // TestCheckAPIAuth_SessionLoginPasses characterizes the session path: a
  233. // logged-in browser session (no bearer token) reaches the handler.
  234. func TestCheckAPIAuth_SessionLoginPasses(t *testing.T) {
  235. engine, _ := newAPIAuthTestEngine(t)
  236. db := database.GetDB()
  237. var n int64
  238. if err := db.Model(&model.User{}).Count(&n).Error; err != nil {
  239. t.Fatalf("count users: %v", err)
  240. }
  241. if n == 0 {
  242. if err := db.Create(&model.User{Username: "sess", Password: "x"}).Error; err != nil {
  243. t.Fatalf("seed user: %v", err)
  244. }
  245. }
  246. ts := httptest.NewServer(engine)
  247. defer ts.Close()
  248. jar, err := cookiejar.New(nil)
  249. if err != nil {
  250. t.Fatalf("cookiejar: %v", err)
  251. }
  252. client := &http.Client{Jar: jar}
  253. loginResp, err := client.Get(ts.URL + "/test-login")
  254. if err != nil {
  255. t.Fatalf("login: %v", err)
  256. }
  257. loginResp.Body.Close()
  258. if loginResp.StatusCode != http.StatusOK {
  259. t.Fatalf("login status = %d, want 200", loginResp.StatusCode)
  260. }
  261. pingResp, err := client.Get(ts.URL + "/panel/api/ping")
  262. if err != nil {
  263. t.Fatalf("ping: %v", err)
  264. }
  265. pingResp.Body.Close()
  266. if pingResp.StatusCode != http.StatusOK {
  267. t.Fatalf("session ping status = %d, want 200", pingResp.StatusCode)
  268. }
  269. }