|
@@ -7,6 +7,7 @@ import (
|
|
|
"net/http/cookiejar"
|
|
"net/http/cookiejar"
|
|
|
"net/http/httptest"
|
|
"net/http/httptest"
|
|
|
"path/filepath"
|
|
"path/filepath"
|
|
|
|
|
+ "reflect"
|
|
|
"testing"
|
|
"testing"
|
|
|
|
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-contrib/sessions"
|
|
@@ -57,9 +58,26 @@ func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
|
|
|
|
|
|
|
|
api := engine.Group("/panel/api")
|
|
api := engine.Group("/panel/api")
|
|
|
api.Use(a.checkAPIAuth)
|
|
api.Use(a.checkAPIAuth)
|
|
|
|
|
+ api.Use(a.enforceTokenScope)
|
|
|
api.GET("/ping", func(c *gin.Context) {
|
|
api.GET("/ping", func(c *gin.Context) {
|
|
|
c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
|
|
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
|
|
return engine, a
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -74,6 +92,7 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
|
|
|
Name: "t1",
|
|
Name: "t1",
|
|
|
Token: crypto.HashTokenSHA256(plaintext),
|
|
Token: crypto.HashTokenSHA256(plaintext),
|
|
|
Enabled: true,
|
|
Enabled: true,
|
|
|
|
|
+ Scope: model.ApiScopeAdmin,
|
|
|
}).Error; err != nil {
|
|
}).Error; err != nil {
|
|
|
t.Fatalf("seed token: %v", err)
|
|
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) {
|
|
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
|
|
engine, _ := newAPIAuthTestEngine(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{
|
|
req.TLS = &tls.ConnectionState{
|
|
|
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
|
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
|
|
}
|
|
}
|
|
@@ -108,8 +125,79 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
|
|
if w.Code != http.StatusOK {
|
|
if w.Code != http.StatusOK {
|
|
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
|
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/[email protected]/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())
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|