feat(admin): add security and activity management features
This commit is contained in:
@@ -2,11 +2,14 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
const adminSessionCookie = "warpbox_admin_session"
|
||||
@@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
guard := app.securityGuard
|
||||
if guard == nil {
|
||||
guard = security.NewGuard()
|
||||
app.securityGuard = guard
|
||||
}
|
||||
if !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
|
||||
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
|
||||
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Too many failed attempts. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
||||
if !guard.IsAdminWhitelisted(ip) {
|
||||
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
|
||||
if banned {
|
||||
app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)})
|
||||
app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Invalid username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
|
||||
secure := app.config.AdminCookieSecure
|
||||
maxAge := int(app.config.SessionTTLSeconds)
|
||||
|
||||
@@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.alertStore != nil {
|
||||
var err error
|
||||
alertsList, err = app.alertStore.List(500)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load alerts")
|
||||
return
|
||||
}
|
||||
}
|
||||
openCount := 0
|
||||
highCount := 0
|
||||
ackedCount := 0
|
||||
closedCount := 0
|
||||
for _, alert := range alertsList {
|
||||
switch string(alert.Status) {
|
||||
case "open":
|
||||
openCount++
|
||||
case "acked":
|
||||
ackedCount++
|
||||
case "closed":
|
||||
closedCount++
|
||||
}
|
||||
if alert.Severity == "high" && string(alert.Status) != "closed" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "alerts",
|
||||
"Alerts": alertsList,
|
||||
"OpenCount": strconv.Itoa(openCount),
|
||||
"HighCount": strconv.Itoa(highCount),
|
||||
"AckCount": strconv.Itoa(ackedCount),
|
||||
"ClosedCount": strconv.Itoa(closedCount),
|
||||
})
|
||||
}
|
||||
|
||||
258
lib/server/admin_security.go
Normal file
258
lib/server/admin_security.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/activity"
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
type adminAlertsActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
type adminSecurityActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
IP string `json:"ip"`
|
||||
BanUntil string `json:"ban_until"`
|
||||
}
|
||||
|
||||
func (app *App) reloadSecurityConfig() {
|
||||
if app.securityGuard == nil {
|
||||
app.securityGuard = security.NewGuard()
|
||||
}
|
||||
app.securityGuard.Reload(security.Config{
|
||||
IPWhitelist: app.config.SecurityIPWhitelist,
|
||||
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
|
||||
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
|
||||
LoginMaxAttempts: app.config.SecurityLoginMaxAttempts,
|
||||
BanSeconds: app.config.SecurityBanSeconds,
|
||||
ScanWindowSeconds: app.config.SecurityScanWindowSeconds,
|
||||
ScanMaxAttempts: app.config.SecurityScanMaxAttempts,
|
||||
UploadWindowSeconds: app.config.SecurityUploadWindowSeconds,
|
||||
UploadMaxRequests: app.config.SecurityUploadMaxRequests,
|
||||
UploadMaxBytes: app.config.SecurityUploadMaxBytes,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) {
|
||||
if app.activityStore == nil {
|
||||
return
|
||||
}
|
||||
event := activity.Event{
|
||||
Kind: kind,
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Meta: meta,
|
||||
}
|
||||
if ctx != nil {
|
||||
event.IP = clientIP(ctx)
|
||||
event.Path = ctx.Request.URL.Path
|
||||
event.Method = ctx.Request.Method
|
||||
}
|
||||
_ = app.activityStore.Append(event, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
|
||||
func (app *App) createAlert(title string, severity string, group string, code string, trace string, message string, meta map[string]string) {
|
||||
if app.alertStore == nil {
|
||||
return
|
||||
}
|
||||
_ = app.alertStore.Add(alerts.Alert{
|
||||
Title: title,
|
||||
Severity: severity,
|
||||
Group: group,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Message: message,
|
||||
Status: alerts.StatusOpen,
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) securityMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
if app.securityGuard.IsBanned(ip) {
|
||||
app.logActivity("security.block", "high", "Blocked banned IP", ctx, nil)
|
||||
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many abusive requests. Try again later."})
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleNoRoute(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
return
|
||||
}
|
||||
path := strings.ToLower(ctx.Request.URL.Path)
|
||||
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
|
||||
if suspicious {
|
||||
ip := clientIP(ctx)
|
||||
if !app.securityGuard.IsWhitelisted(ip) {
|
||||
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)})
|
||||
if banned {
|
||||
app.createAlert("IP auto-banned after malicious path scans", "high", "security", "410", "security.scan.autoban", "Repeated malicious path scans triggered temporary ban.", map[string]string{"ip": ip, "attempts": intToString(attempts)})
|
||||
app.logActivity("security.ban", "high", "IP auto-banned after scans", ctx, map[string]string{"attempts": intToString(attempts)})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminActivity(ctx *gin.Context) {
|
||||
if app.activityStore == nil {
|
||||
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "activity",
|
||||
"Events": []activity.Event{},
|
||||
})
|
||||
return
|
||||
}
|
||||
events, err := app.activityStore.List(400, app.config.ActivityRetentionSeconds)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load activity")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "activity",
|
||||
"Events": events,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurity(ctx *gin.Context) {
|
||||
events := []activity.Event{}
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.activityStore != nil {
|
||||
events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
if app.alertStore != nil {
|
||||
alertsList, _ = app.alertStore.List(50)
|
||||
}
|
||||
bans := []security.BanEntry{}
|
||||
if app.securityGuard != nil {
|
||||
bans = app.securityGuard.BanList()
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/security.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "security",
|
||||
"Events": events,
|
||||
"Alerts": alertsList,
|
||||
"Bans": bans,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
|
||||
if app.alertStore == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Alert store unavailable"})
|
||||
return
|
||||
}
|
||||
var request adminAlertsActionRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||
return
|
||||
}
|
||||
switch request.Action {
|
||||
case "ack":
|
||||
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusAcked); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||||
return
|
||||
}
|
||||
case "close":
|
||||
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusClosed); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||||
return
|
||||
}
|
||||
case "delete":
|
||||
if err := app.alertStore.Delete(request.IDs); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete alerts"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
return
|
||||
}
|
||||
app.logActivity("alerts.action", "low", "Admin changed alert state", ctx, map[string]string{"action": request.Action, "count": intToString(len(request.IDs))})
|
||||
alertsList, _ := app.alertStore.List(500)
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
||||
return
|
||||
}
|
||||
var request adminSecurityActionRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||
return
|
||||
}
|
||||
ip := strings.TrimSpace(request.IP)
|
||||
if ip != "" && net.ParseIP(ip) == nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
|
||||
return
|
||||
}
|
||||
switch request.Action {
|
||||
case "ban":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
|
||||
case "ban_until":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
until, err := time.Parse(time.RFC3339, strings.TrimSpace(request.BanUntil))
|
||||
if err != nil || until.Before(time.Now().UTC()) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ban expiration"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.BanUntil(ip, until)
|
||||
app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
|
||||
case "unban":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.Unban(ip)
|
||||
app.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
}
|
||||
}
|
||||
|
||||
func intToString(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
@@ -269,6 +269,7 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
|
||||
|
||||
app.config = nextCfg
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
app.reloadSecurityConfig()
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
return rows, warnings, nil
|
||||
}
|
||||
@@ -399,6 +400,8 @@ func settingsCategoryMeta() []settingsCategoryInfo {
|
||||
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||
{Key: "security", Label: "Security", Icon: "🔒"},
|
||||
{Key: "activity", Label: "Activity", Icon: "☰"},
|
||||
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||
{Key: "api", Label: "API", Icon: "{ }"},
|
||||
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||
@@ -428,10 +431,16 @@ func settingsCategoryForKey(key string) string {
|
||||
switch key {
|
||||
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||
return "uploads"
|
||||
case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
|
||||
return "uploads"
|
||||
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||
return "downloads"
|
||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||
return "retention"
|
||||
case config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
|
||||
return "security"
|
||||
case config.SettingActivityRetentionSeconds:
|
||||
return "activity"
|
||||
case config.SettingSessionTTLSeconds:
|
||||
return "accounts"
|
||||
case config.SettingAPIEnabled:
|
||||
@@ -466,6 +475,17 @@ func settingsDescription(key string) string {
|
||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
||||
config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.",
|
||||
config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.",
|
||||
config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.",
|
||||
config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.",
|
||||
config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.",
|
||||
config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.",
|
||||
config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.",
|
||||
config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.",
|
||||
config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.",
|
||||
config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.",
|
||||
config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
|
||||
89
lib/server/ip.go
Normal file
89
lib/server/ip.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func clientIP(ctx *gin.Context) string {
|
||||
if ctx == nil || ctx.Request == nil {
|
||||
return ""
|
||||
}
|
||||
remoteIP := remoteAddrIP(ctx.Request)
|
||||
|
||||
// Only trust forwarding headers when remote hop looks like local/internal proxy.
|
||||
if isPrivateOrLoopback(remoteIP) {
|
||||
for _, candidate := range headerIPs(ctx.Request.Header) {
|
||||
if isPublicIP(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
candidates := headerIPs(ctx.Request.Header)
|
||||
if len(candidates) > 0 {
|
||||
return candidates[0]
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func headerIPs(header http.Header) []string {
|
||||
keys := []string{
|
||||
"X-Forwarded-For",
|
||||
"X-Real-Ip",
|
||||
"CF-Connecting-IP",
|
||||
"X-Envoy-External-Address",
|
||||
"Fly-Client-IP",
|
||||
}
|
||||
out := make([]string, 0, 4)
|
||||
seen := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
raw := strings.TrimSpace(header.Get(key))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
ip := normalizeIP(strings.TrimSpace(part))
|
||||
if ip == "" || seen[ip] {
|
||||
continue
|
||||
}
|
||||
seen[ip] = true
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteAddrIP(request *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr))
|
||||
if err != nil {
|
||||
return normalizeIP(strings.TrimSpace(request.RemoteAddr))
|
||||
}
|
||||
return normalizeIP(host)
|
||||
}
|
||||
|
||||
func normalizeIP(raw string) string {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func isPublicIP(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil || !ip.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
return !isPrivateOrLoopback(value)
|
||||
}
|
||||
|
||||
func isPrivateOrLoopback(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
|
||||
}
|
||||
@@ -9,14 +9,20 @@ import (
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/activity"
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/routing"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
settingsOverridesPath string
|
||||
activityStore *activity.Store
|
||||
alertStore *alerts.Store
|
||||
securityGuard *security.Guard
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -38,9 +44,18 @@ func Run(addr string) error {
|
||||
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
app := &App{config: cfg, settingsOverridesPath: overridesPath}
|
||||
app := &App{
|
||||
config: cfg,
|
||||
settingsOverridesPath: overridesPath,
|
||||
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")),
|
||||
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
|
||||
securityGuard: security.NewGuard(),
|
||||
}
|
||||
app.reloadSecurityConfig()
|
||||
|
||||
router := gin.Default()
|
||||
router.Use(app.securityMiddleware())
|
||||
router.NoRoute(app.handleNoRoute)
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,6 +86,10 @@ func Run(addr string) error {
|
||||
AdminBoxes: app.handleAdminBoxes,
|
||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||
AdminUsers: app.handleAdminUsers,
|
||||
AdminActivity: app.handleAdminActivity,
|
||||
AdminSecurity: app.handleAdminSecurity,
|
||||
AdminAlertsAction: app.handleAdminAlertsAction,
|
||||
AdminSecurityAction: app.handleAdminSecurityAction,
|
||||
AdminSettings: app.handleAdminSettings,
|
||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||
|
||||
@@ -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,36 @@ func (app *App) maxRequestBodyBytes() int64 {
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
|
||||
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
|
||||
ip := clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
return true
|
||||
}
|
||||
allowed, requestCount, totalBytes := app.securityGuard.AllowUpload(
|
||||
ip,
|
||||
size,
|
||||
app.config.SecurityUploadWindowSeconds,
|
||||
app.config.SecurityUploadMaxRequests,
|
||||
app.config.SecurityUploadMaxBytes,
|
||||
)
|
||||
if allowed {
|
||||
return true
|
||||
}
|
||||
|
||||
app.logActivity("security.upload_limit", "high", "Upload rate limit exceeded", ctx, map[string]string{
|
||||
"requests": strconv.Itoa(requestCount),
|
||||
"bytes": strconv.FormatInt(totalBytes, 10),
|
||||
})
|
||||
app.createAlert(
|
||||
"Upload rate limit triggered",
|
||||
"medium",
|
||||
"security",
|
||||
"430",
|
||||
"security.upload.rate_limit",
|
||||
"Per-IP upload rate limit blocked request.",
|
||||
map[string]string{"ip": ip, "requests": strconv.Itoa(requestCount)},
|
||||
)
|
||||
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many uploads from this IP. Try again later."})
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user