chore: git cache cleanup
This commit is contained in:
458
backend/internal/api/handlers/system_permissions_handler.go
Normal file
458
backend/internal/api/handlers/system_permissions_handler.go
Normal file
@@ -0,0 +1,458 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user