mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
Phase 02 - MCP input validation: - Add command allowlist (node, python, npx, uvx, etc.) - Block dangerous args (--eval, -e, -c, exec(), etc.) - SSRF protection via security.Validate() with DNS rebinding check - Fail-closed env var allowlist for headers - Integrate validation in create/update/import handlers Phase 03 - Hooks async context leak: - Use context.WithoutCancel to preserve TenantID/UserID in async hooks - Increase audit write timeout 2s → 5s - Increase circuit breaker store timeout 1s → 2s Also: Add security guard tests for skills upload
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/mcp"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
// handleMCPImport imports a MCP servers archive (POST /v1/mcp/import).
|
|
func (h *MCPHandler) handleMCPImport(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
userID := store.UserIDFromContext(r.Context())
|
|
|
|
if h.db == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")})
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())})
|
|
return
|
|
}
|
|
|
|
f, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
stream := r.URL.Query().Get("stream") == "true"
|
|
if stream {
|
|
flusher := initSSE(w)
|
|
if flusher == nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"})
|
|
return
|
|
}
|
|
progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) }
|
|
summary, importErr := h.doMCPImport(r.Context(), f, userID, progressFn)
|
|
if importErr != nil {
|
|
sendSSE(w, flusher, "error", ProgressEvent{Phase: "import", Status: "error", Detail: importErr.Error()})
|
|
return
|
|
}
|
|
sendSSE(w, flusher, "complete", summary)
|
|
return
|
|
}
|
|
|
|
summary, err := h.doMCPImport(r.Context(), f, userID, nil)
|
|
if err != nil {
|
|
slog.Error("mcp.import", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, summary)
|
|
}
|
|
|
|
// MCPImportSummary is returned after a successful MCP import.
|
|
type MCPImportSummary struct {
|
|
ServersImported int `json:"servers_imported"`
|
|
ServersSkipped int `json:"servers_skipped"`
|
|
GrantsApplied int `json:"grants_applied"`
|
|
}
|
|
|
|
// doMCPImport parses the MCP tar.gz and creates servers + grants.
|
|
func (h *MCPHandler) doMCPImport(ctx context.Context, r io.Reader, userID string, progressFn func(ProgressEvent)) (*MCPImportSummary, error) {
|
|
entries, err := readTarGzEntries(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
summary := &MCPImportSummary{}
|
|
|
|
// Parse servers
|
|
var servers []pg.MCPServerExport
|
|
if raw, ok := entries["servers.jsonl"]; ok {
|
|
servers, err = parseJSONL[pg.MCPServerExport](raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse servers.jsonl: %w", err)
|
|
}
|
|
}
|
|
|
|
// Parse grants
|
|
var grants []pg.MCPGrantWithKey
|
|
if raw, ok := entries["grants.jsonl"]; ok {
|
|
grants, err = parseJSONL[pg.MCPGrantWithKey](raw)
|
|
if err != nil {
|
|
slog.Warn("mcp.import: parse grants.jsonl failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// Import servers — build name → uuid.UUID map for grant wiring
|
|
tid := importTenantID(ctx)
|
|
serverNameToUUID := make(map[string]uuid.UUID, len(servers))
|
|
|
|
for i, srv := range servers {
|
|
if progressFn != nil {
|
|
progressFn(ProgressEvent{Phase: "server", Status: "running", Current: i + 1, Total: len(servers), Detail: srv.Name})
|
|
}
|
|
|
|
// Security validation: validate imported server config
|
|
var args []string
|
|
if len(srv.Args) > 0 {
|
|
_ = json.Unmarshal(srv.Args, &args)
|
|
}
|
|
if err := mcp.ValidateServerConfig(srv.Transport, srv.Command, args, srv.URL); err != nil {
|
|
slog.Warn("security.mcp.import_rejected",
|
|
"name", srv.Name,
|
|
"reason", err.Error(),
|
|
"transport", srv.Transport)
|
|
summary.ServersSkipped++
|
|
continue
|
|
}
|
|
|
|
id, created, err := pg.ImportMCPServer(ctx, h.db, srv, userID)
|
|
if err != nil {
|
|
slog.Warn("mcp.import: create server", "name", srv.Name, "error", err)
|
|
continue
|
|
}
|
|
serverNameToUUID[srv.Name] = id
|
|
if created {
|
|
summary.ServersImported++
|
|
} else {
|
|
summary.ServersSkipped++
|
|
}
|
|
if progressFn != nil {
|
|
progressFn(ProgressEvent{Phase: "server", Status: "done", Detail: srv.Name})
|
|
}
|
|
}
|
|
|
|
h.emitCacheInvalidate()
|
|
|
|
// Apply grants
|
|
for i, g := range grants {
|
|
if progressFn != nil {
|
|
progressFn(ProgressEvent{Phase: "grant", Status: "running", Current: i + 1, Total: len(grants)})
|
|
}
|
|
|
|
serverID, ok := serverNameToUUID[g.ServerName]
|
|
if !ok {
|
|
// Server may have pre-existed — look it up (tenant-scoped)
|
|
if err := h.db.QueryRowContext(ctx,
|
|
"SELECT id FROM mcp_servers WHERE name = $1 AND tenant_id = $2", g.ServerName, tid,
|
|
).Scan(&serverID); err != nil {
|
|
slog.Warn("mcp.import.grant: server not found", "server", g.ServerName)
|
|
continue
|
|
}
|
|
}
|
|
|
|
var agentID uuid.UUID
|
|
if err := h.db.QueryRowContext(ctx,
|
|
"SELECT id FROM agents WHERE agent_key = $1 AND tenant_id = $2", g.AgentKey, tid,
|
|
).Scan(&agentID); err != nil {
|
|
slog.Warn("mcp.import.grant: agent not found", "key", g.AgentKey)
|
|
continue
|
|
}
|
|
|
|
if err := pg.ImportMCPGrant(ctx, h.db, serverID, agentID, g, userID); err != nil {
|
|
slog.Warn("mcp.import.grant: insert", "server", g.ServerName, "agent", g.AgentKey, "error", err)
|
|
continue
|
|
}
|
|
summary.GrantsApplied++
|
|
}
|
|
|
|
if progressFn != nil {
|
|
progressFn(ProgressEvent{Phase: "grants", Status: "done", Current: summary.GrantsApplied, Total: len(grants)})
|
|
}
|
|
|
|
return summary, nil
|
|
}
|