package handlers import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "syscall" "github.com/gin-gonic/gin" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" ) type PermissionChecker interface { Check(path, required string) util.PermissionCheck } type OSChecker struct{} func (OSChecker) Check(path, required string) util.PermissionCheck { return util.CheckPathPermissions(path, required) } type SystemPermissionsHandler struct { cfg config.Config checker PermissionChecker securityService *services.SecurityService } type permissionsPathSpec struct { Path string Required string } type permissionsRepairRequest struct { Paths []string `json:"paths" binding:"required,min=1"` GroupMode bool `json:"group_mode"` } type permissionsRepairResult struct { Path string `json:"path"` Status string `json:"status"` OwnerUID int `json:"owner_uid,omitempty"` OwnerGID int `json:"owner_gid,omitempty"` ModeBefore string `json:"mode_before,omitempty"` ModeAfter string `json:"mode_after,omitempty"` Message string `json:"message,omitempty"` ErrorCode string `json:"error_code,omitempty"` } func NewSystemPermissionsHandler(cfg config.Config, securityService *services.SecurityService, checker PermissionChecker) *SystemPermissionsHandler { if checker == nil { checker = OSChecker{} } return &SystemPermissionsHandler{ cfg: cfg, checker: checker, securityService: securityService, } } func (h *SystemPermissionsHandler) GetPermissions(c *gin.Context) { if !requireAdmin(c) { h.logAudit(c, "permissions_diagnostics", "blocked", "permissions_admin_only", 0) return } paths := h.defaultPaths() results := make([]util.PermissionCheck, 0, len(paths)) for _, spec := range paths { results = append(results, h.checker.Check(spec.Path, spec.Required)) } h.logAudit(c, "permissions_diagnostics", "ok", "", len(results)) c.JSON(http.StatusOK, gin.H{"paths": results}) } func (h *SystemPermissionsHandler) RepairPermissions(c *gin.Context) { if !requireAdmin(c) { h.logAudit(c, "permissions_repair", "blocked", "permissions_admin_only", 0) return } if !h.cfg.SingleContainer { h.logAudit(c, "permissions_repair", "blocked", "permissions_repair_disabled", 0) c.JSON(http.StatusForbidden, gin.H{ "error": "repair disabled", "error_code": "permissions_repair_disabled", }) return } if os.Geteuid() != 0 { h.logAudit(c, "permissions_repair", "blocked", "permissions_non_root", 0) c.JSON(http.StatusForbidden, gin.H{ "error": "root privileges required", "error_code": "permissions_non_root", }) return } var req permissionsRepairRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } results := make([]permissionsRepairResult, 0, len(req.Paths)) allowlist := h.allowlistRoots() for _, rawPath := range req.Paths { result := h.repairPath(rawPath, req.GroupMode, allowlist) results = append(results, result) } h.logAudit(c, "permissions_repair", "ok", "", len(results)) c.JSON(http.StatusOK, gin.H{"paths": results}) } func (h *SystemPermissionsHandler) repairPath(rawPath string, groupMode bool, allowlist []string) permissionsRepairResult { cleanPath, invalidCode := normalizePath(rawPath) if invalidCode != "" { return permissionsRepairResult{ Path: rawPath, Status: "error", ErrorCode: invalidCode, Message: "invalid path", } } normalizedAllowlist := normalizeAllowlist(allowlist) if !isWithinAllowlist(cleanPath, normalizedAllowlist) { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_outside_allowlist", Message: "path outside allowlist", } } info, err := os.Lstat(cleanPath) if err != nil { if os.IsNotExist(err) { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_missing_path", Message: "path does not exist", } } return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_repair_failed", Message: err.Error(), } } if info.Mode()&os.ModeSymlink != 0 { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_symlink_rejected", Message: "symlink not allowed", } } hasSymlinkComponent, symlinkErr := pathHasSymlink(cleanPath) if symlinkErr != nil { if os.IsNotExist(symlinkErr) { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_missing_path", Message: "path does not exist", } } return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_repair_failed", Message: symlinkErr.Error(), } } if hasSymlinkComponent { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_symlink_rejected", Message: "symlink not allowed", } } resolved, err := filepath.EvalSymlinks(cleanPath) if err != nil { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_repair_failed", Message: err.Error(), } } if !isWithinAllowlist(resolved, normalizedAllowlist) { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_outside_allowlist", Message: "path outside allowlist", } } if !info.IsDir() && !info.Mode().IsRegular() { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_unsupported_type", Message: "unsupported path type", } } uid := os.Geteuid() gid := os.Getegid() modeBefore := fmt.Sprintf("%04o", info.Mode().Perm()) modeAfter := targetMode(info.IsDir(), groupMode) alreadyOwned := isOwnedBy(info, uid, gid) alreadyMode := modeBefore == modeAfter if alreadyOwned && alreadyMode { return permissionsRepairResult{ Path: cleanPath, Status: "skipped", OwnerUID: uid, OwnerGID: gid, ModeBefore: modeBefore, ModeAfter: modeAfter, Message: "ownership and mode already correct", ErrorCode: "permissions_repair_skipped", } } if err := os.Chown(cleanPath, uid, gid); err != nil { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: mapRepairErrorCode(err), Message: err.Error(), } } parsedMode, parseErr := parseMode(modeAfter) if parseErr != nil { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: "permissions_repair_failed", Message: parseErr.Error(), } } if err := os.Chmod(cleanPath, parsedMode); err != nil { return permissionsRepairResult{ Path: cleanPath, Status: "error", ErrorCode: mapRepairErrorCode(err), Message: err.Error(), } } return permissionsRepairResult{ Path: cleanPath, Status: "repaired", OwnerUID: uid, OwnerGID: gid, ModeBefore: modeBefore, ModeAfter: modeAfter, Message: "ownership and mode updated", } } func (h *SystemPermissionsHandler) defaultPaths() []permissionsPathSpec { dataRoot := filepath.Dir(h.cfg.DatabasePath) return []permissionsPathSpec{ {Path: dataRoot, Required: "rwx"}, {Path: h.cfg.DatabasePath, Required: "rw"}, {Path: filepath.Join(dataRoot, "backups"), Required: "rwx"}, {Path: filepath.Join(dataRoot, "imports"), Required: "rwx"}, {Path: filepath.Join(dataRoot, "caddy"), Required: "rwx"}, {Path: filepath.Join(dataRoot, "crowdsec"), Required: "rwx"}, {Path: filepath.Join(dataRoot, "geoip"), Required: "rwx"}, {Path: h.cfg.ConfigRoot, Required: "rwx"}, {Path: h.cfg.CaddyLogDir, Required: "rwx"}, {Path: h.cfg.CrowdSecLogDir, Required: "rwx"}, {Path: h.cfg.PluginsDir, Required: "r-x"}, } } func (h *SystemPermissionsHandler) allowlistRoots() []string { dataRoot := filepath.Dir(h.cfg.DatabasePath) return []string{ dataRoot, h.cfg.ConfigRoot, h.cfg.CaddyLogDir, h.cfg.CrowdSecLogDir, } } func (h *SystemPermissionsHandler) logAudit(c *gin.Context, action, result, code string, pathCount int) { if h.securityService == nil { return } payload := map[string]any{ "result": result, "error_code": code, "path_count": pathCount, "admin": isAdmin(c), } payloadJSON, _ := json.Marshal(payload) actor := "unknown" if userID, ok := c.Get("userID"); ok { actor = fmt.Sprintf("%v", userID) } _ = h.securityService.LogAudit(&models.SecurityAudit{ Actor: actor, Action: action, EventCategory: "permissions", Details: string(payloadJSON), IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), }) } func normalizePath(rawPath string) (string, string) { if rawPath == "" { return "", "permissions_invalid_path" } if !filepath.IsAbs(rawPath) { return "", "permissions_invalid_path" } clean := filepath.Clean(rawPath) if clean == "." || clean == ".." { return "", "permissions_invalid_path" } if containsParentReference(clean) { return "", "permissions_invalid_path" } return clean, "" } func containsParentReference(clean string) bool { if clean == ".." { return true } if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { return true } if strings.Contains(clean, string(os.PathSeparator)+".."+string(os.PathSeparator)) { return true } return strings.HasSuffix(clean, string(os.PathSeparator)+"..") } func normalizeAllowlist(allowlist []string) []string { normalized := make([]string, 0, len(allowlist)) for _, root := range allowlist { if root == "" { continue } normalized = append(normalized, filepath.Clean(root)) } return normalized } func pathHasSymlink(path string) (bool, error) { clean := filepath.Clean(path) parts := strings.Split(clean, string(os.PathSeparator)) current := string(os.PathSeparator) for _, part := range parts { if part == "" { continue } current = filepath.Join(current, part) info, err := os.Lstat(current) if err != nil { return false, err } if info.Mode()&os.ModeSymlink != 0 { return true, nil } } return false, nil } func isWithinAllowlist(path string, allowlist []string) bool { for _, root := range allowlist { rel, err := filepath.Rel(root, path) if err != nil { continue } if rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") { return true } } return false } func targetMode(isDir, groupMode bool) string { if isDir { if groupMode { return "0770" } return "0700" } if groupMode { return "0660" } return "0600" } func parseMode(mode string) (os.FileMode, error) { if mode == "" { return 0, fmt.Errorf("mode required") } var parsed uint32 if _, err := fmt.Sscanf(mode, "%o", &parsed); err != nil { return 0, fmt.Errorf("parse mode: %w", err) } return os.FileMode(parsed), nil } func isOwnedBy(info os.FileInfo, uid, gid int) bool { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return false } return int(stat.Uid) == uid && int(stat.Gid) == gid } func mapRepairErrorCode(err error) string { switch { case err == nil: return "" case errors.Is(err, syscall.EROFS): return "permissions_readonly" case errors.Is(err, syscall.EACCES) || os.IsPermission(err): return "permissions_write_denied" default: return "permissions_repair_failed" } }