Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 1 addition & 33 deletions internal/mcp/llm_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -184,47 +183,16 @@ 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)
}
})
}

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()
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/llm_binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Expand Down
45 changes: 45 additions & 0 deletions internal/networking/networking.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package networking
import (
"fmt"
"net"
"net/http"
"net/url"
)

Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions internal/networking/networking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package networking
import (
"fmt"
"net"
"net/http"
"net/url"
"testing"

Expand Down Expand Up @@ -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)
})
}
}
78 changes: 51 additions & 27 deletions internal/trust/trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/trust/trust.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ <h1 class="snx-heading h1" id="header">Do you trust this path?</h1>
</div>
</div>
</div>
<script>var __TRUST_NONCE__ = "{{.Nonce}}";</script>
<script>
function sendAction(action) {
var messageElem = document.getElementById('message');
Expand All @@ -100,6 +101,7 @@ <h1 class="snx-heading h1" id="header">Do you trust this path?</h1>

var xhr = new XMLHttpRequest();
xhr.open('POST', action, true);
xhr.setRequestHeader('X-Trust-Nonce', __TRUST_NONCE__);

xhr.onload = function() {
headerElem.style.display = 'none';
Expand Down
Loading
Loading