feat(security): add trusted proxies and abuse event cleanup
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
- 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.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user