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:
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
1
run.sh
1
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}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user