Files
viettranx c81cf6cf39 feat(security): MCP validation + hooks context isolation
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
2026-04-16 17:19:50 +07:00

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
}