From 8288c36f574308c1e2190a18e265db2fbe2e0c39 Mon Sep 17 00:00:00 2001 From: dan-arpino Date: Mon, 15 Jun 2026 17:01:25 -0400 Subject: [PATCH] chore: [AG-290] Non-Forgeable Trust --- internal/mcp/llm_binding.go | 34 +----- internal/mcp/llm_binding_test.go | 2 +- internal/networking/networking.go | 45 +++++++ internal/networking/networking_test.go | 95 +++++++++++++++ internal/trust/trust.go | 78 ++++++++----- internal/trust/trust.html | 2 + internal/trust/trust_test.go | 155 +++++++++++++++++++++++++ 7 files changed, 350 insertions(+), 61 deletions(-) diff --git a/internal/mcp/llm_binding.go b/internal/mcp/llm_binding.go index c993803..26a4fa4 100644 --- a/internal/mcp/llm_binding.go +++ b/internal/mcp/llm_binding.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" "fmt" - "net" "net/http" "net/url" "os" @@ -184,16 +183,9 @@ func (m *McpLLMBinding) HandleSseServer() error { return nil } -var allowedHostnames = map[string]bool{ - "localhost": true, - "127.0.0.1": true, - "::1": true, - "": true, -} - func middleware(sseServer *server.SSEServer) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isValidHttpRequest(r) { + if networking.IsValidLoopbackRequest(r) { sseServer.ServeHTTP(w, r) } else { http.Error(w, "Forbidden: Access restricted to localhost origins", http.StatusForbidden) @@ -201,30 +193,6 @@ func middleware(sseServer *server.SSEServer) http.Handler { }) } -func isValidHttpRequest(r *http.Request) bool { - originHeader := r.Header.Get("Origin") - isValidOrigin := originHeader == "" - hostHeader := r.Host - host, _, err := net.SplitHostPort(hostHeader) - if err != nil { - // Try to parse without port - host = hostHeader - } - isValidHost := allowedHostnames[host] - - if !isValidOrigin { - parsedOrigin, err := url.Parse(originHeader) - if err == nil { - requestHost := parsedOrigin.Hostname() - if _, allowed := allowedHostnames[requestHost]; allowed { - isValidOrigin = true - } - } - } - - return isValidOrigin && isValidHost -} - func (m *McpLLMBinding) Shutdown(ctx context.Context) { m.mutex.Lock() defer m.mutex.Unlock() diff --git a/internal/mcp/llm_binding_test.go b/internal/mcp/llm_binding_test.go index c06a58e..6105aad 100644 --- a/internal/mcp/llm_binding_test.go +++ b/internal/mcp/llm_binding_test.go @@ -259,7 +259,7 @@ func TestIsValidHttpRequest(t *testing.T) { r.Header.Set("Origin", tt.origin) } - result := isValidHttpRequest(r) + result := networking.IsValidLoopbackRequest(r) assert.Equal(t, tt.expected, result) }) } diff --git a/internal/networking/networking.go b/internal/networking/networking.go index fc84d56..a35edb3 100644 --- a/internal/networking/networking.go +++ b/internal/networking/networking.go @@ -19,6 +19,7 @@ package networking import ( "fmt" "net" + "net/http" "net/url" ) @@ -54,6 +55,50 @@ func DetermineFreePort() int { return port } +func RandomLoopbackListener() (*url.URL, net.Listener, error) { + listener, err := net.Listen("tcp", net.JoinHostPort(DefaultHost, "0")) + if err != nil { + return nil, nil, fmt.Errorf("failed to listen on loopback: %w", err) + } + addr := listener.Addr().(*net.TCPAddr) + u, err := url.Parse(fmt.Sprintf("http://%s:%d", DefaultHost, addr.Port)) + if err != nil { + _ = listener.Close() + return nil, nil, err + } + return u, listener, nil +} + +var AllowedLoopbackHostnames = map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "::1": true, + "": true, +} + +func IsValidLoopbackRequest(r *http.Request) bool { + originHeader := r.Header.Get("Origin") + isValidOrigin := originHeader == "" + hostHeader := r.Host + host, _, err := net.SplitHostPort(hostHeader) + if err != nil { + host = hostHeader + } + isValidHost := AllowedLoopbackHostnames[host] + + if !isValidOrigin { + parsedOrigin, err := url.Parse(originHeader) + if err == nil { + requestHost := parsedOrigin.Hostname() + if _, allowed := AllowedLoopbackHostnames[requestHost]; allowed { + isValidOrigin = true + } + } + } + + return isValidOrigin && isValidHost +} + func LoopbackURL() (*url.URL, error) { rawURL := fmt.Sprintf("http://%s:%d", DefaultHost, DetermineFreePort()) u, err := url.Parse(rawURL) diff --git a/internal/networking/networking_test.go b/internal/networking/networking_test.go index 87447b2..735acca 100644 --- a/internal/networking/networking_test.go +++ b/internal/networking/networking_test.go @@ -19,6 +19,7 @@ package networking import ( "fmt" "net" + "net/http" "net/url" "testing" @@ -90,3 +91,97 @@ func Test_determineFreePort(t *testing.T) { t.Errorf("Expected to fail to find a free port. Port %d found instead ", port) } } + +func Test_RandomLoopbackListener(t *testing.T) { + u, listener, err := RandomLoopbackListener() + require.NoError(t, err) + defer func() { _ = listener.Close() }() + + assert.Equal(t, "http", u.Scheme) + assert.Contains(t, u.Host, DefaultHost) + + addr := listener.Addr().(*net.TCPAddr) + assert.NotEqual(t, DefaultPort, addr.Port) + + conn, dialErr := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, dialErr) + _ = conn.Close() +} + +func Test_IsValidLoopbackRequest(t *testing.T) { + tests := []struct { + name string + host string + origin string + expected bool + }{ + { + name: "valid localhost host, no origin", + host: "localhost", + origin: "", + expected: true, + }, + { + name: "valid localhost host and origin", + host: "localhost", + origin: "http://localhost:3000", + expected: true, + }, + { + name: "valid IPv4 loopback", + host: "127.0.0.1", + origin: "", + expected: true, + }, + { + name: "valid IPv6 loopback", + host: "::1", + origin: "", + expected: true, + }, + { + name: "external host rejected", + host: "example.com", + origin: "", + expected: false, + }, + { + name: "external origin rejected", + host: "localhost", + origin: "http://evil.com", + expected: false, + }, + { + name: "empty host and origin allowed", + host: "", + origin: "", + expected: true, + }, + { + name: "localhost host with port", + host: "localhost:8080", + origin: "", + expected: true, + }, + { + name: "127.0.0.1 origin with external host rejected", + host: "evil.com", + origin: "http://127.0.0.1:3000", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &http.Request{ + Header: make(http.Header), + Host: tt.host, + } + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + result := IsValidLoopbackRequest(r) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/trust/trust.go b/internal/trust/trust.go index c917aba..f99a1c1 100644 --- a/internal/trust/trust.go +++ b/internal/trust/trust.go @@ -18,18 +18,19 @@ package trust import ( "context" + "crypto/rand" + "crypto/subtle" _ "embed" + "encoding/hex" "errors" "fmt" "html/template" - "net" "net/http" "os" "path/filepath" "runtime" "strings" "sync" - "time" "github.com/mark3labs/mcp-go/mcp" "github.com/pkg/browser" @@ -65,8 +66,8 @@ func NewFolderTrust(logger *zerolog.Logger, config configuration.Configuration) if f == "" { continue } - // AddTrustedFolder checks if the folder is already trusted folderTrust.AddTrustedFolder(f) + folderTrust.logger.Warn().Str("folder", f).Msg("Folder auto-trusted via TRUSTED_FOLDERS environment variable") } } @@ -154,6 +155,14 @@ func (t *FolderTrust) addTrustedFolder(folder string) { t.config.Set(TrustedFoldersConfigKey, trustedFolders) } +func generateNonce() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + return hex.EncodeToString(b), nil +} + func (t *FolderTrust) HandleTrust(ctx context.Context, folderPath string, logger zerolog.Logger) (*mcp.CallToolResult, error) { resultChan := make(chan *mcp.CallToolResult) errorChan := make(chan error) @@ -166,6 +175,17 @@ func (t *FolderTrust) HandleTrust(ctx context.Context, folderPath string, logger return nil, fmt.Errorf("failed to parse HTML template from trust.SnykTrustPage: %w", err) } + nonce, err := generateNonce() + if err != nil { + return nil, err + } + + serverUrl, listener, err := networking.RandomLoopbackListener() + if err != nil { + return nil, fmt.Errorf("failed to create listener: %w", err) + } + rawUrl := serverUrl.String() + mux := http.NewServeMux() server := &http.Server{Handler: mux} defer func() { @@ -177,24 +197,7 @@ func (t *FolderTrust) HandleTrust(ctx context.Context, folderPath string, logger } }() - t.addHttpHandlers(logger, mux, folderPath, tmpl, resultChan, errorChan) - - serverUrl, err := networking.LoopbackURL() - if err != nil { - return nil, fmt.Errorf("failed to get default url: %w", err) - } - - retries := 0 - for networking.IsPortInUse(serverUrl) && retries < 10 { - time.Sleep(10 * time.Millisecond) - retries++ - } - rawUrl := serverUrl.String() - listener, err := net.Listen("tcp", serverUrl.Host) - - if err != nil { - return nil, fmt.Errorf("failed to create listener: %w", err) - } + t.addHttpHandlers(logger, mux, folderPath, nonce, tmpl, resultChan, errorChan) go func() { logger.Info().Str("url", rawUrl).Msg("Starting trust confirmation server") @@ -226,10 +229,33 @@ func (t *FolderTrust) HandleTrust(ctx context.Context, folderPath string, logger } } -func (t *FolderTrust) addHttpHandlers(logger zerolog.Logger, mux *http.ServeMux, folderPath string, tmpl *template.Template, resultChan chan *mcp.CallToolResult, errorChan chan error) { +func (t *FolderTrust) addHttpHandlers(logger zerolog.Logger, mux *http.ServeMux, folderPath string, nonce string, tmpl *template.Template, resultChan chan *mcp.CallToolResult, errorChan chan error) { + validateRequest := func(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return false + } + if !networking.IsValidLoopbackRequest(r) { + logger.Warn().Str("origin", r.Header.Get("Origin")).Msg("Rejected cross-origin request to trust server") + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + receivedNonce := r.Header.Get("X-Trust-Nonce") + if subtle.ConstantTimeCompare([]byte(receivedNonce), []byte(nonce)) != 1 { + logger.Warn().Msg("Rejected request with invalid or missing nonce") + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + return true + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - pageData := struct{ Path string }{Path: folderPath} + w.Header().Set("X-Frame-Options", "DENY") + pageData := struct { + Path string + Nonce string + }{Path: folderPath, Nonce: nonce} tmpErr := tmpl.Execute(w, pageData) if tmpErr != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) @@ -239,8 +265,7 @@ func (t *FolderTrust) addHttpHandlers(logger zerolog.Logger, mux *http.ServeMux, }) mux.HandleFunc("/trust", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + if !validateRequest(w, r) { return } logger.Info().Str("path", folderPath).Msg("User chose to trust folder") @@ -250,8 +275,7 @@ func (t *FolderTrust) addHttpHandlers(logger zerolog.Logger, mux *http.ServeMux, }) mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + if !validateRequest(w, r) { return } logger.Info().Str("path", folderPath).Msg("User chose not to trust folder") diff --git a/internal/trust/trust.html b/internal/trust/trust.html index 51d67aa..745c3a6 100644 --- a/internal/trust/trust.html +++ b/internal/trust/trust.html @@ -88,6 +88,7 @@

Do you trust this path?

+