security.go
1 package fileserver 2 3 import ( 4 "errors" 5 "fmt" 6 "os" 7 "path/filepath" 8 "regexp" 9 "strings" 10 11 "github.com/easyshell-org/easyshell/easyshell-agent/internal/config" 12 ) 13 14 var ( 15 ErrPathTraversal = errors.New("path traversal detected") 16 ErrAccessDenied = errors.New("access denied by security rules") 17 ErrSymlinkTraversal = errors.New("symlink traversal detected") 18 ErrReadOnly = errors.New("file system is read-only") 19 ErrFileTooLarge = errors.New("file exceeds maximum size") 20 ) 21 22 var DefaultBlacklist = []string{ 23 `^/etc/shadow$`, 24 `^/etc/gshadow$`, 25 `^/etc/sudoers$`, 26 `^/etc/sudoers\.d/`, 27 `^/etc/ssh/ssh_host_`, 28 `/\.ssh/id_`, 29 `/\.ssh/authorized_keys$`, 30 `/\.gnupg/`, 31 `/\.aws/credentials$`, 32 `/\.aws/config$`, 33 `/\.kube/config$`, 34 `/\.docker/config\.json$`, 35 `/\.[a-z]*_history$`, 36 `/\.env$`, 37 `/\.env\.`, 38 `/secrets?\.(ya?ml|json|toml|properties|conf)$`, 39 `/credentials?\.(ya?ml|json|toml|properties|conf)$`, 40 `/\.git/config$`, 41 `/\.gitconfig$`, 42 `/\.netrc$`, 43 `/\.pgpass$`, 44 `/private[_-]?key`, 45 `\.pem$`, 46 `\.key$`, 47 } 48 49 type SecurityConfig struct { 50 Enabled bool 51 RootPath string 52 ReadOnly bool 53 MaxFileSize int64 54 MaxListEntries int 55 BlacklistRegexp []*regexp.Regexp 56 WhitelistPaths []string 57 } 58 59 func NewSecurityConfig(cfg *config.FileConfig) *SecurityConfig { 60 sc := &SecurityConfig{ 61 Enabled: cfg.Enabled, 62 RootPath: filepath.Clean(cfg.Root), 63 ReadOnly: cfg.ReadOnly, 64 MaxFileSize: int64(cfg.MaxSizeMB) * 1024 * 1024, 65 MaxListEntries: cfg.MaxListEntries, 66 WhitelistPaths: cfg.Whitelist, 67 } 68 69 for _, pattern := range DefaultBlacklist { 70 if re, err := regexp.Compile(pattern); err == nil { 71 sc.BlacklistRegexp = append(sc.BlacklistRegexp, re) 72 } 73 } 74 for _, pattern := range cfg.Blacklist { 75 if re, err := regexp.Compile(pattern); err == nil { 76 sc.BlacklistRegexp = append(sc.BlacklistRegexp, re) 77 } 78 } 79 80 return sc 81 } 82 83 func (sc *SecurityConfig) ValidatePath(requestedPath string) (string, error) { 84 cleaned := filepath.Clean(requestedPath) 85 absPath := filepath.Clean(filepath.Join(sc.RootPath, cleaned)) 86 87 rootClean := filepath.Clean(sc.RootPath) 88 // Special case: when root is "/", all absolute paths are valid under root 89 if rootClean == "/" { 90 if !filepath.IsAbs(absPath) { 91 return "", ErrPathTraversal 92 } 93 } else { 94 if !strings.HasPrefix(absPath, rootClean+string(os.PathSeparator)) && absPath != rootClean { 95 return "", ErrPathTraversal 96 } 97 } 98 99 if _, err := os.Lstat(absPath); err == nil { 100 evalPath, err := filepath.EvalSymlinks(absPath) 101 if err == nil { 102 evalClean := filepath.Clean(evalPath) 103 if rootClean != "/" { 104 if !strings.HasPrefix(evalClean, rootClean+string(os.PathSeparator)) && evalClean != rootClean { 105 return "", ErrSymlinkTraversal 106 } 107 } 108 } 109 } 110 111 for _, re := range sc.BlacklistRegexp { 112 if re.MatchString(absPath) { 113 return "", ErrAccessDenied 114 } 115 } 116 117 if len(sc.WhitelistPaths) > 0 { 118 allowed := false 119 for _, w := range sc.WhitelistPaths { 120 wClean := filepath.Clean(w) 121 if strings.HasPrefix(absPath, wClean+string(os.PathSeparator)) || absPath == wClean { 122 allowed = true 123 break 124 } 125 } 126 if !allowed { 127 return "", ErrAccessDenied 128 } 129 } 130 131 return absPath, nil 132 } 133 134 func (sc *SecurityConfig) ValidateWrite(requestedPath string) (string, error) { 135 if sc.ReadOnly { 136 return "", ErrReadOnly 137 } 138 return sc.ValidatePath(requestedPath) 139 } 140 141 func (sc *SecurityConfig) ValidateFileSize(size int64) error { 142 if size > sc.MaxFileSize { 143 return fmt.Errorf("%w: %d > %d", ErrFileTooLarge, size, sc.MaxFileSize) 144 } 145 return nil 146 }