diff --git a/internal/handler/webshell.go b/internal/handler/webshell.go index 5b34c368..d6d828df 100644 --- a/internal/handler/webshell.go +++ b/internal/handler/webshell.go @@ -721,15 +721,22 @@ func (h *WebShellHandler) Exec(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"}) return } - conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL) - if !allowed { + // Pre-save connectivity tests send form credentials without connection_id. + // Saved connections must go through resource ACL; DB credentials are authoritative. + if cid := strings.TrimSpace(req.ConnectionID); cid != "" { + conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL) + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + // Never let a caller pair an authorized ID with attacker-controlled + // transport credentials or a URL. + req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type + req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding + } else if !security.SessionHasPermission(c, "webshell:write") { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return } - // The database record is authoritative. Never let a caller pair an - // authorized ID with attacker-controlled transport credentials or a URL. - req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type - req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { @@ -823,13 +830,18 @@ func (h *WebShellHandler) FileOp(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"}) return } - conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL) - if !allowed { + if cid := strings.TrimSpace(req.ConnectionID); cid != "" { + conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL) + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type + req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS + } else if !security.SessionHasPermission(c, "webshell:write") { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return } - req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type - req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { diff --git a/internal/handler/webshell_rbac_test.go b/internal/handler/webshell_rbac_test.go index cea5676b..33b86e1e 100644 --- a/internal/handler/webshell_rbac_test.go +++ b/internal/handler/webshell_rbac_test.go @@ -40,15 +40,47 @@ func TestWebshellExecRequiresConnectionAccessWhenConnectionIDProvided(t *testing } } -func TestWebshellExecRejectsAdHocURLWithoutConnectionID(t *testing.T) { +func TestWebshellExecAllowsAdHocURLWithoutConnectionID(t *testing.T) { gin.SetMode(gin.TestMode) _, user, _, _ := setupWebshellRBACTest(t) handler := NewWebShellHandler(zap.NewNop(), nil) + // Ad-hoc probe (connectivity test before save) must not be rejected as "无权访问". + // The target URL will fail to connect; we only assert auth allows the request through. w := performWebshellJSON(user, http.MethodPost, "/api/webshell/exec", map[string]interface{}{ - "url": "http://127.0.0.1/admin", "command": "id", + "url": "http://127.0.0.1:1/admin", "command": "id", }, handler.Exec) + if w.Code == http.StatusForbidden { + t.Fatalf("ad-hoc URL status = %d, want non-403: %s", w.Code, w.Body.String()) + } + var resp ExecResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v body=%s", err, w.Body.String()) + } + if resp.OK { + t.Fatalf("expected connection failure for closed port, got ok=true") + } +} + +func TestWebshellExecRejectsAdHocWithoutWritePermission(t *testing.T) { + gin.SetMode(gin.TestMode) + user := &database.RBACUser{ID: "u_ro", Username: "readonly"} + handler := NewWebShellHandler(zap.NewNop(), nil) + payload, _ := json.Marshal(map[string]interface{}{ + "url": "http://127.0.0.1/admin", "command": "id", + }) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/webshell/exec", bytes.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(security.ContextSessionKey, security.Session{ + UserID: user.ID, + Username: user.Username, + Permissions: map[string]bool{"webshell:read": true}, + Scope: database.RBACScopeAssigned, + }) + handler.Exec(c) if w.Code != http.StatusForbidden { - t.Fatalf("ad-hoc URL status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String()) + t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String()) } }