Open alerts
-5
+{{ .OpenCount }}
Requires attention
diff --git a/TO-DO.md b/TO-DO.md new file mode 100644 index 0000000..f996782 --- /dev/null +++ b/TO-DO.md @@ -0,0 +1,114 @@ +# WarpBox Security TO-DO + +## 1) High Priority (Do Next) + +- [ ] Persist IP bans across restarts + - Current: bans stored in-memory (`lib/security/guard.go`) + - Target: durable store in `DBDir` (similar style to `activity`/`alerts`) + - Include: startup load, expiry cleanup, atomic writes, corruption-safe fallback + +- [ ] Add trusted proxy CIDR config + - Current: forwarded headers trusted only when remote hop is private/local (`lib/server/ip.go`) + - Risk: heuristic-only trust model + - Target: + - `WARPBOX_TRUSTED_PROXY_CIDRS` setting + - trust `X-Forwarded-For` only when `RemoteAddr` in trusted CIDR + - fallback to direct remote IP otherwise + +- [ ] Add CIDR/range support for whitelists + - Current: exact IP match only (`WARPBOX_SECURITY_IP_WHITELIST`, `WARPBOX_SECURITY_ADMIN_IP_WHITELIST`) + - Target: support exact IP + CIDR entries + - Include strict parser + validation errors in settings save + +- [ ] Add unban / ban edit API audit trail hardening + - Ensure all manual ban/unban/ban-until actions always write: + - activity event + - alert (or policy-based selective alerting) + - Add tests for these paths + +## 2) Medium Priority + +- [ ] GeoIP integration for security detail pane + - Current: placeholder fields in `/admin/security` + - Target: wire geoipfast provider for country/region/ASN fields + - Add caching + timeout/failure-safe behavior + +- [ ] Expand malicious path detection rules + - Current: simple substring checks in `handleNoRoute` + - Target: + - rule list/pattern config + - normalize URL + decode checks + - classify severity by signature group + +- [ ] Add global abuse score per IP + - Combine signals: + - failed admin auth + - malicious path scans + - upload abuse + - Use score to escalate ban duration automatically + +- [ ] Ban duration policy ladder + - Current: fixed `WARPBOX_SECURITY_BAN_SECONDS` + - Target: + - progressive durations (e.g., 30m, 2h, 24h) + - reset after quiet period + +- [ ] Add security settings validation UX + - Ensure invalid values (negative, malformed lists, invalid CIDR) rejected with clear UI errors + - Add server tests for malformed security override payloads + +## 3) Admin UX Follow-Ups + +- [ ] Add dedicated “Active Bans” page-level controls + - bulk unban + - filter/sort by expiry and IP + - copy IP and quick search in activity/alerts + +- [ ] Add “why banned” detail + - link ban entry to latest triggering events and alerts + - show counts in active windows (login/scan/upload) + +- [ ] Add optional confirmation modal for destructive security actions + - unban all / bulk unban / long custom bans + +## 4) Testing & QA + +- [ ] Add unit tests for `lib/security/guard.go` + - `Ban`, `BanUntil`, `Unban`, `BanList` expiry pruning + - login/scan threshold behavior + - upload rate limiting behavior + +- [ ] Add tests for real-IP resolution edge cases (`lib/server/ip.go`) + - direct client + - trusted proxy chain + - spoofed forwarding headers from untrusted remote + +- [ ] Add integration tests for security endpoints + - `/admin/security/actions` ban/ban_until/unban + - `/admin/alerts/actions` + - admin login brute-force auto-ban flow + +- [ ] Add concurrency/race test pass in CI + - run `go test ./... -race` in workflow (where Go toolchain available) + +## 5) Operational / Deployment + +- [ ] Document reverse-proxy setup requirements + - Caddy / ingress config examples for forwarding headers + - guidance for trusted proxy CIDRs + +- [ ] Add security runbook + - how to investigate alerts + - how to ban/unban safely + - how to tune thresholds for low/high traffic environments + +- [ ] Add metrics hooks (future) + - counts: blocked requests, bans issued, unbans, alert volume + - expose to Prometheus-compatible endpoint later + +## 6) Nice-to-Have (Later) + +- [ ] Optional external enforcement bridge (fail2ban-compatible log format) +- [ ] Webhook notifications for high-severity security alerts +- [ ] Per-account/API-key limits once account system matures + diff --git a/lib/activity/activity.go b/lib/activity/activity.go new file mode 100644 index 0000000..7727934 --- /dev/null +++ b/lib/activity/activity.go @@ -0,0 +1,116 @@ +package activity + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Event struct { + ID string `json:"id"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Message string `json:"message"` + Actor string `json:"actor"` + IP string `json:"ip"` + Path string `json:"path"` + Method string `json:"method"` + CreatedAt time.Time `json:"created_at"` + Meta map[string]string `json:"meta,omitempty"` +} + +type Store struct { + path string + mu sync.Mutex +} + +func NewStore(path string) *Store { + return &Store{path: path} +} + +func (s *Store) Append(event Event, retentionSeconds int64) error { + s.mu.Lock() + defer s.mu.Unlock() + + events, err := s.readLocked() + if err != nil { + return err + } + if event.CreatedAt.IsZero() { + event.CreatedAt = time.Now().UTC() + } + if event.ID == "" { + event.ID = event.CreatedAt.Format("20060102T150405.000000000") + } + + events = append(events, event) + events = pruneByRetention(events, retentionSeconds) + return s.writeLocked(events) +} + +func (s *Store) List(limit int, retentionSeconds int64) ([]Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + + events, err := s.readLocked() + if err != nil { + return nil, err + } + events = pruneByRetention(events, retentionSeconds) + if err := s.writeLocked(events); err != nil { + return nil, err + } + sort.Slice(events, func(i, j int) bool { + return events[i].CreatedAt.After(events[j].CreatedAt) + }) + if limit > 0 && len(events) > limit { + return events[:limit], nil + } + return events, nil +} + +func pruneByRetention(events []Event, retentionSeconds int64) []Event { + if retentionSeconds <= 0 { + return events + } + cutoff := time.Now().UTC().Add(-time.Duration(retentionSeconds) * time.Second) + out := make([]Event, 0, len(events)) + for _, event := range events { + if event.CreatedAt.IsZero() || event.CreatedAt.After(cutoff) { + out = append(out, event) + } + } + return out +} + +func (s *Store) readLocked() ([]Event, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return []Event{}, nil + } + return nil, err + } + if len(data) == 0 { + return []Event{}, nil + } + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return []Event{}, nil + } + return events, nil +} + +func (s *Store) writeLocked(events []Event) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(events, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} diff --git a/lib/alerts/alerts.go b/lib/alerts/alerts.go new file mode 100644 index 0000000..a065a0a --- /dev/null +++ b/lib/alerts/alerts.go @@ -0,0 +1,151 @@ +package alerts + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "time" +) + +type Status string + +const ( + StatusOpen Status = "open" + StatusAcked Status = "acked" + StatusClosed Status = "closed" +) + +type Alert struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status Status `json:"status"` + Group string `json:"group"` + Code string `json:"code"` + Trace string `json:"trace"` + Message string `json:"message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Meta map[string]string `json:"meta,omitempty"` +} + +type Store struct { + path string + mu sync.Mutex +} + +func NewStore(path string) *Store { + return &Store{path: path} +} + +func (s *Store) Add(alert Alert) error { + s.mu.Lock() + defer s.mu.Unlock() + + alertsList, err := s.readLocked() + if err != nil { + return err + } + now := time.Now().UTC() + if alert.ID == "" { + alert.ID = strconv.FormatInt(now.UnixNano(), 10) + } + if alert.Status == "" { + alert.Status = StatusOpen + } + if alert.CreatedAt.IsZero() { + alert.CreatedAt = now + } + alert.UpdatedAt = now + alertsList = append(alertsList, alert) + return s.writeLocked(alertsList) +} + +func (s *Store) List(limit int) ([]Alert, error) { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return nil, err + } + sort.Slice(alertsList, func(i, j int) bool { + return alertsList[i].CreatedAt.After(alertsList[j].CreatedAt) + }) + if limit > 0 && len(alertsList) > limit { + return alertsList[:limit], nil + } + return alertsList, nil +} + +func (s *Store) SetStatus(ids []string, status Status) error { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return err + } + target := map[string]bool{} + for _, id := range ids { + target[id] = true + } + now := time.Now().UTC() + for i := range alertsList { + if target[alertsList[i].ID] { + alertsList[i].Status = status + alertsList[i].UpdatedAt = now + } + } + return s.writeLocked(alertsList) +} + +func (s *Store) Delete(ids []string) error { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return err + } + target := map[string]bool{} + for _, id := range ids { + target[id] = true + } + kept := make([]Alert, 0, len(alertsList)) + for _, alert := range alertsList { + if !target[alert.ID] { + kept = append(kept, alert) + } + } + return s.writeLocked(kept) +} + +func (s *Store) readLocked() ([]Alert, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return []Alert{}, nil + } + return nil, err + } + if len(data) == 0 { + return []Alert{}, nil + } + var alertsList []Alert + if err := json.Unmarshal(data, &alertsList); err != nil { + return []Alert{}, nil + } + return alertsList, nil +} + +func (s *Store) writeLocked(alertsList []Alert) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(alertsList, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} diff --git a/lib/config/definitions.go b/lib/config/definitions.go index 9f03059..f3d6ecc 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -20,6 +20,17 @@ var Definitions = []SettingDefinition{ {Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {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: 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: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityScanWindowSecs, EnvName: "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", Label: "Malicious path window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityScanMaxAttempts, EnvName: "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", Label: "Malicious path max attempts", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityUploadWindowSecs, EnvName: "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", Label: "Upload limit window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityUploadMaxRequests, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", Label: "Upload max requests per window", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityUploadMaxGB, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_GB", Label: "Upload max total GB per window", Type: SettingTypeSizeGB, Editable: true, Minimum: 0}, } func (cfg *Config) SettingRows() []SettingRow { diff --git a/lib/config/load.go b/lib/config/load.go index eebde15..6f0cbcd 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -26,6 +26,15 @@ func Load() (*Config, error) { BoxPollIntervalMS: 5000, ThumbnailBatchSize: 10, ThumbnailIntervalSeconds: 30, + ActivityRetentionSeconds: 7 * 24 * 60 * 60, + SecurityLoginWindowSeconds: 10 * 60, + SecurityLoginMaxAttempts: 8, + SecurityBanSeconds: 30 * 60, + SecurityScanWindowSeconds: 5 * 60, + SecurityScanMaxAttempts: 12, + SecurityUploadWindowSeconds: 60, + SecurityUploadMaxRequests: 20, + SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024, sources: make(map[string]Source), values: make(map[string]string), defaults: make(map[string]string), @@ -47,6 +56,12 @@ func Load() (*Config, error) { if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil { return nil, err } + if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil { + return nil, err + } + if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil { + return nil, err + } if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" { mode := AdminEnabledMode(strings.ToLower(raw)) if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { @@ -90,6 +105,11 @@ func Load() (*Config, error) { {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, + {SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds}, + {SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds}, + {SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds}, + {SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds}, + {SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds}, } for _, item := range envInt64s { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { @@ -107,6 +127,7 @@ func Load() (*Config, error) { {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes}, + {SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes}, } for _, item := range sizeEnvVars { if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil { @@ -123,6 +144,9 @@ func Load() (*Config, error) { {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, + {SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts}, + {SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts}, + {SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests}, } for _, item := range envInts { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { @@ -172,6 +196,17 @@ func (cfg *Config) captureDefaults() { cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS)) cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize)) cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds)) + cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist) + cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist) + cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts)) + cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityScanWindowSecs, strconv.FormatInt(cfg.SecurityScanWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityScanMaxAttempts, strconv.Itoa(cfg.SecurityScanMaxAttempts)) + cfg.captureDefaultValue(SettingSecurityUploadWindowSecs, strconv.FormatInt(cfg.SecurityUploadWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityUploadMaxRequests, strconv.Itoa(cfg.SecurityUploadMaxRequests)) + cfg.captureDefaultValue(SettingSecurityUploadMaxGB, formatGigabytesFromBytes(cfg.SecurityUploadMaxBytes)) } func (cfg *Config) captureDefaultValue(key string, value string) { diff --git a/lib/config/models.go b/lib/config/models.go index 118aa3a..d471cd5 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -36,6 +36,17 @@ const ( SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingDataDir = "data_dir" + SettingActivityRetentionSeconds = "activity_retention_seconds" + SettingSecurityIPWhitelist = "security_ip_whitelist" + SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist" + SettingSecurityLoginWindowSecs = "security_login_window_seconds" + SettingSecurityLoginMaxAttempts = "security_login_max_attempts" + SettingSecurityBanSeconds = "security_ban_seconds" + SettingSecurityScanWindowSecs = "security_scan_window_seconds" + SettingSecurityScanMaxAttempts = "security_scan_max_attempts" + SettingSecurityUploadWindowSecs = "security_upload_window_seconds" + SettingSecurityUploadMaxRequests = "security_upload_max_requests" + SettingSecurityUploadMaxGB = "security_upload_max_gb" ) type SettingType string @@ -95,6 +106,17 @@ type Config struct { BoxPollIntervalMS int ThumbnailBatchSize int ThumbnailIntervalSeconds int + ActivityRetentionSeconds int64 + SecurityIPWhitelist string + SecurityAdminIPWhitelist string + SecurityLoginWindowSeconds int64 + SecurityLoginMaxAttempts int + SecurityBanSeconds int64 + SecurityScanWindowSeconds int64 + SecurityScanMaxAttempts int + SecurityUploadWindowSeconds int64 + SecurityUploadMaxRequests int + SecurityUploadMaxBytes int64 sources map[string]Source values map[string]string diff --git a/lib/config/overrides.go b/lib/config/overrides.go index bd2f449..79bd351 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -51,6 +51,8 @@ func (cfg *Config) ApplyOverride(key string, value string) error { return fmt.Errorf("%s: %w", key, err) } cfg.assignInt(key, int(parsed64), SourceDB) + case SettingTypeText: + cfg.assignText(key, value, SourceDB) default: return fmt.Errorf("setting %q is not runtime editable", key) } @@ -92,8 +94,20 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) { cfg.DefaultUserMaxBoxSizeBytes = value case SettingSessionTTLSeconds: cfg.SessionTTLSeconds = value + case SettingActivityRetentionSeconds: + cfg.ActivityRetentionSeconds = value + case SettingSecurityLoginWindowSecs: + cfg.SecurityLoginWindowSeconds = value + case SettingSecurityBanSeconds: + cfg.SecurityBanSeconds = value + case SettingSecurityScanWindowSecs: + cfg.SecurityScanWindowSeconds = value + case SettingSecurityUploadWindowSecs: + cfg.SecurityUploadWindowSeconds = value + case SettingSecurityUploadMaxGB: + cfg.SecurityUploadMaxBytes = value } - if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes { + if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB { cfg.setValue(key, formatGigabytesFromBytes(value), source) return } @@ -108,10 +122,26 @@ func (cfg *Config) assignInt(key string, value int, source Source) { cfg.ThumbnailBatchSize = value case SettingThumbnailIntervalSeconds: cfg.ThumbnailIntervalSeconds = value + case SettingSecurityLoginMaxAttempts: + cfg.SecurityLoginMaxAttempts = value + case SettingSecurityScanMaxAttempts: + cfg.SecurityScanMaxAttempts = value + case SettingSecurityUploadMaxRequests: + cfg.SecurityUploadMaxRequests = value } cfg.setValue(key, strconv.Itoa(value), source) } +func (cfg *Config) assignText(key string, value string, source Source) { + switch key { + case SettingSecurityIPWhitelist: + cfg.SecurityIPWhitelist = value + case SettingSecurityAdminIPWhitelist: + cfg.SecurityAdminIPWhitelist = value + } + cfg.setValue(key, value, source) +} + func (cfg *Config) setValue(key string, value string, source Source) { if key == "" { return diff --git a/lib/routing/routes.go b/lib/routing/routes.go index e1f1a0b..f60185f 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -26,6 +26,10 @@ type Handlers struct { AdminBoxes gin.HandlerFunc AdminBoxesAction gin.HandlerFunc AdminUsers gin.HandlerFunc + AdminActivity gin.HandlerFunc + AdminSecurity gin.HandlerFunc + AdminAlertsAction gin.HandlerFunc + AdminSecurityAction gin.HandlerFunc AdminSettings gin.HandlerFunc AdminSettingsExport gin.HandlerFunc AdminSettingsSave gin.HandlerFunc @@ -62,9 +66,13 @@ func Register(router *gin.Engine, handlers Handlers) { protected := router.Group("/admin", handlers.AdminAuth) protected.GET("/dashboard", handlers.AdminDashboard) protected.GET("/alerts", handlers.AdminAlerts) + protected.POST("/alerts/actions", handlers.AdminAlertsAction) protected.GET("/boxes", handlers.AdminBoxes) protected.POST("/boxes/actions", handlers.AdminBoxesAction) protected.GET("/users", handlers.AdminUsers) + protected.GET("/activity", handlers.AdminActivity) + protected.GET("/security", handlers.AdminSecurity) + protected.POST("/security/actions", handlers.AdminSecurityAction) protected.GET("/settings", handlers.AdminSettings) protected.GET("/settings/export", handlers.AdminSettingsExport) protected.POST("/settings/save", handlers.AdminSettingsSave) diff --git a/lib/security/guard.go b/lib/security/guard.go new file mode 100644 index 0000000..568d1f2 --- /dev/null +++ b/lib/security/guard.go @@ -0,0 +1,217 @@ +package security + +import ( + "sort" + "strings" + "sync" + "time" +) + +type Config struct { + IPWhitelist string + AdminIPWhitelist string + LoginWindowSeconds int64 + LoginMaxAttempts int + BanSeconds int64 + ScanWindowSeconds int64 + ScanMaxAttempts int + UploadWindowSeconds int64 + UploadMaxRequests int + UploadMaxBytes int64 +} + +type Guard struct { + mu sync.Mutex + failedLogins map[string][]time.Time + scanAttempts map[string][]time.Time + uploadEvents map[string][]uploadEvent + bannedUntil map[string]time.Time + ipWhitelist map[string]bool + adminWhitelist map[string]bool +} + +type uploadEvent struct { + at time.Time + bytes int64 +} + +type BanEntry struct { + IP string `json:"ip"` + Until time.Time `json:"until"` +} + +func NewGuard() *Guard { + return &Guard{ + failedLogins: map[string][]time.Time{}, + scanAttempts: map[string][]time.Time{}, + uploadEvents: map[string][]uploadEvent{}, + bannedUntil: map[string]time.Time{}, + ipWhitelist: map[string]bool{}, + adminWhitelist: map[string]bool{}, + } +} + +func (g *Guard) Reload(cfg Config) { + g.mu.Lock() + defer g.mu.Unlock() + g.ipWhitelist = parseList(cfg.IPWhitelist) + g.adminWhitelist = parseList(cfg.AdminIPWhitelist) +} + +func (g *Guard) IsWhitelisted(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.ipWhitelist[ip] +} + +func (g *Guard) IsAdminWhitelisted(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.adminWhitelist[ip] || g.ipWhitelist[ip] +} + +func (g *Guard) IsBanned(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + until, ok := g.bannedUntil[ip] + if !ok { + return false + } + if time.Now().UTC().After(until) { + delete(g.bannedUntil, ip) + return false + } + return true +} + +func (g *Guard) Ban(ip string, seconds int64) { + if seconds <= 0 || ip == "" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second) +} + +func (g *Guard) BanUntil(ip string, until time.Time) { + if ip == "" || until.IsZero() { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.bannedUntil[ip] = until.UTC() +} + +func (g *Guard) Unban(ip string) { + if ip == "" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + delete(g.bannedUntil, ip) +} + +func (g *Guard) BanList() []BanEntry { + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + out := make([]BanEntry, 0, len(g.bannedUntil)) + for ip, until := range g.bannedUntil { + if now.After(until) { + delete(g.bannedUntil, ip) + continue + } + out = append(out, BanEntry{IP: ip, Until: until}) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Until.Before(out[j].Until) + }) + return out +} + +func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) { + if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 { + return false, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + attempts := pruneTimes(g.failedLogins[ip], cutoff) + attempts = append(attempts, now) + g.failedLogins[ip] = attempts + if len(attempts) >= maxAttempts { + g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second) + return true, len(attempts) + } + return false, len(attempts) +} + +func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) { + if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 { + return false, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + attempts := pruneTimes(g.scanAttempts[ip], cutoff) + attempts = append(attempts, now) + g.scanAttempts[ip] = attempts + if len(attempts) >= maxAttempts { + g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second) + return true, len(attempts) + } + return false, len(attempts) +} + +func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxRequests int, maxBytes int64) (bool, int, int64) { + if ip == "" || windowSeconds <= 0 || maxRequests <= 0 { + return true, 0, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + events := g.uploadEvents[ip] + kept := make([]uploadEvent, 0, len(events)+1) + totalBytes := int64(0) + for _, event := range events { + if event.at.After(cutoff) { + kept = append(kept, event) + totalBytes += event.bytes + } + } + nextCount := len(kept) + 1 + nextBytes := totalBytes + size + if nextCount > maxRequests { + return false, nextCount, nextBytes + } + if maxBytes > 0 && nextBytes > maxBytes { + return false, nextCount, nextBytes + } + kept = append(kept, uploadEvent{at: now, bytes: size}) + g.uploadEvents[ip] = kept + return true, nextCount, nextBytes +} + +func pruneTimes(values []time.Time, cutoff time.Time) []time.Time { + kept := make([]time.Time, 0, len(values)) + for _, value := range values { + if value.After(cutoff) { + kept = append(kept, value) + } + } + return kept +} + +func parseList(raw string) map[string]bool { + out := map[string]bool{} + for _, chunk := range strings.Split(raw, ",") { + value := strings.TrimSpace(chunk) + if value != "" { + out[value] = true + } + } + return out +} diff --git a/lib/server/admin.go b/lib/server/admin.go index 8ed3ecf..42754ae 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -2,11 +2,14 @@ package server import ( "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" + "warpbox/lib/alerts" "warpbox/lib/config" + "warpbox/lib/security" ) const adminSessionCookie = "warpbox_admin_session" @@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, "/") return } + ip := clientIP(ctx) + guard := app.securityGuard + if guard == nil { + guard = security.NewGuard() + app.securityGuard = guard + } + if !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.", + }) + return + } username := strings.TrimSpace(ctx.PostForm("username")) password := ctx.PostForm("password") if username != app.config.AdminUsername || password != app.config.AdminPassword { + if !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 { + app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)}) + app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)}) + } + } ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{ "ErrorMessage": "Invalid username or password.", }) return } + app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil) secure := app.config.AdminCookieSecure maxAge := int(app.config.SessionTTLSeconds) @@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) { return } + alertsList := []alerts.Alert{} + if app.alertStore != nil { + var err error + alertsList, err = app.alertStore.List(500) + if err != nil { + ctx.String(http.StatusInternalServerError, "Could not load alerts") + return + } + } + openCount := 0 + highCount := 0 + ackedCount := 0 + closedCount := 0 + for _, alert := range alertsList { + switch string(alert.Status) { + case "open": + openCount++ + case "acked": + ackedCount++ + case "closed": + closedCount++ + } + if alert.Severity == "high" && string(alert.Status) != "closed" { + highCount++ + } + } + ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, "ActivePage": "alerts", + "Alerts": alertsList, + "OpenCount": strconv.Itoa(openCount), + "HighCount": strconv.Itoa(highCount), + "AckCount": strconv.Itoa(ackedCount), + "ClosedCount": strconv.Itoa(closedCount), }) } diff --git a/lib/server/admin_security.go b/lib/server/admin_security.go new file mode 100644 index 0000000..9d80caa --- /dev/null +++ b/lib/server/admin_security.go @@ -0,0 +1,258 @@ +package server + +import ( + "net" + "net/http" + "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"` + BanUntil string `json:"ban_until"` +} + +func (app *App) reloadSecurityConfig() { + if app.securityGuard == nil { + app.securityGuard = security.NewGuard() + } + 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, + }) +} + +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 = 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.securityGuard == nil { + ctx.Next() + return + } + ip := 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.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 := 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) { + events := []activity.Event{} + alertsList := []alerts.Alert{} + if app.activityStore != nil { + events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds) + } + if app.alertStore != nil { + alertsList, _ = app.alertStore.List(50) + } + 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) handleAdminSecurityAction(ctx *gin.Context) { + 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.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip}) + app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip}) + 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) + app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)}) + app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)}) + 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.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip}) + app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip}) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()}) + default: + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) + } +} + +func intToString(value int) string { + return strconv.Itoa(value) +} diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go index 77e81fc..2af7655 100644 --- a/lib/server/admin_settings.go +++ b/lib/server/admin_settings.go @@ -269,6 +269,7 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti app.config = nextCfg applyBoxstoreRuntimeConfig(app.config) + app.reloadSecurityConfig() rows, _ := app.buildAdminSettingsRows() return rows, warnings, nil } @@ -399,6 +400,8 @@ func settingsCategoryMeta() []settingsCategoryInfo { {Key: "uploads", Label: "Uploads", Icon: "↥"}, {Key: "downloads", Label: "Downloads", Icon: "↧"}, {Key: "retention", Label: "Retention", Icon: "⌛"}, + {Key: "security", Label: "Security", Icon: "🔒"}, + {Key: "activity", Label: "Activity", Icon: "☰"}, {Key: "accounts", Label: "Accounts", Icon: "☺"}, {Key: "api", Label: "API", Icon: "{ }"}, {Key: "storage", Label: "Storage", Icon: "▥"}, @@ -428,10 +431,16 @@ func settingsCategoryForKey(key string) string { switch key { case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes: return "uploads" + case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB: + return "uploads" case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled: 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: + return "security" + case config.SettingActivityRetentionSeconds: + return "activity" case config.SettingSessionTTLSeconds: return "accounts" case config.SettingAPIEnabled: @@ -466,6 +475,17 @@ func settingsDescription(key string) string { config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.", 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.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.", + config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.", + config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.", + config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.", + config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.", + config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.", + config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.", + config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.", } return descriptions[key] } diff --git a/lib/server/ip.go b/lib/server/ip.go new file mode 100644 index 0000000..606d943 --- /dev/null +++ b/lib/server/ip.go @@ -0,0 +1,89 @@ +package server + +import ( + "net" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func clientIP(ctx *gin.Context) string { + if ctx == nil || ctx.Request == nil { + return "" + } + remoteIP := remoteAddrIP(ctx.Request) + + // Only trust forwarding headers when remote hop looks like local/internal proxy. + if isPrivateOrLoopback(remoteIP) { + for _, candidate := range headerIPs(ctx.Request.Header) { + if isPublicIP(candidate) { + return candidate + } + } + candidates := headerIPs(ctx.Request.Header) + if len(candidates) > 0 { + return candidates[0] + } + } + return remoteIP +} + +func headerIPs(header http.Header) []string { + keys := []string{ + "X-Forwarded-For", + "X-Real-Ip", + "CF-Connecting-IP", + "X-Envoy-External-Address", + "Fly-Client-IP", + } + out := make([]string, 0, 4) + seen := map[string]bool{} + for _, key := range keys { + raw := strings.TrimSpace(header.Get(key)) + if raw == "" { + continue + } + for _, part := range strings.Split(raw, ",") { + ip := normalizeIP(strings.TrimSpace(part)) + if ip == "" || seen[ip] { + continue + } + seen[ip] = true + out = append(out, ip) + } + } + return out +} + +func remoteAddrIP(request *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr)) + if err != nil { + return normalizeIP(strings.TrimSpace(request.RemoteAddr)) + } + return normalizeIP(host) +} + +func normalizeIP(raw string) string { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return "" + } + return ip.String() +} + +func isPublicIP(value string) bool { + ip := net.ParseIP(value) + if ip == nil || !ip.IsGlobalUnicast() { + return false + } + return !isPrivateOrLoopback(value) +} + +func isPrivateOrLoopback(value string) bool { + ip := net.ParseIP(value) + if ip == nil { + return true + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} diff --git a/lib/server/server.go b/lib/server/server.go index d9cfcbf..5743faf 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -9,14 +9,20 @@ import ( "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" + "warpbox/lib/activity" + "warpbox/lib/alerts" "warpbox/lib/boxstore" "warpbox/lib/config" "warpbox/lib/routing" + "warpbox/lib/security" ) type App struct { config *config.Config settingsOverridesPath string + activityStore *activity.Store + alertStore *alerts.Store + securityGuard *security.Guard } func Run(addr string) error { @@ -38,9 +44,18 @@ func Run(addr string) error { applyBoxstoreRuntimeConfig(cfg) - app := &App{config: cfg, settingsOverridesPath: overridesPath} + app := &App{ + config: cfg, + settingsOverridesPath: overridesPath, + activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")), + alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")), + securityGuard: security.NewGuard(), + } + app.reloadSecurityConfig() router := gin.Default() + router.Use(app.securityMiddleware()) + router.NoRoute(app.handleNoRoute) htmlTemplates, err := loadHTMLTemplates() if err != nil { return err @@ -71,6 +86,10 @@ func Run(addr string) error { AdminBoxes: app.handleAdminBoxes, AdminBoxesAction: app.handleAdminBoxesAction, AdminUsers: app.handleAdminUsers, + AdminActivity: app.handleAdminActivity, + AdminSecurity: app.handleAdminSecurity, + AdminAlertsAction: app.handleAdminAlertsAction, + AdminSecurityAction: app.handleAdminSecurityAction, AdminSettings: app.handleAdminSettings, AdminSettingsExport: app.handleAdminSettingsExport, AdminSettingsSave: app.handleAdminSettingsSave, diff --git a/lib/server/uploads.go b/lib/server/uploads.go index 33f1b2e..d50777e 100644 --- a/lib/server/uploads.go +++ b/lib/server/uploads.go @@ -39,6 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + totalSize := int64(0) + for _, file := range request.Files { + totalSize += file.Size + } + if !app.enforceUploadRateLimit(ctx, totalSize) { + return + } files, err := boxstore.CreateManifest(boxID, request) if err != nil { @@ -73,6 +80,10 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, file.Size) { + boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) + return + } savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file) if err != nil { @@ -141,6 +152,9 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, file.Size) { + return + } savedFile, err := boxstore.SaveUpload(boxID, file) if err != nil { @@ -180,6 +194,9 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, totalSize) { + return + } boxID, err := boxstore.NewBoxID() if err != nil { diff --git a/lib/server/validation.go b/lib/server/validation.go index 1aa41bd..84aa533 100644 --- a/lib/server/validation.go +++ b/lib/server/validation.go @@ -3,6 +3,7 @@ package server import ( "fmt" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -153,3 +154,36 @@ func (app *App) maxRequestBodyBytes() int64 { } return limit + 10*1024*1024 } + +func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool { + ip := clientIP(ctx) + if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { + return true + } + allowed, requestCount, totalBytes := app.securityGuard.AllowUpload( + ip, + size, + app.config.SecurityUploadWindowSeconds, + app.config.SecurityUploadMaxRequests, + app.config.SecurityUploadMaxBytes, + ) + if allowed { + return true + } + + app.logActivity("security.upload_limit", "high", "Upload rate limit exceeded", ctx, map[string]string{ + "requests": strconv.Itoa(requestCount), + "bytes": strconv.FormatInt(totalBytes, 10), + }) + app.createAlert( + "Upload rate limit triggered", + "medium", + "security", + "430", + "security.upload.rate_limit", + "Per-IP upload rate limit blocked request.", + map[string]string{"ip": ip, "requests": strconv.Itoa(requestCount)}, + ) + ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many uploads from this IP. Try again later."}) + return false +} diff --git a/run.sh b/run.sh index dbc2fe8..6242105 100755 --- a/run.sh +++ b/run.sh @@ -25,6 +25,17 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172 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_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}" +export WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS="${WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS:-8}" +export WARPBOX_SECURITY_BAN_SECONDS="${WARPBOX_SECURITY_BAN_SECONDS:-1800}" +export WARPBOX_SECURITY_SCAN_WINDOW_SECONDS="${WARPBOX_SECURITY_SCAN_WINDOW_SECONDS:-300}" +export WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS="${WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS:-12}" +export WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS="${WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS:-60}" +export WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS="${WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS:-20}" +export WARPBOX_SECURITY_UPLOAD_MAX_GB="${WARPBOX_SECURITY_UPLOAD_MAX_GB:-10}" # Data location. export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}" diff --git a/static/css/activity.css b/static/css/activity.css new file mode 100644 index 0000000..76f127a --- /dev/null +++ b/static/css/activity.css @@ -0,0 +1,63 @@ +.activity-page-body { display: grid; gap: 10px; } +.activity-panel { + min-height: 0; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 10px; +} +.activity-toolbar-grid { + display: grid; + grid-template-columns: minmax(220px, 1.2fr) minmax(130px, .4fr) minmax(150px, .5fr); + gap: 8px; + margin-bottom: 8px; +} +.activity-input, +.activity-select { + width: 100%; + min-width: 0; + height: 28px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} +.activity-table-wrap { + min-height: 420px; + height: 520px; + overflow: auto; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} +.activity-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + line-height: 14px; +} +.activity-table th, +.activity-table td { + padding: 6px; + border-bottom: 1px solid #e1e1e1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.activity-table th { + position: sticky; + top: 0; + background: #dfdfdf; + z-index: 2; +} diff --git a/static/css/security.css b/static/css/security.css new file mode 100644 index 0000000..bc9b68d --- /dev/null +++ b/static/css/security.css @@ -0,0 +1,115 @@ +.security-page-body { display: grid; gap: 10px; } +.security-grid { display: grid; grid-template-columns: minmax(260px, .65fr) minmax(0, 1.35fr); gap: 10px; } +.security-panel { + min-height: 0; + display: flex; + flex-direction: column; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; +} +.security-panel-header { + min-height: 34px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; +} +.security-panel-header span { color: #444444; font-size: 12px; } +.security-panel-body { flex: 1 1 auto; min-height: 0; padding: 10px; overflow: auto; } +.security-field { display: grid; gap: 4px; font-size: 12px; } +.security-input { + width: 100%; + min-width: 0; + height: 28px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} +.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; } +.security-note { + margin-top: 8px; + padding: 8px; + color: #000000; + background: #ffffcc; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a08000; + border-bottom: 1px solid #a08000; + font-size: 12px; + line-height: 15px; +} +.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; } +.security-ban-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr); + gap: 10px; +} +.security-bans-wrap { height: 220px; min-height: 220px; } +.security-ip-detail { + min-height: 0; + padding: 10px; + background: #f5f5f5; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; +} +.security-ip-detail h3 { + margin: 0 0 8px; + font-size: 16px; + line-height: 16px; +} +.security-ip-detail ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 6px; + font-size: 12px; +} +.security-bans-body-row.is-selected { background: #c5dcff; } +.security-table-wrap { + min-height: 280px; + height: 320px; + overflow: auto; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} +.security-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + line-height: 14px; +} +.security-table th, +.security-table td { + padding: 6px; + border-bottom: 1px solid #e1e1e1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.security-table th { position: sticky; top: 0; background: #dfdfdf; z-index: 2; } + +@media (max-width: 980px) { + .security-grid, + .security-ban-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/js/admin/activity.js b/static/js/admin/activity.js new file mode 100644 index 0000000..c45dc62 --- /dev/null +++ b/static/js/admin/activity.js @@ -0,0 +1,104 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} }; + const dataNode = document.getElementById("activity-data"); + const body = document.getElementById("activity-body"); + const searchInput = document.getElementById("activity-search"); + const severityFilter = document.getElementById("activity-severity"); + const kindFilter = document.getElementById("activity-kind"); + const statusLeft = document.getElementById("activity-status-left"); + const toast = document.getElementById("toast"); + + if (!dataNode || !body || !searchInput) return; + const events = parseData(); + + function parseData() { + try { + return JSON.parse(dataNode.textContent || "[]"); + } catch (_) { + return []; + } + } + + function showToast(message, type = "info", duration = 1800) { + window.WarpBoxUI?.toast?.(message, type, { target: toast, duration }); + } + + function renderKindFilter() { + const kinds = new Set(events.map((event) => event.kind || "unknown")); + Array.from(kinds).sort().forEach((kind) => { + const option = document.createElement("option"); + option.value = kind; + option.textContent = kind; + kindFilter.appendChild(option); + }); + } + + function createdLabel(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return "-"; + return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC"; + } + + function filtered() { + const query = searchInput.value.trim().toLowerCase(); + const severity = severityFilter.value; + const kind = kindFilter.value; + return events.filter((event) => { + const haystack = [event.kind, event.message, event.ip, event.path, event.method].join(" ").toLowerCase(); + const matchesQuery = !query || haystack.includes(query); + const matchesSeverity = severity === "all" || event.severity === severity; + const matchesKind = kind === "all" || event.kind === kind; + return matchesQuery && matchesSeverity && matchesKind; + }); + } + + function render() { + const rows = filtered(); + body.innerHTML = ""; + rows.forEach((event) => { + const row = document.createElement("tr"); + row.innerHTML = ` +
| Time | +Kind | +Severity | +IP | +Method | +Path | +Message | +
|---|
Open alerts
-5
+{{ .OpenCount }}
Requires attention
High severity
-2
+{{ .HighCount }}
Escalate first
Acknowledged
-3
+{{ .AckCount }}
Seen but not closed
Closed today
-2
+{{ .ClosedCount }}
History stays lightweight