mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Ho hum.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Package config provides configuration management for the MCP file operations server.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Config holds all configuration options for the MCP server.
|
||||
type Config struct {
|
||||
Formatters map[string]string `json:"formatters"`
|
||||
WorkspaceRoot string `json:"workspace_root"`
|
||||
LSPTimeout time.Duration `json:"lsp_timeout"`
|
||||
SearchTimeout time.Duration `json:"search_timeout"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
MaxSearchResults int `json:"max_search_results"`
|
||||
MaxEditSize int64 `json:"max_edit_size"`
|
||||
EnableLSP bool `json:"enable_lsp"`
|
||||
FollowSymlinks bool `json:"follow_symlinks"`
|
||||
RespectGitignore bool `json:"respect_gitignore"`
|
||||
}
|
||||
|
||||
// Default values for configuration.
|
||||
const (
|
||||
DefaultLSPTimeout = 5 * time.Minute
|
||||
DefaultSearchTimeout = 30 * time.Second
|
||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxSearchResults = 1000
|
||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||
)
|
||||
|
||||
// Default returns a Config with default values.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
WorkspaceRoot: ".",
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
SearchTimeout: DefaultSearchTimeout,
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxSearchResults: DefaultMaxSearchResults,
|
||||
MaxEditSize: DefaultMaxEditSize,
|
||||
EnableLSP: true,
|
||||
Formatters: make(map[string]string),
|
||||
FollowSymlinks: true,
|
||||
RespectGitignore: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables and optional config file.
|
||||
// Priority: CLI flags > environment variables > config file > defaults.
|
||||
func Load(workspaceRoot string) (*Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
// Set workspace root
|
||||
if workspaceRoot != "" {
|
||||
absPath, err := filepath.Abs(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.WorkspaceRoot = absPath
|
||||
} else if cwd, err := os.Getwd(); err == nil {
|
||||
cfg.WorkspaceRoot = cwd
|
||||
}
|
||||
|
||||
// Try to load from config file in workspace root
|
||||
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Override from environment variables
|
||||
cfg.loadFromEnv()
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) loadFromEnv() {
|
||||
if v := os.Getenv("MCP_WORKSPACE_ROOT"); v != "" {
|
||||
if absPath, err := filepath.Abs(v); err == nil {
|
||||
c.WorkspaceRoot = absPath
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("MCP_LSP_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
c.LSPTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("MCP_SEARCH_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
c.SearchTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("MCP_ENABLE_LSP"); v == "false" || v == "0" {
|
||||
c.EnableLSP = false
|
||||
}
|
||||
|
||||
if v := os.Getenv("MCP_FOLLOW_SYMLINKS"); v == "false" || v == "0" {
|
||||
c.FollowSymlinks = false
|
||||
}
|
||||
|
||||
if v := os.Getenv("MCP_RESPECT_GITIGNORE"); v == "false" || v == "0" {
|
||||
c.RespectGitignore = false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPathAllowed checks if a path is within the workspace root.
|
||||
// It resolves symlinks to prevent path traversal attacks.
|
||||
func (c *Config) IsPathAllowed(path string) bool {
|
||||
// Get absolute path of the target
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get absolute path of workspace root
|
||||
absRoot, err := filepath.Abs(c.WorkspaceRoot)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always try to resolve workspace root symlinks for consistent comparison
|
||||
evalRoot, evalErr := filepath.EvalSymlinks(absRoot)
|
||||
if evalErr == nil {
|
||||
absRoot = evalRoot
|
||||
}
|
||||
|
||||
// For the target path, try to resolve symlinks
|
||||
evalPath, evalErr := filepath.EvalSymlinks(absPath)
|
||||
if evalErr == nil {
|
||||
// File exists and was resolved
|
||||
absPath = evalPath
|
||||
} else {
|
||||
// File doesn't exist - resolve parent directories to match workspace root resolution
|
||||
// Walk up the tree until we find an existing directory
|
||||
dir := filepath.Dir(absPath)
|
||||
remaining := filepath.Base(absPath)
|
||||
|
||||
for dir != "." && dir != "/" && dir != absPath {
|
||||
evalDir, evalErr := filepath.EvalSymlinks(dir)
|
||||
if evalErr == nil {
|
||||
// Found an existing directory, reconstruct the path
|
||||
absPath = filepath.Join(evalDir, remaining)
|
||||
break
|
||||
}
|
||||
// Move up one level
|
||||
newDir := filepath.Dir(dir)
|
||||
if newDir == dir {
|
||||
// Reached the root without finding an existing directory
|
||||
break
|
||||
}
|
||||
remaining = filepath.Join(filepath.Base(dir), remaining)
|
||||
dir = newDir
|
||||
}
|
||||
}
|
||||
|
||||
// Compute relative path
|
||||
rel, err := filepath.Rel(absRoot, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the path is within workspace (doesn't start with ..)
|
||||
// This prevents both "../" attacks and symlink bypasses
|
||||
// Also reject empty relative path (which means it's the workspace root itself)
|
||||
return rel != "." && !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
if cfg.WorkspaceRoot != "." {
|
||||
t.Errorf("expected default workspace root '.', got %q", cfg.WorkspaceRoot)
|
||||
}
|
||||
if cfg.LSPTimeout != DefaultLSPTimeout {
|
||||
t.Errorf("expected default LSP timeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
|
||||
}
|
||||
if cfg.SearchTimeout != DefaultSearchTimeout {
|
||||
t.Errorf("expected default search timeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
|
||||
}
|
||||
if cfg.MaxFileSize != DefaultMaxFileSize {
|
||||
t.Errorf("expected default max file size %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
|
||||
}
|
||||
if cfg.MaxSearchResults != DefaultMaxSearchResults {
|
||||
t.Errorf("expected default max search results %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
|
||||
}
|
||||
if cfg.MaxEditSize != DefaultMaxEditSize {
|
||||
t.Errorf("expected default max edit size %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
|
||||
}
|
||||
if !cfg.EnableLSP {
|
||||
t.Error("expected EnableLSP to be true by default")
|
||||
}
|
||||
if !cfg.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks to be true by default")
|
||||
}
|
||||
if !cfg.RespectGitignore {
|
||||
t.Error("expected RespectGitignore to be true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Create a temporary directory for workspace
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(tmpDir)
|
||||
if cfg.WorkspaceRoot != absPath {
|
||||
t.Errorf("expected workspace root %q, got %q", absPath, cfg.WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
// Save original env values
|
||||
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
|
||||
origSearchTimeout := os.Getenv("MCP_SEARCH_TIMEOUT")
|
||||
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
|
||||
origFollowSymlinks := os.Getenv("MCP_FOLLOW_SYMLINKS")
|
||||
origRespectGitignore := os.Getenv("MCP_RESPECT_GITIGNORE")
|
||||
|
||||
// Restore env after test
|
||||
t.Cleanup(func() {
|
||||
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
|
||||
_ = os.Setenv("MCP_SEARCH_TIMEOUT", origSearchTimeout)
|
||||
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
|
||||
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", origFollowSymlinks)
|
||||
_ = os.Setenv("MCP_RESPECT_GITIGNORE", origRespectGitignore)
|
||||
})
|
||||
|
||||
// Set test env values
|
||||
_ = os.Setenv("MCP_LSP_TIMEOUT", "10m")
|
||||
_ = os.Setenv("MCP_SEARCH_TIMEOUT", "1m")
|
||||
_ = os.Setenv("MCP_ENABLE_LSP", "false")
|
||||
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", "0")
|
||||
_ = os.Setenv("MCP_RESPECT_GITIGNORE", "false")
|
||||
|
||||
cfg := Default()
|
||||
cfg.loadFromEnv()
|
||||
|
||||
if cfg.LSPTimeout != 10*time.Minute {
|
||||
t.Errorf("expected LSP timeout 10m, got %v", cfg.LSPTimeout)
|
||||
}
|
||||
if cfg.SearchTimeout != 1*time.Minute {
|
||||
t.Errorf("expected search timeout 1m, got %v", cfg.SearchTimeout)
|
||||
}
|
||||
if cfg.EnableLSP {
|
||||
t.Error("expected EnableLSP to be false")
|
||||
}
|
||||
if cfg.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks to be false")
|
||||
}
|
||||
if cfg.RespectGitignore {
|
||||
t.Error("expected RespectGitignore to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPathAllowed(t *testing.T) {
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := Default()
|
||||
cfg.WorkspaceRoot = tmpDir
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
allowed bool
|
||||
}{
|
||||
{
|
||||
name: "file in workspace",
|
||||
path: filepath.Join(tmpDir, "test.go"),
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "nested file in workspace",
|
||||
path: filepath.Join(tmpDir, "subdir", "test.go"),
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "path outside workspace",
|
||||
path: "/etc/passwd",
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "relative path traversal",
|
||||
path: filepath.Join(tmpDir, "..", "outside.txt"),
|
||||
allowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := cfg.IsPathAllowed(tt.path)
|
||||
if result != tt.allowed {
|
||||
t.Errorf("IsPathAllowed(%q) = %v, want %v", tt.path, result, tt.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithConfigFile(t *testing.T) {
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write config file
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
configContent := `{
|
||||
"enable_lsp": false,
|
||||
"follow_symlinks": false
|
||||
}`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
cfg, err = Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.EnableLSP {
|
||||
t.Error("expected EnableLSP to be false from config file")
|
||||
}
|
||||
if cfg.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks to be false from config file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsPathAllowed_SymlinkSecurity tests the symlink security fix.
|
||||
func TestIsPathAllowed_SymlinkSecurity(t *testing.T) {
|
||||
// Create a temporary workspace
|
||||
tmpDir := t.TempDir()
|
||||
workspace := filepath.Join(tmpDir, "workspace")
|
||||
outside := filepath.Join(tmpDir, "outside")
|
||||
|
||||
if err := os.MkdirAll(workspace, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(outside, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a file outside the workspace
|
||||
outsideFile := filepath.Join(outside, "secret.txt")
|
||||
if err := os.WriteFile(outsideFile, []byte("secret data"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkspaceRoot: workspace,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
setup func() string
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "regular file inside workspace",
|
||||
setup: func() string {
|
||||
return filepath.Join(workspace, "file.txt")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "file with parent directory traversal",
|
||||
setup: func() string {
|
||||
return filepath.Join(workspace, "../outside/secret.txt")
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "symlink pointing outside workspace",
|
||||
setup: func() string {
|
||||
symlink := filepath.Join(workspace, "link.txt")
|
||||
_ = os.Symlink(outsideFile, symlink)
|
||||
return symlink
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "symlink pointing inside workspace",
|
||||
setup: func() string {
|
||||
inside := filepath.Join(workspace, "inside.txt")
|
||||
_ = os.WriteFile(inside, []byte("ok"), 0600)
|
||||
symlink := filepath.Join(workspace, "link_inside.txt")
|
||||
_ = os.Symlink(inside, symlink)
|
||||
return symlink
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "dotfile inside workspace",
|
||||
setup: func() string {
|
||||
return filepath.Join(workspace, ".gitignore")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "hidden directory inside workspace",
|
||||
setup: func() string {
|
||||
return filepath.Join(workspace, ".git/config")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := tt.setup()
|
||||
result := cfg.IsPathAllowed(path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsPathAllowed(%q) = %v, want %v", path, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPathAllowed_BasicCases tests basic path validation.
|
||||
func TestIsPathAllowed_BasicCases(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &Config{
|
||||
WorkspaceRoot: tmpDir,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "path inside workspace",
|
||||
path: filepath.Join(tmpDir, "file.txt"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "path outside workspace",
|
||||
path: "/etc/passwd",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "parent directory reference",
|
||||
path: filepath.Join(tmpDir, "../../../etc/passwd"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "workspace root itself",
|
||||
path: tmpDir,
|
||||
expected: false, // Empty relative path
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := cfg.IsPathAllowed(tt.path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsPathAllowed(%q) = %v, want %v", tt.path, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user