From 10ed806153859e9f13a0729ed75ca209b82f2242 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 21:52:56 +0300 Subject: [PATCH] feat(security): add trusted proxies and abuse event cleanup - Add `WARPBOX_TRUSTED_PROXIES` configuration to restrict accepted forwarded client IP headers to specific proxy IPs/CIDRs, securing client IP resolution. - Integrate `BanService` into the background cleanup job to automatically purge expired abuse and ban evidence events. - Update documentation with reverse proxy security guidelines and a production systemd deployment guide. --- .env.example | 1 + README.md | 72 +++ SECURITY_PROXY.md | 63 ++ backend/libs/config/config.go | 17 + backend/libs/handlers/accounts_test.go | 179 ++++++ backend/libs/handlers/admin.go | 500 +++++++++++++++- backend/libs/handlers/app.go | 11 +- backend/libs/handlers/auth.go | 22 +- backend/libs/handlers/dashboard.go | 9 + backend/libs/handlers/download.go | 18 +- backend/libs/handlers/manage.go | 6 +- backend/libs/handlers/security.go | 28 + backend/libs/handlers/upload.go | 16 +- backend/libs/handlers/upload_stage3_test.go | 7 +- backend/libs/httpserver/server.go | 10 +- backend/libs/jobs/cleanup.go | 12 +- backend/libs/jobs/jobs.go | 4 +- backend/libs/middleware/bans.go | 65 +++ backend/libs/middleware/bans_test.go | 99 ++++ backend/libs/services/bans.go | 545 ++++++++++++++++++ backend/libs/services/bans_test.go | 117 ++++ backend/libs/services/proxy.go | 75 +++ backend/libs/services/proxy_test.go | 29 + backend/libs/services/settings.go | 17 - backend/static/css/16-retro.css | 81 ++- backend/static/css/50-admin.css | 69 +++ backend/templates/pages/admin.html | 2 + backend/templates/pages/admin_bans.html | 153 +++++ backend/templates/pages/admin_logs.html | 105 ++++ backend/templates/pages/admin_settings.html | 2 + backend/templates/pages/admin_storage.html | 2 + .../templates/pages/admin_storage_form.html | 2 + .../templates/pages/admin_storage_new.html | 2 + .../templates/pages/admin_storage_tests.html | 2 + backend/templates/pages/admin_user_edit.html | 2 + backend/templates/pages/admin_users.html | 2 + backend/templates/pages/download.html | 6 +- scripts/env/dev.env.example | 1 + 38 files changed, 2310 insertions(+), 43 deletions(-) create mode 100644 SECURITY_PROXY.md create mode 100644 backend/libs/middleware/bans.go create mode 100644 backend/libs/middleware/bans_test.go create mode 100644 backend/libs/services/bans.go create mode 100644 backend/libs/services/bans_test.go create mode 100644 backend/libs/services/proxy.go create mode 100644 backend/libs/services/proxy_test.go create mode 100644 backend/templates/pages/admin_bans.html create mode 100644 backend/templates/pages/admin_logs.html diff --git a/.env.example b/.env.example index 2c91642..e78e8e5 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local WARPBOX_READ_TIMEOUT=15s WARPBOX_WRITE_TIMEOUT=60s WARPBOX_IDLE_TIMEOUT=120s +WARPBOX_TRUSTED_PROXIES= diff --git a/README.md b/README.md index b5a43a4..a2a6f0b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Upload policy defaults are also configured in megabytes and can later be changed - `WARPBOX_SHORT_WINDOW_SECONDS=60` - `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local` - `WARPBOX_USER_STORAGE_BACKEND=local` +- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md). Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. The dev script resolves that path from the repository root. @@ -74,6 +75,73 @@ The compose example also works with Podman compatible compose tools. Its data vo The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks use `/health`. +## Reverse Proxy Security + +Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The +default behavior trusts `X-Forwarded-For` and `X-Real-IP` so a normal Caddy reverse proxy works +without extra setup. For hardened deployments where the app port might be reachable from more than +one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See +[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes. + +## Systemd + +Build the binary on the server, create a dedicated user, and keep runtime data outside the repo: + +```bash +cd /opt/warpbox-dev/backend +go build -o /usr/local/bin/warpbox ./cmd/warpbox +sudo useradd --system --home /var/lib/warpbox --shell /usr/sbin/nologin warpbox +sudo mkdir -p /var/lib/warpbox /etc/warpbox +sudo chown -R warpbox:warpbox /var/lib/warpbox +sudo cp /opt/warpbox-dev/.env.example /etc/warpbox/warpbox.env +``` + +Example `/etc/warpbox/warpbox.env` values: + +```env +WARPBOX_ENV=production +WARPBOX_ADDR=127.0.0.1:6070 +WARPBOX_BASE_URL=https://warpbox.dev +WARPBOX_DATA_DIR=/var/lib/warpbox +WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static +WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates +WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1 +``` + +Example `/etc/systemd/system/warpbox.service`: + +```ini +[Unit] +Description=Warpbox file sharing service +After=network-online.target +Wants=network-online.target + +[Service] +User=warpbox +Group=warpbox +EnvironmentFile=/etc/warpbox/warpbox.env +ExecStart=/usr/local/bin/warpbox +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/lib/warpbox + +[Install] +WantedBy=multi-user.target +``` + +Then enable it: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now warpbox +sudo systemctl status warpbox +``` + +Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet. + ## Layout - `backend/cmd/warpbox` - main application entry point. @@ -138,6 +206,8 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your user storage quota, and usage retention. - `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides. - `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends. +- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and + repeated login failures. Auto-ban is off by default and configured from the admin UI. - Upload limits now include daily bytes, daily box counts, active box counts, short-window request limits, max expiration days, local storage capacity in GB, and per-user policy overrides. - Uploaded file content, thumbnails, and private box metadata use the selected storage backend. @@ -158,6 +228,8 @@ Warpbox keeps local runtime data under the configured data directory: - `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections. - `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP for anonymous uploads and user ID for signed-in uploads. +- `data/db/warpbox.bbolt` stores manual bans, automatic ban settings, abuse counters, and malicious + path rules. - `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line. ## Static Asset Policy diff --git a/SECURITY_PROXY.md b/SECURITY_PROXY.md new file mode 100644 index 0000000..af5ac48 --- /dev/null +++ b/SECURITY_PROXY.md @@ -0,0 +1,63 @@ +# Security Proxy Notes + +Warpbox usually runs behind a reverse proxy such as Caddy. IP-based quotas, +manual bans, and automatic bans depend on Warpbox seeing the real client IP. + +## Caddy + +Use this shape when Caddy and Warpbox are on the same host: + +```Caddyfile +warpbox.dev { + reverse_proxy 127.0.0.1:6070 { + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Real-IP {http.request.remote.host} + } +} +``` + +By default, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` so simple Docker, +Podman, and systemd deployments work without extra setup. This is convenient, +but it is only safe when the Warpbox port is not directly reachable by the +public internet. + +## Trusted Proxies + +For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR +ranges that are allowed to provide forwarded headers: + +```env +WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8 +``` + +When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only +if the TCP peer address is inside one of those trusted ranges. Requests coming +directly from any other IP ignore forwarded headers and use the socket address. + +Recommended values: + +- Same-host Caddy with systemd: `127.0.0.1,::1` +- Docker bridge networks: add the bridge CIDR, often `172.16.0.0/12` +- Private reverse-proxy networks: add the exact private CIDR used by the proxy + +## Direct Exposure + +If you expose Warpbox directly without Caddy, either leave +`WARPBOX_TRUSTED_PROXIES` empty and ensure clients cannot spoof headers at the +network edge, or set it to a value that does not include public clients. Direct +public exposure is not recommended; use a reverse proxy for TLS and request +normalization. + +## Ban Behavior + +Active bans return: + +```text +HTTP/1.1 403 Forbidden +Content-Type: text/plain; charset=utf-8 + +forbidden +``` + +Blocked requests are still written to the JSON logs and appear under +`/admin/logs` with `source=ban`. diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index 7bc8465..ed87bba 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -23,6 +23,7 @@ type Config struct { ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration + TrustedProxies []string JobsEnabled bool CleanupEnabled bool CleanupEvery time.Duration @@ -66,6 +67,7 @@ func Load() (Config, error) { ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second), WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second), IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), + TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"), JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), @@ -180,6 +182,21 @@ func envInt(key string, fallback int) int { return parsed } +func envCSV(key string) []string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return nil + } + parts := strings.Split(value, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + values = append(values, trimmed) + } + } + return values +} + func envMegabytes(key string, fallback float64) int64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index 36be9f3..823c93e 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -5,8 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" "warpbox.dev/backend/libs/services" ) @@ -801,6 +804,182 @@ func TestAdminStorageTestingPageRendersHistory(t *testing.T) { } } +func TestAdminLogsAndBansPagesRender(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + adminToken := createAdminSession(t, app) + logDir := filepath.Join(app.cfg.DataDir, "logs") + if err := os.MkdirAll(logDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + logPath := filepath.Join(logDir, "2026-05-31.log") + line := `{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}` + "\n" + if err := os.WriteFile(logPath, []byte(line), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + logsRequest := httptest.NewRequest(http.MethodGet, "/admin/logs?q=box123", nil) + logsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + logsResponse := httptest.NewRecorder() + app.AdminLogs(logsResponse, logsRequest) + if logsResponse.Code != http.StatusOK { + t.Fatalf("AdminLogs status = %d, body = %s", logsResponse.Code, logsResponse.Body.String()) + } + logsBody := logsResponse.Body.String() + if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") { + t.Fatalf("AdminLogs missing expected log entry: %s", logsBody) + } + + bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil) + bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + bansResponse := httptest.NewRecorder() + app.AdminBans(bansResponse, bansRequest) + if bansResponse.Code != http.StatusOK { + t.Fatalf("AdminBans status = %d, body = %s", bansResponse.Code, bansResponse.Body.String()) + } + if !strings.Contains(bansResponse.Body.String(), "Manual ban") || !strings.Contains(bansResponse.Body.String(), "Auto-ban settings") { + t.Fatalf("AdminBans missing ban controls: %s", bansResponse.Body.String()) + } +} + +func TestAdminCanCreateAndUnbanIPBan(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + adminToken := createAdminSession(t, app) + expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04") + request := httptest.NewRequest(http.MethodPost, "/admin/bans", strings.NewReader("target=203.0.113.90&reason=test&expires_at="+expiresAt+"&csrf_token=test-csrf")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + response := httptest.NewRecorder() + app.AdminCreateBan(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("AdminCreateBan status = %d, body = %s", response.Code, response.Body.String()) + } + records, err := app.banService.ListBans() + if err != nil { + t.Fatalf("ListBans returned error: %v", err) + } + if len(records) != 1 || records[0].Normalized != "203.0.113.90" { + t.Fatalf("records = %+v", records) + } + + unbanRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/"+records[0].ID+"/unban", strings.NewReader("csrf_token=test-csrf")) + unbanRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + unbanRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + unbanRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + unbanRequest.SetPathValue("banID", records[0].ID) + unbanResponse := httptest.NewRecorder() + app.AdminUnban(unbanResponse, unbanRequest) + if unbanResponse.Code != http.StatusSeeOther { + t.Fatalf("AdminUnban status = %d, body = %s", unbanResponse.Code, unbanResponse.Body.String()) + } + if _, ok, err := app.banService.Match("203.0.113.90", time.Now().UTC()); err != nil || ok { + t.Fatalf("unbanned Match = %v, %v", ok, err) + } +} + +func TestAdminCanUpdateBanSettingsAndRules(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + adminToken := createAdminSession(t, app) + settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/settings", strings.NewReader("auto_ban_enabled=on&auto_ban_duration_hours=48&abuse_window_hours=12&malicious_path_threshold=2&admin_login_failure_threshold=4&user_login_failure_threshold=5&csrf_token=test-csrf")) + settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + settingsResponse := httptest.NewRecorder() + app.AdminBanSettingsPost(settingsResponse, settingsRequest) + if settingsResponse.Code != http.StatusSeeOther { + t.Fatalf("AdminBanSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String()) + } + settings, err := app.banService.Settings() + if err != nil { + t.Fatalf("Settings returned error: %v", err) + } + if !settings.AutoBanEnabled || settings.AutoBanDurationHours != 48 || settings.MaliciousPathThreshold != 2 { + t.Fatalf("settings = %+v", settings) + } + + rulesRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/rules", strings.NewReader("patterns=%2Fcustom-one%0A%2Fcustom-two&csrf_token=test-csrf")) + rulesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rulesRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + rulesRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + rulesResponse := httptest.NewRecorder() + app.AdminBanRulesPost(rulesResponse, rulesRequest) + if rulesResponse.Code != http.StatusSeeOther { + t.Fatalf("AdminBanRulesPost status = %d, body = %s", rulesResponse.Code, rulesResponse.Body.String()) + } + if pattern, err := app.banService.MaliciousPattern("/x/custom-two"); err != nil || pattern != "/custom-two" { + t.Fatalf("MaliciousPattern = %q, %v", pattern, err) + } +} + +func TestLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + _, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + settings, err := app.banService.Settings() + if err != nil { + t.Fatalf("Settings returned error: %v", err) + } + settings.AutoBanEnabled = true + settings.UserLoginFailureThreshold = 2 + if err := app.banService.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings returned error: %v", err) + } + + for i := 0; i < 2; i++ { + request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("email=admin@example.test&password=wrong")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "203.0.113.91:1234" + response := httptest.NewRecorder() + app.LoginPost(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("LoginPost status = %d", response.Code) + } + } + if _, ok, err := app.banService.Match("203.0.113.91", time.Now().UTC()); err != nil || !ok { + t.Fatalf("Match after login failures = %v, %v", ok, err) + } +} + +func TestAdminLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + settings, err := app.banService.Settings() + if err != nil { + t.Fatalf("Settings returned error: %v", err) + } + settings.AutoBanEnabled = true + settings.AdminLoginFailureThreshold = 2 + if err := app.banService.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings returned error: %v", err) + } + app.cfg.AdminToken = "correct-token" + + for i := 0; i < 2; i++ { + request := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("token=wrong")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "203.0.113.92:1234" + response := httptest.NewRecorder() + app.AdminLoginPost(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("AdminLoginPost status = %d", response.Code) + } + } + if _, ok, err := app.banService.Match("203.0.113.92", time.Now().UTC()); err != nil || !ok { + t.Fatalf("Match after admin login failures = %v, %v", ok, err) + } +} + func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult { t.Helper() user, err := app.authService.UserByID(userID) diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index 0725585..de1c1c9 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -1,6 +1,7 @@ package handlers import ( + "bufio" "context" "crypto/sha256" "encoding/hex" @@ -8,8 +9,12 @@ import ( "fmt" "net/http" "net/url" + "os" "path" + "path/filepath" + "sort" "strconv" + "strings" "time" "warpbox.dev/backend/libs/jobs" @@ -29,6 +34,8 @@ type adminPageData struct { StorageForm adminStorageFormView StorageTest adminStorageTestView StorageTypes []adminStorageProviderView + Logs adminLogsView + Bans adminBansView Section string PageTitle string LastInviteURL string @@ -36,6 +43,57 @@ type adminPageData struct { Error string } +type adminLogsView struct { + Entries []adminLogEntry + Dates []string + Date string + Severity string + Source string + Query string + Sort string + TotalShown int +} + +type adminLogEntry struct { + Date string + Time string + Source string + Severity string + Code string + Message string + Method string + Path string + Status string + IP string + UserID string + Details string +} + +type adminBansView struct { + Bans []adminBanView + Rules []services.BanRule + Settings services.BanSettings + Query string + Status string + ActiveCount int + ExpiredCount int + UnbannedCount int + RulePatterns string + Notice string + Error string +} + +type adminBanView struct { + ID string + Target string + Reason string + Source string + Status string + CreatedAt string + ExpiresAt string + LastMatched string +} + type adminStorageFormView struct { Mode string Provider string @@ -144,7 +202,8 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { return } if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken { - a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301) + a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301, "ip", uploadClientIP(r)) + a.recordLoginAbuse(r, services.AbuseKindAdminLogin, "admin token login failed") a.renderAdminLogin(w, r, http.StatusUnauthorized, "Invalid admin token.") return } @@ -158,7 +217,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { Secure: r.TLS != nil, Expires: time.Now().Add(12 * time.Hour), }) - a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301) + a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301, "ip", uploadClientIP(r)) http.Redirect(w, r, "/admin", http.StatusSeeOther) } @@ -166,6 +225,7 @@ func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) { if !a.validateCSRF(w, r) { return } + a.logger.Info("admin logout", "source", "admin", "severity", "user_activity", "code", 2303, "ip", uploadClientIP(r)) a.clearUserSessionCookie(w) http.SetCookie(w, &http.Cookie{ Name: adminCookieName, @@ -439,9 +499,145 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + a.logger.Info("admin settings updated", "source", "admin", "severity", "user_activity", "code", 2310, "ip", uploadClientIP(r)) http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) } +func (a *App) AdminLogs(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + view, err := a.adminLogsView(r) + if err != nil { + http.Error(w, "unable to load logs", http.StatusInternalServerError) + return + } + a.logger.Info("admin viewed logs", "source", "admin", "severity", "user_activity", "code", 2350, "ip", uploadClientIP(r), "date_filter", view.Date) + a.renderPage(w, r, http.StatusOK, "admin_logs.html", web.PageData{ + Title: "Admin logs", + Description: "Browse Warpbox JSON logs.", + CurrentUser: a.currentPublicUser(r), + Data: adminPageData{ + Section: "logs", + PageTitle: "Logs", + Logs: view, + }, + }) +} + +func (a *App) AdminBans(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + view, err := a.adminBansView(r) + if err != nil { + http.Error(w, "unable to load bans", http.StatusInternalServerError) + return + } + a.logger.Info("admin viewed bans", "source", "admin", "severity", "user_activity", "code", 2351, "ip", uploadClientIP(r)) + a.renderPage(w, r, http.StatusOK, "admin_bans.html", web.PageData{ + Title: "Admin bans", + Description: "IP ban controls.", + CurrentUser: a.currentPublicUser(r), + Data: adminPageData{ + Section: "bans", + PageTitle: "Bans", + Bans: view, + }, + }) +} + +func (a *App) AdminCreateBan(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read ban form."), http.StatusSeeOther) + return + } + expiresAt, err := time.ParseInLocation("2006-01-02T15:04", r.FormValue("expires_at"), time.Local) + if err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Expiration date and time is invalid."), http.StatusSeeOther) + return + } + createdBy := "admin" + if user, ok := a.currentUser(r); ok { + createdBy = user.ID + } + ban, err := a.banService.CreateManualBan(r.FormValue("target"), r.FormValue("reason"), createdBy, expiresAt.UTC()) + if err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + a.logger.Info("manual ban created", "source", "admin", "severity", "user_activity", "code", 2360, "ip", uploadClientIP(r), "ban_id", ban.ID, "target", ban.Normalized) + http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban created."), http.StatusSeeOther) +} + +func (a *App) AdminUnban(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + if err := a.banService.Unban(r.PathValue("banID"), time.Now().UTC()); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + a.logger.Info("ban removed", "source", "admin", "severity", "user_activity", "code", 2361, "ip", uploadClientIP(r), "ban_id", r.PathValue("banID")) + http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban removed."), http.StatusSeeOther) +} + +func (a *App) AdminBanSettingsPost(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read settings form."), http.StatusSeeOther) + return + } + settings := services.BanSettings{ + AutoBanEnabled: r.FormValue("auto_ban_enabled") == "on", + AutoBanDurationHours: parsePositiveInt(r.FormValue("auto_ban_duration_hours")), + MaliciousPathThreshold: parsePositiveInt(r.FormValue("malicious_path_threshold")), + AdminLoginFailureThreshold: parsePositiveInt(r.FormValue("admin_login_failure_threshold")), + UserLoginFailureThreshold: parsePositiveInt(r.FormValue("user_login_failure_threshold")), + AbuseWindowHours: parsePositiveInt(r.FormValue("abuse_window_hours")), + } + if err := a.banService.UpdateSettings(settings); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + a.logger.Info("ban settings updated", "source", "admin", "severity", "user_activity", "code", 2362, "ip", uploadClientIP(r), "auto_ban_enabled", settings.AutoBanEnabled) + http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban settings saved."), http.StatusSeeOther) +} + +func (a *App) AdminBanRulesPost(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read rules form."), http.StatusSeeOther) + return + } + patterns := splitRulePatterns(r.FormValue("patterns")) + if err := a.banService.SaveRules(patterns, time.Now().UTC()); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + a.logger.Info("ban rules updated", "source", "admin", "severity", "user_activity", "code", 2363, "ip", uploadClientIP(r), "rules", len(patterns)) + http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Malicious path rules saved."), http.StatusSeeOther) +} + +func (a *App) AdminBanRuleDelete(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + if err := a.banService.DeleteRule(r.PathValue("ruleID")); err != nil { + http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + a.logger.Info("ban rule deleted", "source", "admin", "severity", "user_activity", "code", 2364, "ip", uploadClientIP(r), "rule_id", r.PathValue("ruleID")) + http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Rule deleted."), http.StatusSeeOther) +} + func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return @@ -625,6 +821,7 @@ func (a *App) AdminCreateStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/storage/new/"+provider+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("storage backend created", "source", "admin", "severity", "user_activity", "code", 2320, "ip", uploadClientIP(r), "provider", provider) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend added."), http.StatusSeeOther) } @@ -642,6 +839,7 @@ func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("storage backend updated", "source", "admin", "severity", "user_activity", "code", 2321, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "provider", provider) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend updated."), http.StatusSeeOther) } @@ -654,9 +852,11 @@ func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) { next = "/admin/storage/" + r.PathValue("backendID") + "/tests" } if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil { + a.logger.Warn("storage connection test failed", "source", "admin", "severity", "warn", "code", 4320, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "error", err.Error()) http.Redirect(w, r, next+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("storage connection test passed", "source", "admin", "severity", "user_activity", "code", 2322, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID")) http.Redirect(w, r, next+"?notice="+url.QueryEscape("Storage connection test passed."), http.StatusSeeOther) } @@ -679,6 +879,7 @@ func (a *App) AdminStartStorageSpeedTest(w http.ResponseWriter, r *http.Request) return } go a.uploadService.Storage().RunSpeedTest(context.Background(), test.ID) + a.logger.Info("storage speed test started", "source", "admin", "severity", "user_activity", "code", 2323, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "test_id", test.ID, "mode", test.Mode) http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther) } @@ -692,6 +893,7 @@ func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("storage backend disabled", "source", "admin", "severity", "user_activity", "code", 2324, "ip", uploadClientIP(r), "backend_id", id) http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) } @@ -705,6 +907,7 @@ func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("storage backend deleted", "source", "admin", "severity", "user_activity", "code", 2325, "ip", uploadClientIP(r), "backend_id", id) http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) } @@ -714,9 +917,11 @@ func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) { } cleaned, err := jobs.RunCleanupNow(a.uploadService, a.logger) if err != nil { + a.logger.Warn("admin cleanup run failed", "source", "admin", "severity", "warn", "code", 4340, "ip", uploadClientIP(r), "error", err.Error()) http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("admin ran cleanup", "source", "admin", "severity", "user_activity", "code", 2340, "ip", uploadClientIP(r), "cleaned", cleaned) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(fmt.Sprintf("Cleanup finished. Removed %d unavailable boxes.", cleaned)), http.StatusSeeOther) } @@ -726,10 +931,12 @@ func (a *App) AdminRunStorageThumbnails(w http.ResponseWriter, r *http.Request) } result, err := jobs.RunThumbnailsNow(a.uploadService, a.logger) if err != nil { + a.logger.Warn("admin thumbnail run failed", "source", "admin", "severity", "warn", "code", 4341, "ip", uploadClientIP(r), "error", err.Error()) http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } message := fmt.Sprintf("Thumbnail pass finished. Scanned %d files, generated %d, failed %d.", result.Scanned, result.Generated, result.Failed) + a.logger.Info("admin ran thumbnail generation", "source", "admin", "severity", "user_activity", "code", 2341, "ip", uploadClientIP(r), "scanned", result.Scanned, "generated", result.Generated, "failed", result.Failed) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther) } @@ -739,6 +946,7 @@ func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request) } configs, err := a.uploadService.Storage().ListBackendConfigs() if err != nil { + a.logger.Warn("admin storage verification failed", "source", "admin", "severity", "warn", "code", 4342, "ip", uploadClientIP(r), "error", err.Error()) http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } @@ -755,6 +963,7 @@ func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request) passed++ } message := fmt.Sprintf("Storage verification finished. %d passed, %d failed.", passed, failed) + a.logger.Info("admin verified storage backends", "source", "admin", "severity", "user_activity", "code", 2342, "ip", uploadClientIP(r), "passed", passed, "failed", failed) http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther) } @@ -779,6 +988,7 @@ func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to update quota", http.StatusInternalServerError) return } + a.logger.Info("admin updated user quota", "source", "admin", "severity", "user_activity", "code", 2330, "ip", uploadClientIP(r), "user_id", r.PathValue("userID")) http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } @@ -810,6 +1020,7 @@ func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + a.logger.Info("admin updated user policy", "source", "admin", "severity", "user_activity", "code", 2331, "ip", uploadClientIP(r), "user_id", r.PathValue("userID")) http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } @@ -848,6 +1059,7 @@ func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } + a.logger.Info("admin updated user", "source", "admin", "severity", "user_activity", "code", 2332, "ip", uploadClientIP(r), "user_id", r.PathValue("userID")) http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther) } @@ -869,6 +1081,7 @@ func (a *App) AdminUpdateUserStorage(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to update user storage", http.StatusInternalServerError) return } + a.logger.Info("admin updated user storage", "source", "admin", "severity", "user_activity", "code", 2333, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"), "backend_id", r.FormValue("storage_backend_id")) http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } @@ -886,7 +1099,7 @@ func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users", http.StatusSeeOther) return } - a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID) + a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID, "ip", uploadClientIP(r), "email", r.FormValue("email"), "role", r.FormValue("role")) http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther) } @@ -899,6 +1112,7 @@ func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to update user", http.StatusInternalServerError) return } + a.logger.Info("admin changed user disabled state", "source", "admin", "severity", "user_activity", "code", 2334, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"), "disabled", disabled) http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } @@ -912,6 +1126,7 @@ func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to create reset link", http.StatusInternalServerError) return } + a.logger.Info("admin generated password reset", "source", "admin", "severity", "user_activity", "code", 2335, "ip", uploadClientIP(r), "admin_id", admin.ID, "user_id", r.PathValue("userID")) if r.URL.Query().Get("next") == "edit" { http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?invite="+url.QueryEscape(result.URL), http.StatusSeeOther) return @@ -930,6 +1145,7 @@ func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to delete box", http.StatusInternalServerError) return } + a.logger.Info("admin deleted box", "source", "admin", "severity", "user_activity", "code", 2304, "ip", uploadClientIP(r), "box_id", boxID) http.Redirect(w, r, "/admin/files", http.StatusSeeOther) } @@ -1101,6 +1317,284 @@ func formatMB(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) + " MB" } +func (a *App) adminBansView(r *http.Request) (adminBansView, error) { + settings, err := a.banService.Settings() + if err != nil { + return adminBansView{}, err + } + records, err := a.banService.ListBans() + if err != nil { + return adminBansView{}, err + } + rules, err := a.banService.ListRules() + if err != nil { + return adminBansView{}, err + } + now := time.Now().UTC() + query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + statusFilter := strings.TrimSpace(r.URL.Query().Get("status")) + rows := []adminBanView{} + active, expired, unbanned := 0, 0, 0 + for _, record := range records { + status := record.Status(now) + switch status { + case "active": + active++ + case "expired": + expired++ + case "unbanned": + unbanned++ + } + if statusFilter != "" && status != statusFilter { + continue + } + search := strings.ToLower(strings.Join([]string{record.Target, record.Normalized, record.Reason, record.Source, status}, " ")) + if query != "" && !strings.Contains(search, query) { + continue + } + rows = append(rows, adminBanView{ + ID: record.ID, + Target: record.Normalized, + Reason: record.Reason, + Source: record.Source, + Status: status, + CreatedAt: record.CreatedAt.Format("Jan 2 15:04"), + ExpiresAt: record.ExpiresAt.Format("Jan 2 15:04"), + LastMatched: formatOptionalTime(record.LastMatchedAt), + }) + } + return adminBansView{ + Bans: rows, + Rules: rules, + Settings: settings, + Query: r.URL.Query().Get("q"), + Status: statusFilter, + ActiveCount: active, + ExpiredCount: expired, + UnbannedCount: unbanned, + RulePatterns: joinRulePatterns(rules), + Notice: r.URL.Query().Get("notice"), + Error: r.URL.Query().Get("error"), + }, nil +} + +func formatOptionalTime(value *time.Time) string { + if value == nil { + return "Never" + } + return value.Format("Jan 2 15:04") +} + +func joinRulePatterns(rules []services.BanRule) string { + patterns := make([]string, 0, len(rules)) + for _, rule := range rules { + if rule.Enabled { + patterns = append(patterns, rule.Pattern) + } + } + return strings.Join(patterns, "\n") +} + +func splitRulePatterns(value string) []string { + lines := strings.Split(value, "\n") + patterns := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + patterns = append(patterns, line) + } + } + return patterns +} + +func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) { + logDir := filepath.Join(a.cfg.DataDir, "logs") + dates, err := availableLogDates(logDir) + if err != nil { + return adminLogsView{}, err + } + selectedDate := strings.TrimSpace(r.URL.Query().Get("date")) + if selectedDate == "" && len(dates) > 0 { + selectedDate = dates[0] + } else if selectedDate != "" && selectedDate != "all" && !containsString(dates, selectedDate) { + selectedDate = "" + } + severity := strings.TrimSpace(r.URL.Query().Get("severity")) + source := strings.TrimSpace(r.URL.Query().Get("source")) + query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + sortOrder := strings.TrimSpace(r.URL.Query().Get("sort")) + if sortOrder != "asc" { + sortOrder = "desc" + } + + files := []string{} + if selectedDate == "all" { + for _, date := range dates { + files = append(files, filepath.Join(logDir, date+".log")) + } + } else if selectedDate != "" { + files = append(files, filepath.Join(logDir, selectedDate+".log")) + } + + entries := []adminLogEntry{} + for _, file := range files { + fileEntries, err := readLogEntries(file) + if err != nil && !os.IsNotExist(err) { + return adminLogsView{}, err + } + for _, entry := range fileEntries { + if severity != "" && entry.Severity != severity { + continue + } + if source != "" && entry.Source != source { + continue + } + if query != "" && !strings.Contains(strings.ToLower(entry.searchText()), query) { + continue + } + entries = append(entries, entry) + } + } + + sort.Slice(entries, func(i, j int) bool { + left := entries[i].Date + " " + entries[i].Time + right := entries[j].Date + " " + entries[j].Time + if sortOrder == "asc" { + return left < right + } + return left > right + }) + if len(entries) > 500 { + entries = entries[:500] + } + return adminLogsView{ + Entries: entries, + Dates: dates, + Date: selectedDate, + Severity: severity, + Source: source, + Query: r.URL.Query().Get("q"), + Sort: sortOrder, + TotalShown: len(entries), + }, nil +} + +func availableLogDates(logDir string) ([]string, error) { + matches, err := filepath.Glob(filepath.Join(logDir, "*.log")) + if err != nil { + return nil, err + } + dates := make([]string, 0, len(matches)) + for _, match := range matches { + name := strings.TrimSuffix(filepath.Base(match), ".log") + if name != "" { + dates = append(dates, name) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(dates))) + return dates, nil +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func readLogEntries(file string) ([]adminLogEntry, error) { + handle, err := os.Open(file) + if err != nil { + return nil, err + } + defer handle.Close() + scanner := bufio.NewScanner(handle) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + entries := []adminLogEntry{} + for scanner.Scan() { + line := scanner.Bytes() + var raw map[string]any + if err := json.Unmarshal(line, &raw); err != nil { + continue + } + entries = append(entries, logEntryFromMap(raw)) + } + return entries, scanner.Err() +} + +func logEntryFromMap(raw map[string]any) adminLogEntry { + entry := adminLogEntry{ + Date: logString(raw, "date"), + Time: logString(raw, "time"), + Source: logString(raw, "source"), + Severity: logString(raw, "severity"), + Code: logAnyString(raw["code"]), + Message: logString(raw, "log"), + Method: logString(raw, "method"), + Path: logString(raw, "path"), + Status: logAnyString(raw["status"]), + IP: firstLogString(raw, "ip", "client_ip", "remote_addr"), + UserID: logString(raw, "user_id"), + } + entry.Details = logDetails(raw) + return entry +} + +func logDetails(raw map[string]any) string { + ignore := map[string]bool{ + "date": true, "time": true, "source": true, "severity": true, "code": true, "log": true, + "method": true, "path": true, "status": true, "ip": true, "client_ip": true, "remote_addr": true, "user_id": true, + } + details := map[string]any{} + for key, value := range raw { + if !ignore[key] { + details[key] = value + } + } + if len(details) == 0 { + return "" + } + data, err := json.Marshal(details) + if err != nil { + return "" + } + return string(data) +} + +func (e adminLogEntry) searchText() string { + return strings.Join([]string{e.Date, e.Time, e.Source, e.Severity, e.Code, e.Message, e.Method, e.Path, e.Status, e.IP, e.UserID, e.Details}, " ") +} + +func logString(raw map[string]any, key string) string { + return logAnyString(raw[key]) +} + +func firstLogString(raw map[string]any, keys ...string) string { + for _, key := range keys { + if value := logString(raw, key); value != "" { + return value + } + } + return "" +} + +func logAnyString(value any) string { + switch typed := value.(type) { + case string: + return typed + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case bool: + return strconv.FormatBool(typed) + case nil: + return "" + default: + return fmt.Sprintf("%v", typed) + } +} + func adminStorageSpeedTestsJSON(tests []services.StorageSpeedTest) []adminStorageSpeedTestJSON { rows := make([]adminStorageSpeedTestJSON, 0, len(tests)) for _, test := range tests { diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 66abe01..9046624 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -16,10 +16,11 @@ type App struct { uploadService *services.UploadService authService *services.AuthService settingsService *services.SettingsService + banService *services.BanService rateLimiter *rateLimiter } -func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App { +func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App { return &App{ cfg: cfg, logger: logger, @@ -27,6 +28,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo uploadService: uploadService, authService: authService, settingsService: settingsService, + banService: banService, rateLimiter: newRateLimiter(), } } @@ -67,6 +69,13 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser) mux.HandleFunc("GET /admin/settings", a.AdminSettings) mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost) + mux.HandleFunc("GET /admin/logs", a.AdminLogs) + mux.HandleFunc("GET /admin/bans", a.AdminBans) + mux.HandleFunc("POST /admin/bans", a.AdminCreateBan) + mux.HandleFunc("POST /admin/bans/{banID}/unban", a.AdminUnban) + mux.HandleFunc("POST /admin/bans/settings", a.AdminBanSettingsPost) + mux.HandleFunc("POST /admin/bans/rules", a.AdminBanRulesPost) + mux.HandleFunc("POST /admin/bans/rules/{ruleID}/delete", a.AdminBanRuleDelete) mux.HandleFunc("GET /admin/storage", a.AdminStorage) mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage) mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider) diff --git a/backend/libs/handlers/auth.go b/backend/libs/handlers/auth.go index 5650a7a..e1023f1 100644 --- a/backend/libs/handlers/auth.go +++ b/backend/libs/handlers/auth.go @@ -35,6 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) { func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) { if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) { + a.logger.Warn("registration rate limited", "source", "auth", "severity", "warn", "code", 4291, "ip", uploadClientIP(r)) a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."}) return } @@ -44,10 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) { } user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password")) if err != nil { + a.logger.Warn("bootstrap registration failed", "source", "auth", "severity", "warn", "code", 4400, "ip", uploadClientIP(r), "email", r.FormValue("email"), "error", err.Error()) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()}) return } - a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID) + a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID, "ip", uploadClientIP(r)) a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app") } @@ -61,6 +63,7 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) { func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) { if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) { + a.logger.Warn("login rate limited", "source", "auth", "severity", "warn", "code", 4292, "ip", uploadClientIP(r), "email", r.FormValue("email")) a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."}) return } @@ -74,12 +77,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) { } user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password")) if err != nil { - a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email")) + a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"), "ip", uploadClientIP(r)) + a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed") a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next}) return } a.setUserSessionCookie(w, r, token) - a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID) + a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther) } @@ -87,6 +91,9 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) { if !a.validateCSRF(w, r) { return } + if user, ok := a.currentUser(r); ok { + a.logger.Info("user logout", "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID, "ip", uploadClientIP(r)) + } if cookie, err := r.Cookie(userSessionCookieName); err == nil { _ = a.authService.Logout(cookie.Value) } @@ -107,6 +114,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") invite, err := a.authService.InviteByToken(token) if err != nil { + a.logger.Warn("invite accept invalid", "source", "auth", "severity", "warn", "code", 4404, "ip", uploadClientIP(r)) a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."}) return } @@ -116,10 +124,11 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) { } user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password")) if err != nil { + a.logger.Warn("invite accept failed", "source", "auth", "severity", "warn", "code", 4405, "ip", uploadClientIP(r), "invite_email", invite.Email, "error", err.Error()) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()}) return } - a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID) + a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "ip", uploadClientIP(r), "invite_email", invite.Email) a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app") } @@ -176,6 +185,8 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) { } if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil { a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error()) + } else { + a.logger.Info("api token deleted", "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID")) } http.Redirect(w, r, "/account/settings", http.StatusSeeOther) } @@ -222,13 +233,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) { return } if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) { + a.logger.Warn("password change failed current password", "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, "/account/settings", http.StatusSeeOther) return } if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil { + a.logger.Warn("password change failed", "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error()) http.Redirect(w, r, "/account/settings", http.StatusSeeOther) return } + a.logger.Info("password changed", "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, "/account/settings", http.StatusSeeOther) } diff --git a/backend/libs/handlers/dashboard.go b/backend/libs/handlers/dashboard.go index 34b8dca..2aff203 100644 --- a/backend/libs/handlers/dashboard.go +++ b/backend/libs/handlers/dashboard.go @@ -113,6 +113,8 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) { } if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil { a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error()) + } else { + a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name")) } http.Redirect(w, r, "/app", http.StatusSeeOther) } @@ -127,9 +129,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) { return } if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil { + a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error()) a.handleUserBoxError(w, r, err) return } + a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID")) http.Redirect(w, r, "/app", http.StatusSeeOther) } @@ -144,13 +148,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) { } collectionID := r.FormValue("collection_id") if !a.authService.CollectionOwnedBy(collectionID, user.ID) { + a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID) http.Error(w, "collection not found", http.StatusForbidden) return } if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil { + a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error()) a.handleUserBoxError(w, r, err) return } + a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID) http.Redirect(w, r, "/app", http.StatusSeeOther) } @@ -160,9 +167,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) { return } if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil { + a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error()) a.handleUserBoxError(w, r, err) return } + a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID")) http.Redirect(w, r, "/app", http.StatusSeeOther) } diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 29cc195..5eb8ba4 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -53,10 +53,12 @@ type previewPageData struct { func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { + a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r)) http.NotFound(w, r) return } if err := a.uploadService.CanDownload(box); err != nil { + a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error()) a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{ Title: "Download unavailable", Description: "This Warpbox link is no longer available.", @@ -99,6 +101,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { ExpiresLabel: expiresLabel, }, }) + a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked) } func plural(n int) string { @@ -136,6 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { DownloadURL: view.DownloadURL, }, }) + a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r)) } func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) { @@ -144,11 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) { return } if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { + a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r)) http.Error(w, "password required", http.StatusUnauthorized) return } a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1") + a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "attachment", r.URL.Query().Get("inline") != "1") } func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { @@ -196,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) { return } if !a.uploadService.VerifyPassword(box, r.FormValue("password")) { - a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID) + a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther) return } @@ -209,23 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) { Secure: r.TLS != nil, Expires: box.ExpiresAt, }) - a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID) + a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther) } func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { + a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r)) http.NotFound(w, r) return services.Box{}, services.File{}, false } if err := a.uploadService.CanDownload(box); err != nil { + a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error()) http.Error(w, err.Error(), statusForDownloadError(err)) return services.Box{}, services.File{}, false } file, err := a.uploadService.FindFile(box, r.PathValue("fileID")) if err != nil { + a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r)) http.NotFound(w, r) return services.Box{}, services.File{}, false } @@ -235,6 +244,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) { object, err := a.uploadService.OpenFileObject(r.Context(), box, file) if err != nil { + a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error()) http.NotFound(w, r) return } @@ -270,14 +280,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker { func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { + a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r)) http.NotFound(w, r) return } if err := a.uploadService.CanDownload(box); err != nil { + a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error()) http.Error(w, err.Error(), statusForDownloadError(err)) return } if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { + a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r)) http.Error(w, "password required", http.StatusUnauthorized) return } @@ -293,6 +306,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error()) } + a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files)) } func (a *App) fileView(box services.Box, file services.File) fileView { diff --git a/backend/libs/handlers/manage.go b/backend/libs/handlers/manage.go index 50ea622..95d4d93 100644 --- a/backend/libs/handlers/manage.go +++ b/backend/libs/handlers/manage.go @@ -31,6 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) { Description: "Delete this anonymous Warpbox upload.", Data: a.managePageData(box, r.PathValue("token")), }) + a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r)) } func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) { @@ -40,10 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) { } if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil { - a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error()) + a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error()) http.NotFound(w, r) return } + a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r)) http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther) } @@ -58,10 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) { func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { + a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r)) http.NotFound(w, r) return services.Box{}, false } if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) { + a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r)) http.NotFound(w, r) return services.Box{}, false } diff --git a/backend/libs/handlers/security.go b/backend/libs/handlers/security.go index 0a341b0..d33bc88 100644 --- a/backend/libs/handlers/security.go +++ b/backend/libs/handlers/security.go @@ -6,6 +6,8 @@ import ( "net/http" "sync" "time" + + "warpbox.dev/backend/libs/services" ) const csrfCookieName = "warpbox_csrf" @@ -76,3 +78,29 @@ func randomToken(byteCount int) string { } return base64.RawURLEncoding.EncodeToString(data) } + +func (a *App) recordLoginAbuse(r *http.Request, kind, detail string) { + if a.banService == nil { + return + } + settings, err := a.banService.Settings() + if err != nil || !settings.AutoBanEnabled { + return + } + threshold := settings.UserLoginFailureThreshold + if kind == services.AbuseKindAdminLogin { + threshold = settings.AdminLoginFailureThreshold + } + ip := uploadClientIP(r) + result, err := a.banService.RecordAbuse(ip, kind, detail, threshold, time.Now().UTC()) + if err != nil { + a.logger.Error("login abuse event failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "kind", kind, "error", err.Error()) + return + } + if result.Enabled { + a.logger.Warn("login abuse recorded", "source", "ban", "severity", "warn", "code", 4304, "ip", ip, "kind", kind, "count", result.Event.Count) + } + if result.Triggered { + a.logger.Warn("ip auto-banned for login abuse", "source", "ban", "severity", "warn", "code", 4305, "ip", ip, "kind", kind, "ban_id", result.Ban.ID) + } +} diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index a82ab94..d8e4a43 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -18,6 +18,7 @@ import ( func (a *App) Upload(w http.ResponseWriter, r *http.Request) { user, loggedIn, authErr := a.currentUserWithAuthError(r) if authErr != nil { + a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent()) helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token") return } @@ -29,12 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { return } if !loggedIn && !settings.AnonymousUploadsEnabled { + a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r)) helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled") return } effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn) rateKey := uploadRateKey(r, user, loggedIn) if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) { + a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID) helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down") return } @@ -49,6 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { parseLimit = 32 << 20 } if err := r.ParseMultipartForm(parseLimit); err != nil { + a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error()) helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") return } @@ -61,12 +65,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { ownerID = user.ID collectionID = r.FormValue("collection_id") if !a.authService.CollectionOwnedBy(collectionID, user.ID) { + a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID) helpers.WriteJSONError(w, http.StatusForbidden, "collection not found") return } } if !isAdminUpload { if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" { + a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files)) helpers.WriteJSONError(w, status, message) return } @@ -76,11 +82,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { maxDays = min(7, effectivePolicy.MaxDays) } if !isAdminUpload && maxDays > effectivePolicy.MaxDays { + a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) return } expiresMinutes := parseInt(r.FormValue("expires_minutes")) if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 { + a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) return } @@ -97,7 +105,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { StorageBackendID: effectivePolicy.StorageBackendID, }) if err != nil { - a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error()) + a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error()) helpers.WriteJSONError(w, http.StatusBadRequest, err.Error()) return } @@ -110,6 +118,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { } } jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID) + a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload) if wantsJSON(r) { helpers.WriteJSON(w, http.StatusCreated, result) @@ -235,7 +244,10 @@ func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fall } func uploadClientIP(r *http.Request) string { - return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) + if ip, ok := services.ClientIPFromContext(r); ok { + return ip + } + return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil) } func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string { diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index 77f0654..f812c18 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -213,7 +213,12 @@ func newTestApp(t *testing.T) (*App, func()) { service.Close() t.Fatalf("NewSettingsService returned error: %v", err) } - return NewApp(cfg, logger, renderer, service, authService, settingsService), func() { + banService, err := services.NewBanService(service.DB()) + if err != nil { + service.Close() + t.Fatalf("NewBanService returned error: %v", err) + } + return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() { if err := service.Close(); err != nil { t.Fatalf("Close returned error: %v", err) } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index befc23b..92d51ce 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -32,8 +32,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { uploadService.Close() return nil, err } - stopJobs := jobs.StartAll(cfg, logger, uploadService) - app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService) + banService, err := services.NewBanService(uploadService.DB()) + if err != nil { + uploadService.Close() + return nil, err + } + stopJobs := jobs.StartAll(cfg, logger, uploadService, banService) + app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService) router := http.NewServeMux() app.RegisterRoutes(router) @@ -45,6 +50,7 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { middleware.SecurityHeaders, middleware.Gzip, middleware.Logger(logger), + middleware.Bans(logger, banService, cfg.TrustedProxies), ) server := &http.Server{ diff --git a/backend/libs/jobs/cleanup.go b/backend/libs/jobs/cleanup.go index 07c1aed..23fc016 100644 --- a/backend/libs/jobs/cleanup.go +++ b/backend/libs/jobs/cleanup.go @@ -8,7 +8,7 @@ import ( "warpbox.dev/backend/libs/services" ) -func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job { +func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) job { return job{ name: "cleanup", enabled: cfg.CleanupEnabled, @@ -22,6 +22,16 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic if cleaned > 0 { logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned) } + if banService != nil { + cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC()) + if err != nil { + logger.Warn("ban evidence cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4203, "error", err.Error()) + return + } + if cleanedEvents > 0 { + logger.Info("ban evidence cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2203, "cleaned", cleanedEvents) + } + } }, } } diff --git a/backend/libs/jobs/jobs.go b/backend/libs/jobs/jobs.go index 0f6396c..4dd348a 100644 --- a/backend/libs/jobs/jobs.go +++ b/backend/libs/jobs/jobs.go @@ -16,14 +16,14 @@ type job struct { run func() } -func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) func() { +func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) func() { if !cfg.JobsEnabled { logger.Info("background jobs disabled", "source", "jobs", "severity", "dev") return func() {} } stops := []func(){ - start(newCleanupJob(cfg, logger, uploadService), logger), + start(newCleanupJob(cfg, logger, uploadService, banService), logger), start(newThumbnailsJob(cfg, logger, uploadService), logger), } diff --git a/backend/libs/middleware/bans.go b/backend/libs/middleware/bans.go new file mode 100644 index 0000000..4360f1f --- /dev/null +++ b/backend/libs/middleware/bans.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" + + "warpbox.dev/backend/libs/services" +) + +func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies) + r = services.WithClientIP(r, ip) + now := time.Now().UTC() + + if bans != nil { + if matched, ok, err := bans.Match(ip, now); err != nil { + logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error()) + } else if ok { + logger.Warn("banned request blocked", "source", "ban", "severity", "warn", "code", 4030, "ip", ip, "ban_id", matched.Ban.ID, "target", matched.Ban.Normalized, "path", r.URL.Path) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden\n")) + return + } + + if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil { + logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error()) + } else if pattern != "" { + if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, banThreshold(bans, services.AbuseKindMaliciousPath), now); err != nil { + logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error()) + } else if result.Enabled { + logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count) + if result.Triggered { + logger.Warn("ip auto-banned for malicious path", "source", "ban", "severity", "warn", "code", 4303, "ip", ip, "ban_id", result.Ban.ID, "path", r.URL.Path) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden\n")) + return + } + } + } + } + + next.ServeHTTP(w, r) + }) + } +} + +func banThreshold(bans *services.BanService, kind string) int { + settings, err := bans.Settings() + if err != nil { + return 0 + } + switch kind { + case services.AbuseKindAdminLogin: + return settings.AdminLoginFailureThreshold + case services.AbuseKindUserLogin: + return settings.UserLoginFailureThreshold + default: + return settings.MaliciousPathThreshold + } +} diff --git a/backend/libs/middleware/bans_test.go b/backend/libs/middleware/bans_test.go new file mode 100644 index 0000000..681fee9 --- /dev/null +++ b/backend/libs/middleware/bans_test.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "warpbox.dev/backend/libs/services" +) + +func TestBansMiddlewareBlocksActiveBan(t *testing.T) { + bans := newMiddlewareBanService(t) + if _, err := bans.CreateManualBan("203.0.113.20", "test", "admin", time.Now().UTC().Add(time.Hour)); err != nil { + t.Fatalf("CreateManualBan returned error: %v", err) + } + handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("next handler should not be called") + }), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil)) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.RemoteAddr = "127.0.0.1:6070" + request.Header.Set("X-Forwarded-For", "203.0.113.20") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden || response.Body.String() != "forbidden\n" { + t.Fatalf("blocked response = %d %q", response.Code, response.Body.String()) + } +} + +func TestBansMiddlewareAllowsNonBannedIP(t *testing.T) { + bans := newMiddlewareBanService(t) + called := false + handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = io.WriteString(w, "ok") + }), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil)) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.RemoteAddr = "203.0.113.21:6070" + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if !called || response.Code != http.StatusOK { + t.Fatalf("allowed response = called %v code %d", called, response.Code) + } +} + +func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) { + bans := newMiddlewareBanService(t) + settings, err := bans.Settings() + if err != nil { + t.Fatalf("Settings returned error: %v", err) + } + settings.AutoBanEnabled = true + settings.MaliciousPathThreshold = 3 + if err := bans.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings returned error: %v", err) + } + handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil)) + + for i := 0; i < 3; i++ { + request := httptest.NewRequest(http.MethodGet, "/.env", nil) + request.RemoteAddr = "203.0.113.22:6070" + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if i < 2 && response.Code == http.StatusForbidden { + t.Fatalf("request %d blocked before threshold", i+1) + } + if i == 2 && response.Code != http.StatusForbidden { + t.Fatalf("request 3 status = %d, want forbidden", response.Code) + } + } +} + +func newMiddlewareBanService(t *testing.T) *services.BanService { + t.Helper() + root := t.TempDir() + upload, err := services.NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default()) + if err != nil { + t.Fatalf("NewUploadService returned error: %v", err) + } + t.Cleanup(func() { + if err := upload.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }) + bans, err := services.NewBanService(upload.DB()) + if err != nil { + t.Fatalf("NewBanService returned error: %v", err) + } + return bans +} diff --git a/backend/libs/services/bans.go b/backend/libs/services/bans.go new file mode 100644 index 0000000..ef0cbfa --- /dev/null +++ b/backend/libs/services/bans.go @@ -0,0 +1,545 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "sort" + "strings" + "time" + + "go.etcd.io/bbolt" +) + +var ( + bansBucket = []byte("bans") + abuseEventsBucket = []byte("abuse_events") + banRulesBucket = []byte("ban_rules") + banSettingsBucket = []byte("ban_settings") + banSettingsKey = []byte("settings") + defaultBanRulesSeed = []byte("default_rules_seeded") +) + +const ( + BanSourceManual = "manual" + BanSourceAuto = "auto" + + AbuseKindMaliciousPath = "malicious_path" + AbuseKindAdminLogin = "admin_login_failure" + AbuseKindUserLogin = "user_login_failure" +) + +var defaultMaliciousPathRules = []string{ + "/wp-admin", + "/.env", + "/.git/config", + "/phpmyadmin", + "/wp-login.php", + "/xmlrpc.php", + "/config.php", + "/vendor/phpunit", + ".env", + "backup", + "dump.sql", +} + +var ErrBanNotFound = errors.New("ban not found") + +type BanService struct { + db *bbolt.DB +} + +type BanSettings struct { + AutoBanEnabled bool `json:"autoBanEnabled"` + AutoBanDurationHours int `json:"autoBanDurationHours"` + MaliciousPathThreshold int `json:"maliciousPathThreshold"` + AdminLoginFailureThreshold int `json:"adminLoginFailureThreshold"` + UserLoginFailureThreshold int `json:"userLoginFailureThreshold"` + AbuseWindowHours int `json:"abuseWindowHours"` +} + +type BanRecord struct { + ID string `json:"id"` + Target string `json:"target"` + Normalized string `json:"normalized"` + Reason string `json:"reason"` + Source string `json:"source"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ExpiresAt time.Time `json:"expiresAt"` + UnbannedAt *time.Time `json:"unbannedAt,omitempty"` + LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"` +} + +type BanRule struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AbuseEvent struct { + Key string `json:"key"` + IP string `json:"ip"` + Kind string `json:"kind"` + Count int `json:"count"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Detail string `json:"detail,omitempty"` +} + +type MatchedBan struct { + Ban BanRecord + IP string +} + +type AbuseResult struct { + Event AbuseEvent + Ban BanRecord + Triggered bool + Enabled bool +} + +func NewBanService(db *bbolt.DB) (*BanService, error) { + service := &BanService{db: db} + err := db.Update(func(tx *bbolt.Tx) error { + for _, bucket := range [][]byte{bansBucket, abuseEventsBucket, banRulesBucket, banSettingsBucket} { + if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { + return err + } + } + if tx.Bucket(banSettingsBucket).Get(banSettingsKey) == nil { + data, err := json.Marshal(DefaultBanSettings()) + if err != nil { + return err + } + if err := tx.Bucket(banSettingsBucket).Put(banSettingsKey, data); err != nil { + return err + } + } + rules := tx.Bucket(banRulesBucket) + if rules.Get(defaultBanRulesSeed) == nil { + now := time.Now().UTC() + for _, pattern := range defaultMaliciousPathRules { + rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now} + data, err := json.Marshal(rule) + if err != nil { + return err + } + if err := rules.Put([]byte(rule.ID), data); err != nil { + return err + } + } + if err := rules.Put(defaultBanRulesSeed, []byte("1")); err != nil { + return err + } + } + return nil + }) + return service, err +} + +func DefaultBanSettings() BanSettings { + return BanSettings{ + AutoBanEnabled: false, + AutoBanDurationHours: 24, + MaliciousPathThreshold: 3, + AdminLoginFailureThreshold: 10, + UserLoginFailureThreshold: 30, + AbuseWindowHours: 24, + } +} + +func (s *BanService) Settings() (BanSettings, error) { + settings := DefaultBanSettings() + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(banSettingsBucket).Get(banSettingsKey) + if data == nil { + return nil + } + if err := json.Unmarshal(data, &settings); err != nil { + return err + } + settings = withBanSettingDefaults(settings) + return nil + }) + if err != nil { + return BanSettings{}, err + } + return settings, nil +} + +func (s *BanService) UpdateSettings(settings BanSettings) error { + settings = withBanSettingDefaults(settings) + if settings.AutoBanDurationHours <= 0 || settings.MaliciousPathThreshold <= 0 || + settings.AdminLoginFailureThreshold <= 0 || settings.UserLoginFailureThreshold <= 0 || + settings.AbuseWindowHours <= 0 { + return fmt.Errorf("ban settings must be positive") + } + data, err := json.Marshal(settings) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(banSettingsBucket).Put(banSettingsKey, data) + }) +} + +func withBanSettingDefaults(settings BanSettings) BanSettings { + defaults := DefaultBanSettings() + if settings.AutoBanDurationHours <= 0 { + settings.AutoBanDurationHours = defaults.AutoBanDurationHours + } + if settings.MaliciousPathThreshold <= 0 { + settings.MaliciousPathThreshold = defaults.MaliciousPathThreshold + } + if settings.AdminLoginFailureThreshold <= 0 { + settings.AdminLoginFailureThreshold = defaults.AdminLoginFailureThreshold + } + if settings.UserLoginFailureThreshold <= 0 { + settings.UserLoginFailureThreshold = defaults.UserLoginFailureThreshold + } + if settings.AbuseWindowHours <= 0 { + settings.AbuseWindowHours = defaults.AbuseWindowHours + } + return settings +} + +func (s *BanService) CreateManualBan(target, reason, createdBy string, expiresAt time.Time) (BanRecord, error) { + return s.createBan(target, reason, BanSourceManual, createdBy, expiresAt, time.Now().UTC()) +} + +func (s *BanService) createBan(target, reason, source, createdBy string, expiresAt, now time.Time) (BanRecord, error) { + normalized, err := NormalizeBanTarget(target) + if err != nil { + return BanRecord{}, err + } + reason = strings.TrimSpace(reason) + if reason == "" { + return BanRecord{}, fmt.Errorf("ban reason is required") + } + if !expiresAt.After(now) { + return BanRecord{}, fmt.Errorf("ban expiration must be in the future") + } + record := BanRecord{ + ID: randomID(12), + Target: strings.TrimSpace(target), + Normalized: normalized, + Reason: reason, + Source: source, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: expiresAt.UTC(), + } + data, err := json.Marshal(record) + if err != nil { + return BanRecord{}, err + } + err = s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(bansBucket).Put([]byte(record.ID), data) + }) + return record, err +} + +func NormalizeBanTarget(target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("ban target is required") + } + if strings.Contains(target, "/") { + _, network, err := net.ParseCIDR(target) + if err != nil { + return "", fmt.Errorf("invalid CIDR target") + } + return network.String(), nil + } + ip := net.ParseIP(target) + if ip == nil { + return "", fmt.Errorf("invalid IP target") + } + return ip.String(), nil +} + +func (s *BanService) ListBans() ([]BanRecord, error) { + records := []BanRecord{} + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(bansBucket).ForEach(func(_, value []byte) error { + var record BanRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + records = append(records, record) + return nil + }) + }) + sort.Slice(records, func(i, j int) bool { + return records[i].CreatedAt.After(records[j].CreatedAt) + }) + return records, err +} + +func (s *BanService) Unban(id string, now time.Time) error { + id = strings.TrimSpace(id) + return s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bansBucket) + data := bucket.Get([]byte(id)) + if data == nil { + return ErrBanNotFound + } + var record BanRecord + if err := json.Unmarshal(data, &record); err != nil { + return err + } + now = now.UTC() + record.UnbannedAt = &now + record.UpdatedAt = now + next, err := json.Marshal(record) + if err != nil { + return err + } + return bucket.Put([]byte(id), next) + }) +} + +func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return MatchedBan{}, false, nil + } + now = now.UTC() + var matched BanRecord + err := s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bansBucket) + return bucket.ForEach(func(key, value []byte) error { + if matched.ID != "" { + return nil + } + var record BanRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) { + return nil + } + record.LastMatchedAt = &now + record.UpdatedAt = now + next, err := json.Marshal(record) + if err != nil { + return err + } + if err := bucket.Put(key, next); err != nil { + return err + } + matched = record + return nil + }) + }) + return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err +} + +func (r BanRecord) Active(now time.Time) bool { + return r.UnbannedAt == nil && r.ExpiresAt.After(now.UTC()) +} + +func (r BanRecord) Status(now time.Time) string { + switch { + case r.UnbannedAt != nil: + return "unbanned" + case !r.ExpiresAt.After(now.UTC()): + return "expired" + default: + return "active" + } +} + +func banTargetMatches(target string, ip net.IP) bool { + if strings.Contains(target, "/") { + if _, network, err := net.ParseCIDR(target); err == nil { + return network.Contains(ip) + } + return false + } + targetIP := net.ParseIP(target) + return targetIP != nil && targetIP.Equal(ip) +} + +func (s *BanService) ListRules() ([]BanRule, error) { + rules := []BanRule{} + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(banRulesBucket).ForEach(func(key, value []byte) error { + if string(key) == string(defaultBanRulesSeed) { + return nil + } + var rule BanRule + if err := json.Unmarshal(value, &rule); err != nil { + return err + } + rules = append(rules, rule) + return nil + }) + }) + sort.Slice(rules, func(i, j int) bool { + return strings.ToLower(rules[i].Pattern) < strings.ToLower(rules[j].Pattern) + }) + return rules, err +} + +func (s *BanService) SaveRules(patterns []string, now time.Time) error { + now = now.UTC() + return s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(banRulesBucket) + deleteKeys := [][]byte{} + if err := bucket.ForEach(func(key, _ []byte) error { + if string(key) == string(defaultBanRulesSeed) { + return nil + } + deleteKeys = append(deleteKeys, append([]byte(nil), key...)) + return nil + }); err != nil { + return err + } + for _, key := range deleteKeys { + if err := bucket.Delete(key); err != nil { + return err + } + } + seen := map[string]bool{} + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" || seen[strings.ToLower(pattern)] { + continue + } + seen[strings.ToLower(pattern)] = true + rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now} + data, err := json.Marshal(rule) + if err != nil { + return err + } + if err := bucket.Put([]byte(rule.ID), data); err != nil { + return err + } + } + return nil + }) +} + +func (s *BanService) DeleteRule(id string) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(banRulesBucket).Delete([]byte(strings.TrimSpace(id))) + }) +} + +func (s *BanService) MaliciousPattern(path string) (string, error) { + if shouldSkipMaliciousPath(path) { + return "", nil + } + rules, err := s.ListRules() + if err != nil { + return "", err + } + lowerPath := strings.ToLower(path) + for _, rule := range rules { + if rule.Enabled && strings.Contains(lowerPath, strings.ToLower(rule.Pattern)) { + return rule.Pattern, nil + } + } + return "", nil +} + +func shouldSkipMaliciousPath(path string) bool { + return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/") +} + +func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) { + settings, err := s.Settings() + if err != nil { + return AbuseResult{}, err + } + if !settings.AutoBanEnabled { + return AbuseResult{Enabled: false}, nil + } + if threshold <= 0 { + return AbuseResult{Enabled: true}, nil + } + now = now.UTC() + window := time.Duration(settings.AbuseWindowHours) * time.Hour + key := abuseKey(ip, kind) + var event AbuseEvent + var triggered bool + var ban BanRecord + err = s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(abuseEventsBucket) + data := bucket.Get([]byte(key)) + if data != nil { + if err := json.Unmarshal(data, &event); err != nil { + return err + } + } + if data == nil || now.Sub(event.FirstSeen) > window { + event = AbuseEvent{Key: key, IP: ip, Kind: kind, FirstSeen: now} + } + event.Count++ + event.LastSeen = now + event.Detail = detail + next, err := json.Marshal(event) + if err != nil { + return err + } + if err := bucket.Put([]byte(key), next); err != nil { + return err + } + triggered = event.Count >= threshold + return nil + }) + if err != nil || !triggered { + return AbuseResult{Event: event, Triggered: false, Enabled: true}, err + } + reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail) + ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now) + if err != nil { + return AbuseResult{}, err + } + return AbuseResult{Event: event, Ban: ban, Triggered: true, Enabled: true}, nil +} + +func (s *BanService) CleanupAbuseEvents(now time.Time) (int, error) { + settings, err := s.Settings() + if err != nil { + return 0, err + } + cutoff := now.UTC().Add(-time.Duration(settings.AbuseWindowHours) * time.Hour) + cleaned := 0 + err = s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(abuseEventsBucket) + deleteKeys := [][]byte{} + if err := bucket.ForEach(func(key, value []byte) error { + var event AbuseEvent + if err := json.Unmarshal(value, &event); err != nil { + deleteKeys = append(deleteKeys, append([]byte(nil), key...)) + return nil + } + if event.LastSeen.Before(cutoff) { + deleteKeys = append(deleteKeys, append([]byte(nil), key...)) + } + return nil + }); err != nil { + return err + } + for _, key := range deleteKeys { + if err := bucket.Delete(key); err != nil { + return err + } + cleaned++ + } + return nil + }) + return cleaned, err +} + +func abuseKey(ip, kind string) string { + return kind + ":" + strings.TrimSpace(ip) +} diff --git a/backend/libs/services/bans_test.go b/backend/libs/services/bans_test.go new file mode 100644 index 0000000..6358dac --- /dev/null +++ b/backend/libs/services/bans_test.go @@ -0,0 +1,117 @@ +package services + +import ( + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestBanServiceMatchesIPAndCIDR(t *testing.T) { + bans := newTestBanService(t) + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + ipBan, err := bans.createBan("203.0.113.5", "single IP", BanSourceManual, "test", now.Add(time.Hour), now) + if err != nil { + t.Fatalf("createBan IP returned error: %v", err) + } + cidrBan, err := bans.createBan("198.51.100.0/24", "CIDR", BanSourceManual, "test", now.Add(time.Hour), now) + if err != nil { + t.Fatalf("createBan CIDR returned error: %v", err) + } + + if matched, ok, err := bans.Match("203.0.113.5", now); err != nil || !ok || matched.Ban.ID != ipBan.ID { + t.Fatalf("Match IP = %+v, %v, %v", matched, ok, err) + } + if matched, ok, err := bans.Match("198.51.100.42", now); err != nil || !ok || matched.Ban.ID != cidrBan.ID { + t.Fatalf("Match CIDR = %+v, %v, %v", matched, ok, err) + } + if _, ok, err := bans.Match("192.0.2.1", now); err != nil || ok { + t.Fatalf("Match unrelated = %v, %v", ok, err) + } +} + +func TestBanServiceIgnoresExpiredAndUnbanned(t *testing.T) { + bans := newTestBanService(t) + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + expired, err := bans.createBan("203.0.113.6", "expired", BanSourceManual, "test", now.Add(time.Hour), now) + if err != nil { + t.Fatalf("createBan expired returned error: %v", err) + } + if _, ok, err := bans.Match("203.0.113.6", now.Add(2*time.Hour)); err != nil || ok { + t.Fatalf("expired Match = %v, %v", ok, err) + } + active, err := bans.createBan("203.0.113.7", "active", BanSourceManual, "test", now.Add(time.Hour), now) + if err != nil { + t.Fatalf("createBan active returned error: %v", err) + } + if err := bans.Unban(active.ID, now.Add(time.Minute)); err != nil { + t.Fatalf("Unban returned error: %v", err) + } + if _, ok, err := bans.Match("203.0.113.7", now.Add(2*time.Minute)); err != nil || ok { + t.Fatalf("unbanned Match = %v, %v", ok, err) + } + if expired.Status(now.Add(2*time.Hour)) != "expired" { + t.Fatalf("expired status = %q", expired.Status(now.Add(2*time.Hour))) + } +} + +func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) { + bans := newTestBanService(t) + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + if result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now); err != nil || result.Enabled { + t.Fatalf("disabled RecordAbuse = %+v, %v", result, err) + } + settings, err := bans.Settings() + if err != nil { + t.Fatalf("Settings returned error: %v", err) + } + settings.AutoBanEnabled = true + if err := bans.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings returned error: %v", err) + } + for i := 0; i < 2; i++ { + result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(time.Duration(i)*time.Minute)) + if err != nil || result.Triggered { + t.Fatalf("RecordAbuse before threshold = %+v, %v", result, err) + } + } + result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(3*time.Minute)) + if err != nil || !result.Triggered || result.Ban.ID == "" { + t.Fatalf("RecordAbuse threshold = %+v, %v", result, err) + } +} + +func TestBanServiceMaliciousPathRules(t *testing.T) { + bans := newTestBanService(t) + if pattern, err := bans.MaliciousPattern("/foo/.ENV"); err != nil || pattern == "" { + t.Fatalf("MaliciousPattern .env = %q, %v", pattern, err) + } + if pattern, err := bans.MaliciousPattern("/static/.env"); err != nil || pattern != "" { + t.Fatalf("MaliciousPattern static = %q, %v", pattern, err) + } + if err := bans.SaveRules([]string{"/custom-probe"}, time.Now().UTC()); err != nil { + t.Fatalf("SaveRules returned error: %v", err) + } + if pattern, err := bans.MaliciousPattern("/x/CUSTOM-probe"); err != nil || pattern != "/custom-probe" { + t.Fatalf("MaliciousPattern custom = %q, %v", pattern, err) + } +} + +func newTestBanService(t *testing.T) *BanService { + t.Helper() + root := t.TempDir() + upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default()) + if err != nil { + t.Fatalf("NewUploadService returned error: %v", err) + } + t.Cleanup(func() { + if err := upload.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }) + bans, err := NewBanService(upload.DB()) + if err != nil { + t.Fatalf("NewBanService returned error: %v", err) + } + return bans +} diff --git a/backend/libs/services/proxy.go b/backend/libs/services/proxy.go new file mode 100644 index 0000000..0347e19 --- /dev/null +++ b/backend/libs/services/proxy.go @@ -0,0 +1,75 @@ +package services + +import ( + "context" + "net" + "net/http" + "strings" +) + +type clientIPContextKey struct{} + +func WithClientIP(r *http.Request, ip string) *http.Request { + return r.WithContext(context.WithValue(r.Context(), clientIPContextKey{}, ip)) +} + +func ClientIPFromContext(r *http.Request) (string, bool) { + ip, ok := r.Context().Value(clientIPContextKey{}).(string) + return ip, ok && ip != "" +} + +// ClientIP resolves the effective client IP. When trustedProxies is empty, +// forwarded headers are trusted for easy reverse-proxy/container defaults. +func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string { + remoteIP := remoteIPOnly(remoteAddr) + if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) { + if ip := firstForwardedIP(forwardedFor); ip != "" { + return ip + } + if ip := strings.TrimSpace(realIP); ip != "" { + return ip + } + } + return remoteIP +} + +func remoteIPOnly(remoteAddr string) string { + host := strings.TrimSpace(remoteAddr) + if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = splitHost + } + return strings.Trim(host, "[]") +} + +func firstForwardedIP(forwardedFor string) string { + for _, part := range strings.Split(forwardedFor, ",") { + ip := strings.TrimSpace(part) + if ip != "" { + return strings.Trim(ip, "[]") + } + } + return "" +} + +func remoteTrusted(remoteIP string, trustedProxies []string) bool { + parsed := net.ParseIP(remoteIP) + if parsed == nil { + return false + } + for _, trusted := range trustedProxies { + trusted = strings.TrimSpace(trusted) + if trusted == "" { + continue + } + if strings.Contains(trusted, "/") { + if _, network, err := net.ParseCIDR(trusted); err == nil && network.Contains(parsed) { + return true + } + continue + } + if ip := net.ParseIP(trusted); ip != nil && ip.Equal(parsed) { + return true + } + } + return false +} diff --git a/backend/libs/services/proxy_test.go b/backend/libs/services/proxy_test.go new file mode 100644 index 0000000..c2d6e59 --- /dev/null +++ b/backend/libs/services/proxy_test.go @@ -0,0 +1,29 @@ +package services + +import "testing" + +func TestClientIPTrustsForwardedHeadersByDefault(t *testing.T) { + ip := ClientIP("127.0.0.1:6070", "203.0.113.10, 10.0.0.2", "198.51.100.2", nil) + if ip != "203.0.113.10" { + t.Fatalf("ClientIP = %q, want forwarded IP", ip) + } +} + +func TestClientIPUsesTrustedProxyCIDRs(t *testing.T) { + trusted := []string{"127.0.0.1", "172.16.0.0/12"} + ip := ClientIP("172.20.0.4:6070", "203.0.113.11", "", trusted) + if ip != "203.0.113.11" { + t.Fatalf("trusted ClientIP = %q", ip) + } + spoofed := ClientIP("198.51.100.20:6070", "203.0.113.12", "203.0.113.13", trusted) + if spoofed != "198.51.100.20" { + t.Fatalf("untrusted ClientIP = %q, want remote addr", spoofed) + } +} + +func TestClientIPFallsBackToRealIP(t *testing.T) { + ip := ClientIP("127.0.0.1:6070", "", "203.0.113.14", nil) + if ip != "203.0.113.14" { + t.Fatalf("ClientIP = %q, want real IP", ip) + } +} diff --git a/backend/libs/services/settings.go b/backend/libs/services/settings.go index 63f03cd..d7fa8af 100644 --- a/backend/libs/services/settings.go +++ b/backend/libs/services/settings.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "math" - "net" "strconv" "strings" "time" @@ -484,19 +483,3 @@ func normalizeBackendID(id string) string { } return id } - -func ClientIP(remoteAddr, forwardedFor string) string { - if forwardedFor != "" { - parts := strings.Split(forwardedFor, ",") - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - host := remoteAddr - if strings.Contains(remoteAddr, ":") { - if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil { - host = splitHost - } - } - return host -} diff --git a/backend/static/css/16-retro.css b/backend/static/css/16-retro.css index d30a9dd..923775d 100644 --- a/backend/static/css/16-retro.css +++ b/backend/static/css/16-retro.css @@ -189,14 +189,25 @@ padding-left: calc(0.85rem + 1px); } -/* The primary call-to-action gets the blue title-bar gradient. */ +/* The primary call-to-action is a glossy raised blue button. A vertical + gradient + strong 3D bevel keeps it clearly a button (and distinct from the + horizontal title-bar gradient). */ :root[data-theme="retro"] .button-primary { - background: linear-gradient(to right, #000078, 80%, #0f80cd); + background: linear-gradient(to bottom, #2f86e0 0%, #0a3aa0 52%, #000078 100%); color: #ffffff; + border: 1px solid #000000; + box-shadow: inset -1px -1px 0 #00003a, inset 1px 1px 0 #7fc0ff, inset -2px -2px 0 #001a6a, inset 2px 2px 0 #3f9fe8; + text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4); } :root[data-theme="retro"] .button-primary:hover { - background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd); + filter: brightness(1.08); +} + +:root[data-theme="retro"] .button-primary:active { + box-shadow: inset 1px 1px 0 #00003a, inset -1px -1px 0 #7fc0ff; + padding-top: calc(0.45rem + 1px); + padding-left: calc(0.85rem + 1px); } :root[data-theme="retro"] .button-danger { @@ -277,7 +288,8 @@ the API section cards. Pages where a heading sits below an icon or kicker (download/preview/login) keep the inset heading from the base h1 rule. */ :root[data-theme="retro"] .card-content > h1:first-child, -:root[data-theme="retro"] .docs-header h1 { +:root[data-theme="retro"] .docs-header h1, +:root[data-theme="retro"] .download-view-wide .download-card h1 { margin: -1.5rem -1.5rem 1rem; } @@ -547,3 +559,64 @@ border: 1px solid #000000; box-shadow: var(--shadow); } + +/* ------------------------------------------------------------------------- */ +/* Download / box page */ +/* ------------------------------------------------------------------------- */ + +/* The decorative file glyph above the title doesn't suit a Win98 window. */ +:root[data-theme="retro"] .file-emblem { + display: none; +} + +/* The download window's content is left-aligned like a real file manager. */ +:root[data-theme="retro"] .download-view-wide .download-card { + text-align: left; +} + +/* Expiry shown as a sunken status field with a little clock. */ +:root[data-theme="retro"] .badge-row { + justify-content: flex-start; +} + +:root[data-theme="retro"] .badge-expiry { + background: #ffffff; + color: #000000; + border: 1px solid #000000; + box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; + font-weight: 700; + padding: 0.3rem 0.7rem; +} + +:root[data-theme="retro"] .badge-expiry::before { + content: "\23F1 "; +} + +/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat + buttons that raise on hover and depress when active. */ +:root[data-theme="retro"] .view-toolbar { + justify-content: flex-start; + gap: 2px; + margin-top: 1rem; + padding: 3px; + background: #c0c0c0; + border: 1px solid #000000; + box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080; +} + +:root[data-theme="retro"] .view-toolbar .button { + background: transparent; + border: 1px solid transparent; + box-shadow: none; + font-weight: 400; +} + +:root[data-theme="retro"] .view-toolbar .button:hover { + background: #c0c0c0; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff; +} + +:root[data-theme="retro"] .view-toolbar .button.is-active { + background: #d4d0c8; + box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; +} diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 7d82b84..073a1ca 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -106,6 +106,75 @@ margin: 0; } +.logs-filter-card { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.7rem; + align-items: end; + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); +} + +.logs-filter-card label { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.logs-filter-card label span { + color: var(--muted-foreground); + font-size: 0.72rem; +} + +.logs-table td { + vertical-align: top; +} + +.logs-table code { + white-space: pre-wrap; + word-break: break-word; +} + +.log-time { + white-space: nowrap; +} + +.admin-grid-two { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.compact-form { + display: grid; + gap: 0.75rem; +} + +.compact-form textarea { + width: 100%; + resize: vertical; +} + +@media (max-width: 980px) { + .admin-grid-two { + grid-template-columns: 1fr; + } + + .logs-filter-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 620px) { + .logs-filter-card { + grid-template-columns: 1fr; + } +} + /* Inline row edit (details/summary in table cells) */ .row-edit { diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index 9f0dc4f..ff1b974 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -9,6 +9,8 @@ {{template "icon-user-circle" .}}Users {{template "icon-settings" .}}Settings {{template "icon-database" .}}Storage + {{template "icon-database" .}}Logs + {{template "icon-settings" .}}Bans diff --git a/backend/templates/pages/admin_users.html b/backend/templates/pages/admin_users.html index 1d6c76a..b6e906e 100644 --- a/backend/templates/pages/admin_users.html +++ b/backend/templates/pages/admin_users.html @@ -9,6 +9,8 @@ {{template "icon-user-circle" .}}Users {{template "icon-settings" .}}Settings {{template "icon-database" .}}Storage + {{template "icon-database" .}}Logs + {{template "icon-settings" .}}Bans