459 lines
11 KiB
Go
459 lines
11 KiB
Go
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"
|
|
}
|
|
}
|