Implements a master toggle for security features across config, CLI, and application logic. This allows granular control over whether the advanced security middleware and protections are active globally.
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"warpbox/lib/activity"
|
|
"warpbox/lib/alerts"
|
|
"warpbox/lib/security"
|
|
)
|
|
|
|
type adminAlertsActionRequest struct {
|
|
Action string `json:"action"`
|
|
IDs []string `json:"ids"`
|
|
}
|
|
|
|
type adminSecurityActionRequest struct {
|
|
Action string `json:"action"`
|
|
IP string `json:"ip"`
|
|
IPs []string `json:"ips"`
|
|
BanUntil string `json:"ban_until"`
|
|
}
|
|
|
|
func (app *App) reloadSecurityConfig() error {
|
|
if app == nil || app.config == nil {
|
|
return fmt.Errorf("app or config is nil")
|
|
}
|
|
if !app.securityFeaturesEnabled() {
|
|
if app.securityGuard != nil {
|
|
_ = app.securityGuard.Close()
|
|
}
|
|
app.securityGuard = nil
|
|
return nil
|
|
}
|
|
if app.securityGuard == nil {
|
|
app.securityGuard = security.NewGuard()
|
|
}
|
|
if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil {
|
|
return fmt.Errorf("enable ban persistence: %w", err)
|
|
}
|
|
if err := app.securityGuard.Reload(security.Config{
|
|
IPWhitelist: app.config.SecurityIPWhitelist,
|
|
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
|
|
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
|
|
LoginMaxAttempts: app.config.SecurityLoginMaxAttempts,
|
|
BanSeconds: app.config.SecurityBanSeconds,
|
|
ScanWindowSeconds: app.config.SecurityScanWindowSeconds,
|
|
ScanMaxAttempts: app.config.SecurityScanMaxAttempts,
|
|
UploadWindowSeconds: app.config.SecurityUploadWindowSeconds,
|
|
UploadMaxRequests: app.config.SecurityUploadMaxRequests,
|
|
UploadMaxBytes: app.config.SecurityUploadMaxBytes,
|
|
}); err != nil {
|
|
return fmt.Errorf("reload guard config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (app *App) securityFeaturesEnabled() bool {
|
|
return app != nil && app.config != nil && app.config.SecurityEnabled
|
|
}
|
|
|
|
func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) {
|
|
if app.activityStore == nil {
|
|
return
|
|
}
|
|
event := activity.Event{
|
|
Kind: kind,
|
|
Severity: severity,
|
|
Message: message,
|
|
CreatedAt: time.Now().UTC(),
|
|
Meta: meta,
|
|
}
|
|
if ctx != nil {
|
|
event.IP = app.clientIP(ctx)
|
|
event.Path = ctx.Request.URL.Path
|
|
event.Method = ctx.Request.Method
|
|
}
|
|
_ = app.activityStore.Append(event, app.config.ActivityRetentionSeconds)
|
|
}
|
|
|
|
func (app *App) createAlert(title string, severity string, group string, code string, trace string, message string, meta map[string]string) {
|
|
if app.alertStore == nil {
|
|
return
|
|
}
|
|
_ = app.alertStore.Add(alerts.Alert{
|
|
Title: title,
|
|
Severity: severity,
|
|
Group: group,
|
|
Code: code,
|
|
Trace: trace,
|
|
Message: message,
|
|
Status: alerts.StatusOpen,
|
|
Meta: meta,
|
|
})
|
|
}
|
|
|
|
func (app *App) securityMiddleware() gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
if !app.securityFeaturesEnabled() {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
if app.securityGuard == nil {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
ip := app.clientIP(ctx)
|
|
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
if app.securityGuard.IsBanned(ip) {
|
|
app.logActivity("security.block", "high", "Blocked banned IP", ctx, nil)
|
|
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many abusive requests. Try again later."})
|
|
return
|
|
}
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
func (app *App) handleNoRoute(ctx *gin.Context) {
|
|
if !app.securityFeaturesEnabled() {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
return
|
|
}
|
|
if app.securityGuard == nil {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
return
|
|
}
|
|
path := strings.ToLower(ctx.Request.URL.Path)
|
|
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
|
|
if suspicious {
|
|
ip := app.clientIP(ctx)
|
|
if !app.securityGuard.IsWhitelisted(ip) {
|
|
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds)
|
|
app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)})
|
|
if banned {
|
|
app.createAlert("IP auto-banned after malicious path scans", "high", "security", "410", "security.scan.autoban", "Repeated malicious path scans triggered temporary ban.", map[string]string{"ip": ip, "attempts": intToString(attempts)})
|
|
app.logActivity("security.ban", "high", "IP auto-banned after scans", ctx, map[string]string{"attempts": intToString(attempts)})
|
|
}
|
|
}
|
|
}
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
|
}
|
|
|
|
func (app *App) handleAdminActivity(ctx *gin.Context) {
|
|
if app.activityStore == nil {
|
|
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
|
"AdminUsername": app.config.AdminUsername,
|
|
"AdminEmail": app.config.AdminEmail,
|
|
"ActivePage": "activity",
|
|
"Events": []activity.Event{},
|
|
})
|
|
return
|
|
}
|
|
events, err := app.activityStore.List(400, app.config.ActivityRetentionSeconds)
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not load activity")
|
|
return
|
|
}
|
|
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
|
"AdminUsername": app.config.AdminUsername,
|
|
"AdminEmail": app.config.AdminEmail,
|
|
"ActivePage": "activity",
|
|
"Events": events,
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminSecurity(ctx *gin.Context) {
|
|
if !app.securityFeaturesEnabled() {
|
|
ctx.String(http.StatusNotFound, "Security features are disabled")
|
|
return
|
|
}
|
|
events := []activity.Event{}
|
|
alertsList := []alerts.Alert{}
|
|
if app.activityStore != nil {
|
|
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
|
|
}
|
|
if app.alertStore != nil {
|
|
alertsList, _ = app.alertStore.List(120)
|
|
}
|
|
bans := []security.BanEntry{}
|
|
if app.securityGuard != nil {
|
|
bans = app.securityGuard.BanList()
|
|
}
|
|
ctx.HTML(http.StatusOK, "admin/security.html", gin.H{
|
|
"AdminUsername": app.config.AdminUsername,
|
|
"AdminEmail": app.config.AdminEmail,
|
|
"ActivePage": "security",
|
|
"Events": events,
|
|
"Alerts": alertsList,
|
|
"Bans": bans,
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
|
|
if app.alertStore == nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Alert store unavailable"})
|
|
return
|
|
}
|
|
var request adminAlertsActionRequest
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
|
return
|
|
}
|
|
switch request.Action {
|
|
case "ack":
|
|
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusAcked); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
|
return
|
|
}
|
|
case "close":
|
|
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusClosed); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
|
return
|
|
}
|
|
case "delete":
|
|
if err := app.alertStore.Delete(request.IDs); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete alerts"})
|
|
return
|
|
}
|
|
default:
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
|
return
|
|
}
|
|
app.logActivity("alerts.action", "low", "Admin changed alert state", ctx, map[string]string{"action": request.Action, "count": intToString(len(request.IDs))})
|
|
alertsList, _ := app.alertStore.List(500)
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList})
|
|
}
|
|
|
|
func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) {
|
|
metaCopy := map[string]string{"ip": ip}
|
|
for k, v := range meta {
|
|
metaCopy[k] = v
|
|
}
|
|
app.logActivity(kind, severity, message, ctx, metaCopy)
|
|
app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy)
|
|
}
|
|
|
|
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
|
if !app.securityFeaturesEnabled() {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Security features are disabled"})
|
|
return
|
|
}
|
|
if app.securityGuard == nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
|
return
|
|
}
|
|
var request adminSecurityActionRequest
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
|
return
|
|
}
|
|
ip := strings.TrimSpace(request.IP)
|
|
if ip != "" && net.ParseIP(ip) == nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
|
|
return
|
|
}
|
|
|
|
switch request.Action {
|
|
case "ban":
|
|
if ip == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
|
return
|
|
}
|
|
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
|
|
app.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.")
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
|
|
case "ban_until":
|
|
if ip == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
|
return
|
|
}
|
|
until, err := time.Parse(time.RFC3339, strings.TrimSpace(request.BanUntil))
|
|
if err != nil || until.Before(time.Now().UTC()) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ban expiration"})
|
|
return
|
|
}
|
|
app.securityGuard.BanUntil(ip, until)
|
|
meta := map[string]string{"until": until.UTC().Format(time.RFC3339)}
|
|
app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.")
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
|
|
case "unban":
|
|
if ip == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
|
return
|
|
}
|
|
app.securityGuard.Unban(ip)
|
|
app.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.")
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
|
case "bulk_unban":
|
|
if len(request.IPs) == 0 {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"})
|
|
return
|
|
}
|
|
count := 0
|
|
for _, candidate := range request.IPs {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if net.ParseIP(candidate) == nil {
|
|
continue
|
|
}
|
|
app.securityGuard.Unban(candidate)
|
|
count++
|
|
}
|
|
app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)})
|
|
app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)})
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()})
|
|
case "unban_all":
|
|
current := app.securityGuard.BanList()
|
|
for _, ban := range current {
|
|
app.securityGuard.Unban(ban.IP)
|
|
}
|
|
count := len(current)
|
|
app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)})
|
|
app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)})
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()})
|
|
default:
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
|
}
|
|
}
|
|
|
|
func intToString(value int) string {
|
|
return strconv.Itoa(value)
|
|
}
|