feat(security): Implemented more security information

This commit is contained in:
2026-05-03 22:46:54 +03:00
parent 88ab6e808b
commit 9d9db5cf0b
20 changed files with 902 additions and 193 deletions

View File

@@ -23,6 +23,7 @@ var Definitions = []SettingDefinition{
{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: 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},

View File

@@ -62,6 +62,9 @@ func Load() (*Config, error) {
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 {
@@ -162,6 +165,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))
@@ -199,6 +211,7 @@ func (cfg *Config) captureDefaults() {
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
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))

View File

@@ -39,6 +39,7 @@ const (
SettingActivityRetentionSeconds = "activity_retention_seconds"
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"
@@ -109,6 +110,7 @@ type Config struct {
ActivityRetentionSeconds int64
SecurityIPWhitelist string
SecurityAdminIPWhitelist string
TrustedProxyCIDRs string
SecurityLoginWindowSeconds int64
SecurityLoginMaxAttempts int
SecurityBanSeconds int64

View File

@@ -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)
@@ -58,6 +66,21 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
}
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:
@@ -138,6 +161,8 @@ func (cfg *Config) assignText(key string, value string, source Source) {
cfg.SecurityIPWhitelist = value
case SettingSecurityAdminIPWhitelist:
cfg.SecurityAdminIPWhitelist = value
case SettingTrustedProxyCIDRs:
cfg.TrustedProxyCIDRs = value
}
cfg.setValue(key, value, source)
}

View File

@@ -1,10 +1,16 @@
package security
import (
"encoding/binary"
"fmt"
"net"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/dgraph-io/badger/v4"
)
type Config struct {
@@ -26,8 +32,14 @@ type Guard struct {
scanAttempts map[string][]time.Time
uploadEvents map[string][]uploadEvent
bannedUntil map[string]time.Time
ipWhitelist map[string]bool
adminWhitelist map[string]bool
ipWhitelist []ipMatcher
adminWhitelist []ipMatcher
banDB *badger.DB
}
type ipMatcher struct {
exact net.IP
prefix *net.IPNet
}
type uploadEvent struct {
@@ -40,34 +52,90 @@ type BanEntry struct {
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: map[string]bool{},
adminWhitelist: map[string]bool{},
ipWhitelist: []ipMatcher{},
adminWhitelist: []ipMatcher{},
}
}
func (g *Guard) Reload(cfg Config) {
func (g *Guard) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
g.ipWhitelist = parseList(cfg.IPWhitelist)
g.adminWhitelist = parseList(cfg.AdminIPWhitelist)
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 g.ipWhitelist[ip]
return matchIP(g.ipWhitelist, ip)
}
func (g *Guard) IsAdminWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return g.adminWhitelist[ip] || g.ipWhitelist[ip]
return matchIP(g.adminWhitelist, ip) || matchIP(g.ipWhitelist, ip)
}
func (g *Guard) IsBanned(ip string) bool {
@@ -79,6 +147,7 @@ func (g *Guard) IsBanned(ip string) bool {
}
if time.Now().UTC().After(until) {
delete(g.bannedUntil, ip)
g.deleteBanLocked(ip)
return false
}
return true
@@ -90,7 +159,9 @@ func (g *Guard) Ban(ip string, seconds int64) {
}
g.mu.Lock()
defer g.mu.Unlock()
g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
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) {
@@ -99,7 +170,9 @@ func (g *Guard) BanUntil(ip string, until time.Time) {
}
g.mu.Lock()
defer g.mu.Unlock()
g.bannedUntil[ip] = until.UTC()
until = until.UTC()
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
}
func (g *Guard) Unban(ip string) {
@@ -109,6 +182,7 @@ func (g *Guard) Unban(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.bannedUntil, ip)
g.deleteBanLocked(ip)
}
func (g *Guard) BanList() []BanEntry {
@@ -119,6 +193,7 @@ func (g *Guard) BanList() []BanEntry {
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})
@@ -141,7 +216,9 @@ func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts
attempts = append(attempts, now)
g.failedLogins[ip] = attempts
if len(attempts) >= maxAttempts {
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
return true, len(attempts)
}
return false, len(attempts)
@@ -159,7 +236,9 @@ func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts
attempts = append(attempts, now)
g.scanAttempts[ip] = attempts
if len(attempts) >= maxAttempts {
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
return true, len(attempts)
}
return false, len(attempts)
@@ -195,6 +274,49 @@ func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxReque
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 {
@@ -205,13 +327,100 @@ func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
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
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 out
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
})
}

View 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")
}
}

View File

@@ -62,7 +62,7 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ip := clientIP(ctx)
ip := app.clientIP(ctx)
guard := app.securityGuard
if guard == nil {
guard = security.NewGuard()

View File

@@ -3,6 +3,7 @@ package server
import (
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
@@ -20,16 +21,20 @@ type adminAlertsActionRequest struct {
}
type adminSecurityActionRequest struct {
Action string `json:"action"`
IP string `json:"ip"`
BanUntil string `json:"ban_until"`
Action string `json:"action"`
IP string `json:"ip"`
IPs []string `json:"ips"`
BanUntil string `json:"ban_until"`
}
func (app *App) reloadSecurityConfig() {
if app.securityGuard == nil {
app.securityGuard = security.NewGuard()
}
app.securityGuard.Reload(security.Config{
if app.config != nil {
_ = app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger"))
}
_ = app.securityGuard.Reload(security.Config{
IPWhitelist: app.config.SecurityIPWhitelist,
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
@@ -55,7 +60,7 @@ func (app *App) logActivity(kind string, severity string, message string, ctx *g
Meta: meta,
}
if ctx != nil {
event.IP = clientIP(ctx)
event.IP = app.clientIP(ctx)
event.Path = ctx.Request.URL.Path
event.Method = ctx.Request.Method
}
@@ -84,7 +89,7 @@ func (app *App) securityMiddleware() gin.HandlerFunc {
ctx.Next()
return
}
ip := clientIP(ctx)
ip := app.clientIP(ctx)
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
ctx.Next()
return
@@ -106,7 +111,7 @@ func (app *App) handleNoRoute(ctx *gin.Context) {
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)
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)})
@@ -146,10 +151,10 @@ 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)
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(50)
alertsList, _ = app.alertStore.List(120)
}
bans := []security.BanEntry{}
if app.securityGuard != nil {
@@ -200,6 +205,15 @@ func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
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.securityGuard == nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
@@ -215,6 +229,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
return
}
switch request.Action {
case "ban":
if ip == "" {
@@ -222,8 +237,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
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})
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 == "" {
@@ -236,8 +250,8 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
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)})
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 == "" {
@@ -245,9 +259,34 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
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})
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"})
}

View File

@@ -0,0 +1,123 @@
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(),
}
app.reloadSecurityConfig()
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
}

View File

@@ -265,7 +265,34 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"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",
} {
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)
}
}

View File

@@ -6,29 +6,47 @@ import (
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/security"
)
func clientIP(ctx *gin.Context) string {
func (app *App) 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]
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",

44
lib/server/ip_test.go Normal file
View 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)
}
}

View File

@@ -156,7 +156,7 @@ func (app *App) maxRequestBodyBytes() int64 {
}
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
ip := clientIP(ctx)
ip := app.clientIP(ctx)
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
return true
}