feat(config): add security feature toggle support

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.
This commit is contained in:
2026-05-03 23:07:21 +03:00
parent 290a3d15db
commit 43baade930
15 changed files with 78 additions and 12 deletions

View File

@@ -64,11 +64,11 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
}
ip := app.clientIP(ctx)
guard := app.securityGuard
if guard == nil {
if app.securityFeaturesEnabled() && guard == nil {
guard = security.NewGuard()
app.securityGuard = guard
}
if !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
"ErrorMessage": "Too many failed attempts. Try again later.",
@@ -80,7 +80,7 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
password := ctx.PostForm("password")
if username != app.config.AdminUsername || password != app.config.AdminPassword {
if !guard.IsAdminWhitelisted(ip) {
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) {
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
if banned {

View File

@@ -1,6 +1,7 @@
package server
import (
"fmt"
"net"
"net/http"
"path/filepath"
@@ -27,14 +28,24 @@ type adminSecurityActionRequest struct {
BanUntil string `json:"ban_until"`
}
func (app *App) reloadSecurityConfig() {
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 app.config != nil {
_ = app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger"))
if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil {
return fmt.Errorf("enable ban persistence: %w", err)
}
_ = app.securityGuard.Reload(security.Config{
if err := app.securityGuard.Reload(security.Config{
IPWhitelist: app.config.SecurityIPWhitelist,
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
@@ -45,7 +56,14 @@ func (app *App) reloadSecurityConfig() {
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) {
@@ -85,6 +103,10 @@ func (app *App) createAlert(title string, severity string, group string, code st
func (app *App) securityMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
if !app.securityFeaturesEnabled() {
ctx.Next()
return
}
if app.securityGuard == nil {
ctx.Next()
return
@@ -104,6 +126,10 @@ func (app *App) securityMiddleware() gin.HandlerFunc {
}
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
@@ -148,6 +174,10 @@ func (app *App) handleAdminActivity(ctx *gin.Context) {
}
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 {
@@ -215,6 +245,10 @@ func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message str
}
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

View File

@@ -111,7 +111,9 @@ func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) {
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
}
app.reloadSecurityConfig()
if err := app.reloadSecurityConfig(); err != nil {
t.Fatalf("reload security config: %v", err)
}
t.Cleanup(func() { _ = app.securityGuard.Close() })
router := gin.New()

View File

@@ -269,7 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
app.reloadSecurityConfig()
if err := app.reloadSecurityConfig(); err != nil {
return nil, nil, err
}
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
@@ -437,7 +439,7 @@ func settingsCategoryForKey(key string) string {
return "downloads"
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
return "retention"
case config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
case config.SettingSecurityEnabled, config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
return "security"
case config.SettingActivityRetentionSeconds:
return "activity"
@@ -476,6 +478,7 @@ func settingsDescription(key string) string {
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.",
config.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.",
config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.",
config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.",
config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.",

View File

@@ -265,6 +265,7 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_SECURITY_ENABLED",
"WARPBOX_SECURITY_IP_WHITELIST",
"WARPBOX_SECURITY_ADMIN_IP_WHITELIST",
"WARPBOX_TRUSTED_PROXY_CIDRS",

View File

@@ -51,7 +51,9 @@ func Run(addr string) error {
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
}
app.reloadSecurityConfig()
if err := app.reloadSecurityConfig(); err != nil {
return err
}
router := gin.Default()
router.Use(app.securityMiddleware())

View File

@@ -156,6 +156,9 @@ func (app *App) maxRequestBodyBytes() int64 {
}
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
if !app.securityFeaturesEnabled() || app.securityGuard == nil {
return true
}
ip := app.clientIP(ctx)
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
return true