feat/security
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
116
lib/activity/activity.go
Normal file
116
lib/activity/activity.go
Normal 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
151
lib/alerts/alerts.go
Normal 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)
|
||||
}
|
||||
60
lib/boxstore/cleanup.go
Normal file
60
lib/boxstore/cleanup.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type CleanupExpiredResult struct {
|
||||
Scanned int
|
||||
Deleted int
|
||||
Skipped int
|
||||
DeletedIDs []string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func CleanupExpiredBoxes() (CleanupExpiredResult, error) {
|
||||
entries, err := os.ReadDir(uploadRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return CleanupExpiredResult{}, nil
|
||||
}
|
||||
return CleanupExpiredResult{}, err
|
||||
}
|
||||
|
||||
result := CleanupExpiredResult{
|
||||
DeletedIDs: make([]string, 0),
|
||||
Warnings: make([]string, 0),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
boxID := entry.Name()
|
||||
if !ValidBoxID(boxID) {
|
||||
continue
|
||||
}
|
||||
result.Scanned++
|
||||
|
||||
manifest, err := ReadManifest(boxID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
if !os.IsNotExist(err) {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !IsExpired(manifest) {
|
||||
continue
|
||||
}
|
||||
if err := DeleteBox(boxID); err != nil {
|
||||
result.Skipped++
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
|
||||
continue
|
||||
}
|
||||
result.Deleted++
|
||||
result.DeletedIDs = append(result.DeletedIDs, boxID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
58
lib/boxstore/cleanup_test.go
Normal file
58
lib/boxstore/cleanup_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func TestCleanupExpiredBoxesDeletesOnlyExpiredManifestBoxes(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "uploads")
|
||||
previousRoot := UploadRoot()
|
||||
t.Cleanup(func() { SetUploadRoot(previousRoot) })
|
||||
SetUploadRoot(root)
|
||||
|
||||
expiredID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
activeID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
legacyID := "cccccccccccccccccccccccccccccccc"
|
||||
|
||||
if err := os.MkdirAll(BoxPath(expiredID), 0755); err != nil {
|
||||
t.Fatalf("mkdir expired: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(BoxPath(activeID), 0755); err != nil {
|
||||
t.Fatalf("mkdir active: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(BoxPath(legacyID), 0755); err != nil {
|
||||
t.Fatalf("mkdir legacy: %v", err)
|
||||
}
|
||||
|
||||
if err := WriteManifest(expiredID, models.BoxManifest{CreatedAt: time.Now().UTC().Add(-2 * time.Hour), ExpiresAt: time.Now().UTC().Add(-time.Minute)}); err != nil {
|
||||
t.Fatalf("write expired manifest: %v", err)
|
||||
}
|
||||
if err := WriteManifest(activeID, models.BoxManifest{CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("write active manifest: %v", err)
|
||||
}
|
||||
|
||||
result, err := CleanupExpiredBoxes()
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed: %v", err)
|
||||
}
|
||||
if result.Deleted != 1 {
|
||||
t.Fatalf("expected 1 deleted box, got %d", result.Deleted)
|
||||
}
|
||||
if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != expiredID {
|
||||
t.Fatalf("expected deleted id %s, got %#v", expiredID, result.DeletedIDs)
|
||||
}
|
||||
if _, err := os.Stat(BoxPath(expiredID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected expired box dir removed, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(BoxPath(activeID)); err != nil {
|
||||
t.Fatalf("expected active box to remain, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(BoxPath(legacyID)); err != nil {
|
||||
t.Fatalf("expected legacy box to remain, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ func TestDefaults(t *testing.T) {
|
||||
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
||||
t.Fatal("expected default guest/API/download toggles to be enabled")
|
||||
}
|
||||
if !cfg.SecurityEnabled {
|
||||
t.Fatal("expected security features to be enabled by default")
|
||||
}
|
||||
if cfg.AdminUsername != "admin" {
|
||||
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||
}
|
||||
@@ -39,6 +42,7 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
||||
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||
t.Setenv("WARPBOX_SECURITY_ENABLED", "false")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -63,6 +67,9 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||
if !cfg.OneTimeDownloadRetryOnFailure {
|
||||
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
||||
}
|
||||
if cfg.SecurityEnabled {
|
||||
t.Fatal("expected security features toggle from environment to be applied")
|
||||
}
|
||||
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||
}
|
||||
@@ -191,6 +198,8 @@ func clearConfigEnv(t *testing.T) {
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
"WARPBOX_SECURITY_ENABLED",
|
||||
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
|
||||
@@ -20,6 +20,20 @@ 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: SettingSecurityEnabled, EnvName: "WARPBOX_SECURITY_ENABLED", Label: "Security features enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true},
|
||||
{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},
|
||||
{Key: SettingExpiredCleanupIntervalSecs, EnvName: "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", Label: "Expired boxes cleanup interval seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
}
|
||||
|
||||
func (cfg *Config) SettingRows() []SettingRow {
|
||||
|
||||
@@ -26,6 +26,17 @@ func Load() (*Config, error) {
|
||||
BoxPollIntervalMS: 5000,
|
||||
ThumbnailBatchSize: 10,
|
||||
ThumbnailIntervalSeconds: 30,
|
||||
ActivityRetentionSeconds: 7 * 24 * 60 * 60,
|
||||
SecurityEnabled: true,
|
||||
SecurityLoginWindowSeconds: 10 * 60,
|
||||
SecurityLoginMaxAttempts: 8,
|
||||
SecurityBanSeconds: 30 * 60,
|
||||
SecurityScanWindowSeconds: 5 * 60,
|
||||
SecurityScanMaxAttempts: 12,
|
||||
SecurityUploadWindowSeconds: 60,
|
||||
SecurityUploadMaxRequests: 20,
|
||||
SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024,
|
||||
ExpiredCleanupIntervalSeconds: 300,
|
||||
sources: make(map[string]Source),
|
||||
values: make(map[string]string),
|
||||
defaults: make(map[string]string),
|
||||
@@ -47,6 +58,15 @@ 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 err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); 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 {
|
||||
@@ -73,6 +93,7 @@ func Load() (*Config, error) {
|
||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||
{SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled},
|
||||
}
|
||||
for _, item := range envBools {
|
||||
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||
@@ -90,6 +111,12 @@ 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},
|
||||
{SettingExpiredCleanupIntervalSecs, "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", 0, &cfg.ExpiredCleanupIntervalSeconds},
|
||||
}
|
||||
for _, item := range envInt64s {
|
||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||
@@ -107,6 +134,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 +151,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 {
|
||||
@@ -138,6 +169,15 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||
}
|
||||
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||
if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||
@@ -172,6 +212,20 @@ 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(SettingSecurityEnabled, formatBool(cfg.SecurityEnabled))
|
||||
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs)
|
||||
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))
|
||||
cfg.captureDefaultValue(SettingExpiredCleanupIntervalSecs, strconv.FormatInt(cfg.ExpiredCleanupIntervalSeconds, 10))
|
||||
}
|
||||
|
||||
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||
|
||||
@@ -36,6 +36,20 @@ const (
|
||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||
SettingDataDir = "data_dir"
|
||||
SettingActivityRetentionSeconds = "activity_retention_seconds"
|
||||
SettingSecurityEnabled = "security_enabled"
|
||||
SettingSecurityIPWhitelist = "security_ip_whitelist"
|
||||
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
|
||||
SettingTrustedProxyCIDRs = "trusted_proxy_cidrs"
|
||||
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"
|
||||
SettingExpiredCleanupIntervalSecs = "expired_cleanup_interval_seconds"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
@@ -95,6 +109,20 @@ type Config struct {
|
||||
BoxPollIntervalMS int
|
||||
ThumbnailBatchSize int
|
||||
ThumbnailIntervalSeconds int
|
||||
ActivityRetentionSeconds int64
|
||||
SecurityEnabled bool
|
||||
SecurityIPWhitelist string
|
||||
SecurityAdminIPWhitelist string
|
||||
TrustedProxyCIDRs string
|
||||
SecurityLoginWindowSeconds int64
|
||||
SecurityLoginMaxAttempts int
|
||||
SecurityBanSeconds int64
|
||||
SecurityScanWindowSeconds int64
|
||||
SecurityScanMaxAttempts int
|
||||
SecurityUploadWindowSeconds int64
|
||||
SecurityUploadMaxRequests int
|
||||
SecurityUploadMaxBytes int64
|
||||
ExpiredCleanupIntervalSeconds int64
|
||||
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
|
||||
@@ -3,6 +3,9 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||
@@ -26,6 +29,11 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
if err := validateSecurityTextSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch def.Type {
|
||||
case SettingTypeBool:
|
||||
parsed, err := parseBool(value)
|
||||
@@ -51,11 +59,28 @@ 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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityTextSetting(key string, value string) error {
|
||||
switch key {
|
||||
case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist:
|
||||
if _, err := security.ParseIPMatchers(value, true); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
case SettingTrustedProxyCIDRs:
|
||||
if _, err := security.ParseCIDRList(value); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||
switch key {
|
||||
case SettingGuestUploadsEnabled:
|
||||
@@ -70,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||
cfg.RenewOnAccessEnabled = value
|
||||
case SettingRenewOnDownloadEnabled:
|
||||
cfg.RenewOnDownloadEnabled = value
|
||||
case SettingSecurityEnabled:
|
||||
cfg.SecurityEnabled = value
|
||||
}
|
||||
cfg.setValue(key, formatBool(value), source)
|
||||
}
|
||||
@@ -92,8 +119,22 @@ 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
|
||||
case SettingExpiredCleanupIntervalSecs:
|
||||
cfg.ExpiredCleanupIntervalSeconds = 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 +149,28 @@ 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
|
||||
case SettingTrustedProxyCIDRs:
|
||||
cfg.TrustedProxyCIDRs = value
|
||||
}
|
||||
cfg.setValue(key, value, source)
|
||||
}
|
||||
|
||||
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||
if key == "" {
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
426
lib/security/guard.go
Normal file
426
lib/security/guard.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
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 []ipMatcher
|
||||
adminWhitelist []ipMatcher
|
||||
banDB *badger.DB
|
||||
}
|
||||
|
||||
type ipMatcher struct {
|
||||
exact net.IP
|
||||
prefix *net.IPNet
|
||||
}
|
||||
|
||||
type uploadEvent struct {
|
||||
at time.Time
|
||||
bytes int64
|
||||
}
|
||||
|
||||
type BanEntry struct {
|
||||
IP string `json:"ip"`
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
const banKeyPrefix = "ban:"
|
||||
|
||||
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: []ipMatcher{},
|
||||
adminWhitelist: []ipMatcher{},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) Close() error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.banDB == nil {
|
||||
return nil
|
||||
}
|
||||
err := g.banDB.Close()
|
||||
g.banDB = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *Guard) EnableBanPersistence(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.banDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := badger.DefaultOptions(path)
|
||||
opts.Logger = nil
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
// Corruption-safe fallback: quarantine badger files and start fresh.
|
||||
_ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405"))
|
||||
db, err = badger.Open(opts)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.banDB = db
|
||||
|
||||
if err := g.loadBansLocked(); err != nil {
|
||||
_ = g.banDB.Close()
|
||||
g.banDB = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guard) Reload(cfg Config) error {
|
||||
ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip whitelist: %w", err)
|
||||
}
|
||||
adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin ip whitelist: %w", err)
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.ipWhitelist = ipWhitelist
|
||||
g.adminWhitelist = adminWhitelist
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guard) IsWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return matchIP(g.ipWhitelist, ip)
|
||||
}
|
||||
|
||||
func (g *Guard) IsAdminWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return matchIP(g.adminWhitelist, ip) || matchIP(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)
|
||||
g.deleteBanLocked(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()
|
||||
until := time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
}
|
||||
|
||||
func (g *Guard) BanUntil(ip string, until time.Time) {
|
||||
if ip == "" || until.IsZero() {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
until = until.UTC()
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
}
|
||||
|
||||
func (g *Guard) Unban(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.bannedUntil, ip)
|
||||
g.deleteBanLocked(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)
|
||||
g.deleteBanLocked(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 {
|
||||
until := now.Add(time.Duration(banSeconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
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 {
|
||||
until := now.Add(time.Duration(banSeconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
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 ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) {
|
||||
entries := []ipMatcher{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
if !allowCIDR {
|
||||
return nil, fmt.Errorf("%q must be a CIDR", value)
|
||||
}
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q", value)
|
||||
}
|
||||
entries = append(entries, ipMatcher{prefix: network})
|
||||
continue
|
||||
}
|
||||
parsed := net.ParseIP(value)
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("invalid IP %q", value)
|
||||
}
|
||||
entries = append(entries, ipMatcher{exact: parsed})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func ParseCIDRList(raw string) ([]net.IPNet, error) {
|
||||
entries := []net.IPNet{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q", value)
|
||||
}
|
||||
entries = append(entries, *network)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
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 matchIP(rules []ipMatcher, value string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(value))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.exact != nil && rule.exact.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
if rule.prefix != nil && rule.prefix.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Guard) saveBanLocked(ip string, until time.Time) {
|
||||
if g.banDB == nil || ip == "" || until.IsZero() {
|
||||
return
|
||||
}
|
||||
seconds := int64(time.Until(until).Seconds())
|
||||
if seconds <= 0 {
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete([]byte(banKeyPrefix + ip))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
value := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(value, uint64(until.Unix()))
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second)
|
||||
return txn.SetEntry(entry)
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Guard) deleteBanLocked(ip string) {
|
||||
if g.banDB == nil || ip == "" {
|
||||
return
|
||||
}
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete([]byte(banKeyPrefix + ip))
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Guard) loadBansLocked() error {
|
||||
if g.banDB == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
loaded := map[string]time.Time{}
|
||||
expired := [][]byte{}
|
||||
|
||||
err := g.banDB.View(func(txn *badger.Txn) error {
|
||||
it := txn.NewIterator(badger.DefaultIteratorOptions)
|
||||
defer it.Close()
|
||||
for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() {
|
||||
item := it.Item()
|
||||
key := string(item.Key())
|
||||
ip := strings.TrimPrefix(key, banKeyPrefix)
|
||||
err := item.Value(func(val []byte) error {
|
||||
if len(val) != 8 {
|
||||
expired = append(expired, append([]byte(nil), item.Key()...))
|
||||
return nil
|
||||
}
|
||||
unix := int64(binary.BigEndian.Uint64(val))
|
||||
until := time.Unix(unix, 0).UTC()
|
||||
if now.After(until) {
|
||||
expired = append(expired, append([]byte(nil), item.Key()...))
|
||||
return nil
|
||||
}
|
||||
loaded[ip] = until
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.bannedUntil = loaded
|
||||
if len(expired) == 0 {
|
||||
return nil
|
||||
}
|
||||
return g.banDB.Update(func(txn *badger.Txn) error {
|
||||
for _, key := range expired {
|
||||
if err := txn.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
52
lib/security/guard_test.go
Normal file
52
lib/security/guard_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGuardWhitelistSupportsIPAndCIDR(t *testing.T) {
|
||||
g := NewGuard()
|
||||
if err := g.Reload(Config{IPWhitelist: "203.0.113.10,10.0.0.0/8", AdminIPWhitelist: "192.168.1.0/24"}); err != nil {
|
||||
t.Fatalf("Reload returned error: %v", err)
|
||||
}
|
||||
if !g.IsWhitelisted("203.0.113.10") || !g.IsWhitelisted("10.2.3.4") {
|
||||
t.Fatal("expected IP and CIDR entries to match")
|
||||
}
|
||||
if !g.IsAdminWhitelisted("192.168.1.5") {
|
||||
t.Fatal("expected admin CIDR whitelist match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardBanPersistenceAcrossRestart(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "bans.badger")
|
||||
g1 := NewGuard()
|
||||
if err := g1.EnableBanPersistence(dir); err != nil {
|
||||
t.Fatalf("EnableBanPersistence returned error: %v", err)
|
||||
}
|
||||
g1.Ban("198.51.100.4", 3600)
|
||||
if err := g1.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
g2 := NewGuard()
|
||||
if err := g2.EnableBanPersistence(dir); err != nil {
|
||||
t.Fatalf("EnableBanPersistence returned error: %v", err)
|
||||
}
|
||||
defer g2.Close()
|
||||
if !g2.IsBanned("198.51.100.4") {
|
||||
t.Fatal("expected ban to persist across guard restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardBanListPrunesExpired(t *testing.T) {
|
||||
g := NewGuard()
|
||||
g.BanUntil("198.51.100.7", time.Now().UTC().Add(-time.Minute))
|
||||
if g.IsBanned("198.51.100.7") {
|
||||
t.Fatal("expected expired ban to be treated as inactive")
|
||||
}
|
||||
if len(g.BanList()) != 0 {
|
||||
t.Fatal("expected BanList to prune expired entries")
|
||||
}
|
||||
}
|
||||
@@ -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 := app.clientIP(ctx)
|
||||
guard := app.securityGuard
|
||||
if app.securityFeaturesEnabled() && guard == nil {
|
||||
guard = security.NewGuard()
|
||||
app.securityGuard = guard
|
||||
}
|
||||
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
|
||||
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
|
||||
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Too many failed attempts. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
||||
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) {
|
||||
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
|
||||
if banned {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,22 +84,41 @@ func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(request.BoxIDs) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
||||
return
|
||||
}
|
||||
|
||||
switch request.Action {
|
||||
case "delete", "expire", "bump":
|
||||
case "delete", "expire", "bump", "cleanup_expired":
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Action != "cleanup_expired" && len(request.BoxIDs) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Action == "bump" && request.DeltaSeconds <= 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
||||
return
|
||||
}
|
||||
if request.Action == "cleanup_expired" {
|
||||
result, err := app.runExpiredCleanup("admin")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Expired cleanup job failed"})
|
||||
return
|
||||
}
|
||||
boxes, listErr := app.listAdminBoxes()
|
||||
if listErr != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Cleanup finished, but boxes could not be reloaded"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": len(result.Warnings) == 0,
|
||||
"message": fmt.Sprintf("Expired cleanup done: deleted %d box(es), skipped %d", result.Deleted, result.Skipped),
|
||||
"warnings": result.Warnings,
|
||||
"boxes": boxes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
processed := 0
|
||||
warnings := make([]string, 0)
|
||||
@@ -299,6 +318,8 @@ func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) s
|
||||
return fmt.Sprintf("Expired %d box(es)", processed)
|
||||
case "bump":
|
||||
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
|
||||
case "cleanup_expired":
|
||||
return fmt.Sprintf("Expired cleanup processed %d box(es)", processed)
|
||||
default:
|
||||
return "Action complete"
|
||||
}
|
||||
|
||||
331
lib/server/admin_security.go
Normal file
331
lib/server/admin_security.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"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"`
|
||||
IPs []string `json:"ips"`
|
||||
BanUntil string `json:"ban_until"`
|
||||
}
|
||||
|
||||
func (app *App) reloadSecurityConfig() error {
|
||||
if app == nil || app.config == nil {
|
||||
return fmt.Errorf("app or config is nil")
|
||||
}
|
||||
if !app.securityFeaturesEnabled() {
|
||||
if app.securityGuard != nil {
|
||||
_ = app.securityGuard.Close()
|
||||
}
|
||||
app.securityGuard = nil
|
||||
return nil
|
||||
}
|
||||
if app.securityGuard == nil {
|
||||
app.securityGuard = security.NewGuard()
|
||||
}
|
||||
if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil {
|
||||
return fmt.Errorf("enable ban persistence: %w", err)
|
||||
}
|
||||
if err := 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,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("reload guard config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) securityFeaturesEnabled() bool {
|
||||
return app != nil && app.config != nil && app.config.SecurityEnabled
|
||||
}
|
||||
|
||||
func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) {
|
||||
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 = app.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.securityFeaturesEnabled() {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
if app.securityGuard == nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ip := app.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.securityFeaturesEnabled() {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
return
|
||||
}
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
return
|
||||
}
|
||||
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 := app.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) {
|
||||
if !app.securityFeaturesEnabled() {
|
||||
ctx.String(http.StatusNotFound, "Security features are disabled")
|
||||
return
|
||||
}
|
||||
events := []activity.Event{}
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.activityStore != nil {
|
||||
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
if app.alertStore != nil {
|
||||
alertsList, _ = app.alertStore.List(120)
|
||||
}
|
||||
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) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) {
|
||||
metaCopy := map[string]string{"ip": ip}
|
||||
for k, v := range meta {
|
||||
metaCopy[k] = v
|
||||
}
|
||||
app.logActivity(kind, severity, message, ctx, metaCopy)
|
||||
app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy)
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
if !app.securityFeaturesEnabled() {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Security features are disabled"})
|
||||
return
|
||||
}
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
||||
return
|
||||
}
|
||||
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.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.")
|
||||
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)
|
||||
meta := map[string]string{"until": until.UTC().Format(time.RFC3339)}
|
||||
app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.")
|
||||
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.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.")
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
||||
case "bulk_unban":
|
||||
if len(request.IPs) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"})
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, candidate := range request.IPs {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if net.ParseIP(candidate) == nil {
|
||||
continue
|
||||
}
|
||||
app.securityGuard.Unban(candidate)
|
||||
count++
|
||||
}
|
||||
app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)})
|
||||
app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()})
|
||||
case "unban_all":
|
||||
current := app.securityGuard.BanList()
|
||||
for _, ban := range current {
|
||||
app.securityGuard.Unban(ban.IP)
|
||||
}
|
||||
count := len(current)
|
||||
app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)})
|
||||
app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()})
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
}
|
||||
}
|
||||
|
||||
func intToString(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
125
lib/server/admin_security_test.go
Normal file
125
lib/server/admin_security_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/activity"
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) {
|
||||
app, router := setupAdminSecurityTest(t)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"action":"ban","ip":"203.0.113.7"}`,
|
||||
`{"action":"unban","ip":"203.0.113.7"}`,
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
events, err := app.activityStore.List(100, app.config.ActivityRetentionSeconds)
|
||||
if err != nil {
|
||||
t.Fatalf("activity list error: %v", err)
|
||||
}
|
||||
if len(events) < 2 {
|
||||
t.Fatalf("expected activity events, got %d", len(events))
|
||||
}
|
||||
alertsList, err := app.alertStore.List(100)
|
||||
if err != nil {
|
||||
t.Fatalf("alerts list error: %v", err)
|
||||
}
|
||||
if len(alertsList) < 2 {
|
||||
t.Fatalf("expected alerts for manual actions, got %d", len(alertsList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSecurityBulkUnbanAndUnbanAll(t *testing.T) {
|
||||
app, router := setupAdminSecurityTest(t)
|
||||
app.securityGuard.Ban("203.0.113.8", 300)
|
||||
app.securityGuard.Ban("203.0.113.9", 300)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"bulk_unban","ips":["203.0.113.8"]}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("bulk_unban expected 200, got %d", response.Code)
|
||||
}
|
||||
if app.securityGuard.IsBanned("203.0.113.8") {
|
||||
t.Fatal("expected selected IP to be unbanned")
|
||||
}
|
||||
if !app.securityGuard.IsBanned("203.0.113.9") {
|
||||
t.Fatal("expected non-selected IP to remain banned")
|
||||
}
|
||||
|
||||
requestAll := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"unban_all"}`))
|
||||
requestAll.Header.Set("Content-Type", "application/json")
|
||||
requestAll.AddCookie(authCookie(app))
|
||||
responseAll := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseAll, requestAll)
|
||||
if responseAll.Code != http.StatusOK {
|
||||
t.Fatalf("unban_all expected 200, got %d", responseAll.Code)
|
||||
}
|
||||
if len(app.securityGuard.BanList()) != 0 {
|
||||
t.Fatal("expected all bans to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
cwd, _ := os.Getwd()
|
||||
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
clearAdminSettingsEnv(t)
|
||||
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
|
||||
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
|
||||
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("config load: %v", err)
|
||||
}
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
t.Fatalf("ensure dirs: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity.json")),
|
||||
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
|
||||
securityGuard: security.NewGuard(),
|
||||
}
|
||||
if err := app.reloadSecurityConfig(); err != nil {
|
||||
t.Fatalf("reload security config: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = app.securityGuard.Close() })
|
||||
|
||||
router := gin.New()
|
||||
admin := router.Group("/admin")
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
protected := router.Group("/admin", app.adminAuthMiddleware)
|
||||
protected.POST("/security/actions", app.handleAdminSecurityAction)
|
||||
return app, router
|
||||
}
|
||||
@@ -269,6 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
|
||||
|
||||
app.config = nextCfg
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
if err := app.reloadSecurityConfig(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
return rows, warnings, nil
|
||||
}
|
||||
@@ -399,6 +402,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 +433,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.SettingSecurityEnabled, 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:
|
||||
@@ -440,6 +451,8 @@ func settingsCategoryForKey(key string) string {
|
||||
return "storage"
|
||||
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||
return "workers"
|
||||
case config.SettingExpiredCleanupIntervalSecs:
|
||||
return "workers"
|
||||
default:
|
||||
return "accounts"
|
||||
}
|
||||
@@ -466,6 +479,19 @@ 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.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.",
|
||||
config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.",
|
||||
config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.",
|
||||
config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.",
|
||||
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.",
|
||||
config.SettingExpiredCleanupIntervalSecs: "Background interval for deleting expired boxes. Set 0 to disable periodic cleanup.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
|
||||
@@ -265,7 +265,36 @@ func clearAdminSettingsEnv(t *testing.T) {
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
"WARPBOX_SECURITY_ENABLED",
|
||||
"WARPBOX_SECURITY_IP_WHITELIST",
|
||||
"WARPBOX_SECURITY_ADMIN_IP_WHITELIST",
|
||||
"WARPBOX_TRUSTED_PROXY_CIDRS",
|
||||
"WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS",
|
||||
"WARPBOX_SECURITY_BAN_SECONDS",
|
||||
"WARPBOX_SECURITY_SCAN_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS",
|
||||
"WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_GB",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_MB",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_BYTES",
|
||||
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
62
lib/server/cleanup.go
Normal file
62
lib/server/cleanup.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
)
|
||||
|
||||
func (app *App) runExpiredCleanup(trigger string) (boxstore.CleanupExpiredResult, error) {
|
||||
result, err := boxstore.CleanupExpiredBoxes()
|
||||
if err != nil {
|
||||
log.Printf("warpbox cleanup[%s] failed: %v", trigger, err)
|
||||
app.logActivity("boxes.cleanup.failed", "high", "Expired boxes cleanup failed", nil, map[string]string{
|
||||
"trigger": trigger,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"trigger": trigger,
|
||||
"scanned": intToString(result.Scanned),
|
||||
"deleted": intToString(result.Deleted),
|
||||
"skipped": intToString(result.Skipped),
|
||||
}
|
||||
if len(result.DeletedIDs) > 0 {
|
||||
limit := len(result.DeletedIDs)
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
meta["deleted_ids"] = strings.Join(result.DeletedIDs[:limit], ",")
|
||||
}
|
||||
if len(result.Warnings) > 0 {
|
||||
limit := len(result.Warnings)
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
meta["warnings"] = strings.Join(result.Warnings[:limit], " | ")
|
||||
}
|
||||
app.logActivity("boxes.cleanup", "medium", "Expired boxes cleanup run completed", nil, meta)
|
||||
log.Printf("warpbox cleanup[%s] scanned=%d deleted=%d skipped=%d", trigger, result.Scanned, result.Deleted, result.Skipped)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (app *App) startExpiredCleanupWorker() {
|
||||
if app == nil || app.config == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
interval := app.config.ExpiredCleanupIntervalSeconds
|
||||
if interval <= 0 {
|
||||
time.Sleep(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
_, _ = app.runExpiredCleanup("worker")
|
||||
}
|
||||
}()
|
||||
}
|
||||
107
lib/server/ip.go
Normal file
107
lib/server/ip.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
func (app *App) clientIP(ctx *gin.Context) string {
|
||||
if ctx == nil || ctx.Request == nil {
|
||||
return ""
|
||||
}
|
||||
remoteIP := remoteAddrIP(ctx.Request)
|
||||
trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs)
|
||||
if err != nil {
|
||||
return remoteIP
|
||||
}
|
||||
if !remoteIsTrusted(remoteIP, trusted) {
|
||||
return 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 remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(remoteIP))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
44
lib/server/ip_test.go
Normal file
44
lib/server/ip_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
func TestClientIPDirectClient(t *testing.T) {
|
||||
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx.Request.RemoteAddr = "198.51.100.10:1234"
|
||||
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.4")
|
||||
if got := app.clientIP(ctx); got != "198.51.100.10" {
|
||||
t.Fatalf("expected direct remote IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustedProxyChain(t *testing.T) {
|
||||
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx.Request.RemoteAddr = "10.1.2.3:8080"
|
||||
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.44, 10.0.0.5")
|
||||
if got := app.clientIP(ctx); got != "203.0.113.44" {
|
||||
t.Fatalf("expected forwarded public client IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) {
|
||||
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx.Request.RemoteAddr = "203.0.113.200:8080"
|
||||
ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55")
|
||||
if got := app.clientIP(ctx); got != "203.0.113.200" {
|
||||
t.Fatalf("expected untrusted remote IP, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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,20 @@ 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(),
|
||||
}
|
||||
if err := app.reloadSecurityConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
router.Use(app.securityMiddleware())
|
||||
router.NoRoute(app.handleNoRoute)
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,6 +88,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,
|
||||
@@ -83,6 +104,7 @@ func Run(addr string) error {
|
||||
compressed.Static("/static", "./static")
|
||||
|
||||
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
||||
app.startExpiredCleanupWorker()
|
||||
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -153,3 +154,39 @@ func (app *App) maxRequestBodyBytes() int64 {
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
|
||||
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
|
||||
if !app.securityFeaturesEnabled() || app.securityGuard == nil {
|
||||
return true
|
||||
}
|
||||
ip := app.clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user