feat(admin): add security and activity management features

This commit is contained in:
2026-05-01 13:10:23 +03:00
parent dd8dd7cdc2
commit 88ab6e808b
26 changed files with 2208 additions and 262 deletions

116
lib/activity/activity.go Normal file
View File

@@ -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)
}

151
lib/alerts/alerts.go Normal file
View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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)

217
lib/security/guard.go Normal file
View File

@@ -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
}

View File

@@ -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),
})
}

View File

@@ -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)
}

View File

@@ -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]
}

89
lib/server/ip.go Normal file
View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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
}