diff --git a/lib/config/config_test.go b/lib/config/config_test.go index 3b32d59..790f990 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -22,6 +22,9 @@ func TestDefaults(t *testing.T) { if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled { t.Fatal("expected default guest/API/download toggles to be enabled") } + if !cfg.SecurityEnabled { + t.Fatal("expected security features to be enabled by default") + } if cfg.AdminUsername != "admin" { t.Fatalf("unexpected admin username: %s", cfg.AdminUsername) } @@ -39,6 +42,7 @@ func TestEnvironmentOverrides(t *testing.T) { t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") + t.Setenv("WARPBOX_SECURITY_ENABLED", "false") cfg, err := Load() if err != nil { @@ -63,6 +67,9 @@ func TestEnvironmentOverrides(t *testing.T) { if !cfg.OneTimeDownloadRetryOnFailure { t.Fatal("expected one-time retry-on-failure env override to be applied") } + if cfg.SecurityEnabled { + t.Fatal("expected security features toggle from environment to be applied") + } if cfg.Source(SettingAPIEnabled) != SourceEnv { t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled)) } @@ -191,6 +198,7 @@ func clearConfigEnv(t *testing.T) { "WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", + "WARPBOX_SECURITY_ENABLED", } { t.Setenv(name, "") } diff --git a/lib/config/definitions.go b/lib/config/definitions.go index f4aba9f..ff67ead 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -21,6 +21,7 @@ var Definitions = []SettingDefinition{ {Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, + {Key: SettingSecurityEnabled, EnvName: "WARPBOX_SECURITY_ENABLED", Label: "Security features enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true}, {Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true}, {Key: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true}, diff --git a/lib/config/load.go b/lib/config/load.go index 08e37a7..cdf6acf 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -27,6 +27,7 @@ func Load() (*Config, error) { ThumbnailBatchSize: 10, ThumbnailIntervalSeconds: 30, ActivityRetentionSeconds: 7 * 24 * 60 * 60, + SecurityEnabled: true, SecurityLoginWindowSeconds: 10 * 60, SecurityLoginMaxAttempts: 8, SecurityBanSeconds: 30 * 60, @@ -91,6 +92,7 @@ func Load() (*Config, error) { {SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, + {SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled}, } for _, item := range envBools { if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil { @@ -209,6 +211,7 @@ func (cfg *Config) captureDefaults() { cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize)) cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds)) cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityEnabled, formatBool(cfg.SecurityEnabled)) cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist) cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist) cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs) diff --git a/lib/config/models.go b/lib/config/models.go index 7397733..367cb4f 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -37,6 +37,7 @@ const ( SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingDataDir = "data_dir" SettingActivityRetentionSeconds = "activity_retention_seconds" + SettingSecurityEnabled = "security_enabled" SettingSecurityIPWhitelist = "security_ip_whitelist" SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist" SettingTrustedProxyCIDRs = "trusted_proxy_cidrs" @@ -108,6 +109,7 @@ type Config struct { ThumbnailBatchSize int ThumbnailIntervalSeconds int ActivityRetentionSeconds int64 + SecurityEnabled bool SecurityIPWhitelist string SecurityAdminIPWhitelist string TrustedProxyCIDRs string diff --git a/lib/config/overrides.go b/lib/config/overrides.go index 17387b2..0bb2f55 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -95,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) { cfg.RenewOnAccessEnabled = value case SettingRenewOnDownloadEnabled: cfg.RenewOnDownloadEnabled = value + case SettingSecurityEnabled: + cfg.SecurityEnabled = value } cfg.setValue(key, formatBool(value), source) } diff --git a/lib/server/admin.go b/lib/server/admin.go index 4c36de6..1f95896 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -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 { diff --git a/lib/server/admin_security.go b/lib/server/admin_security.go index 4563fd0..5fbb3a9 100644 --- a/lib/server/admin_security.go +++ b/lib/server/admin_security.go @@ -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 diff --git a/lib/server/admin_security_test.go b/lib/server/admin_security_test.go index 4a10cdd..3634cfb 100644 --- a/lib/server/admin_security_test.go +++ b/lib/server/admin_security_test.go @@ -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() diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go index 2af7655..10f8a78 100644 --- a/lib/server/admin_settings.go +++ b/lib/server/admin_settings.go @@ -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.", diff --git a/lib/server/admin_settings_test.go b/lib/server/admin_settings_test.go index 1f0487a..36d393b 100644 --- a/lib/server/admin_settings_test.go +++ b/lib/server/admin_settings_test.go @@ -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", diff --git a/lib/server/server.go b/lib/server/server.go index 5743faf..b780543 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -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()) diff --git a/lib/server/validation.go b/lib/server/validation.go index 48debb5..b8cfdb1 100644 --- a/lib/server/validation.go +++ b/lib/server/validation.go @@ -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 diff --git a/run.sh b/run.sh index 6242105..1ef3674 100755 --- a/run.sh +++ b/run.sh @@ -26,6 +26,7 @@ export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}" export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}" export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}" export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}" +export WARPBOX_SECURITY_ENABLED="${WARPBOX_SECURITY_ENABLED:-true}" export WARPBOX_SECURITY_IP_WHITELIST="${WARPBOX_SECURITY_IP_WHITELIST:-}" export WARPBOX_SECURITY_ADMIN_IP_WHITELIST="${WARPBOX_SECURITY_ADMIN_IP_WHITELIST:-}" export WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS="${WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS:-600}" diff --git a/static/js/admin/activity.js b/static/js/admin/activity.js index c45dc62..e43d3bc 100644 --- a/static/js/admin/activity.js +++ b/static/js/admin/activity.js @@ -10,6 +10,8 @@ if (!dataNode || !body || !searchInput) return; const events = parseData(); + const initialQuery = new URLSearchParams(window.location.search).get("q"); + if (initialQuery) searchInput.value = initialQuery; function parseData() { try { diff --git a/static/js/admin/alerts.js b/static/js/admin/alerts.js index 2b0ad48..075300f 100644 --- a/static/js/admin/alerts.js +++ b/static/js/admin/alerts.js @@ -30,6 +30,8 @@ selected: new Set(), activeID: null }; + const initialQuery = new URLSearchParams(window.location.search).get("q"); + if (initialQuery) searchInput.value = initialQuery; function parseData() { try {