From fbeff3f6c0a6b8fc630b698c0b55375e20c3ed1d Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 4 May 2026 00:00:36 +0300 Subject: [PATCH] feat/security Reviewed-on: https://tea.chunkbyte.com/kato/warpbox/pulls/2 --- NOTICE | 4 - TO-DO.md | 114 +++++++ TRADEMARK.md | 2 - docs/geoip-guide.md | 19 ++ docs/security-runbook.md | 40 +++ go.mod | 26 +- go.sum | 53 +++- lib/activity/activity.go | 116 ++++++++ lib/alerts/alerts.go | 151 ++++++++++ lib/boxstore/cleanup.go | 60 ++++ lib/boxstore/cleanup_test.go | 58 ++++ lib/config/config_test.go | 9 + lib/config/definitions.go | 14 + lib/config/load.go | 54 ++++ lib/config/models.go | 28 ++ lib/config/overrides.go | 61 +++- lib/routing/routes.go | 8 + lib/security/guard.go | 426 +++++++++++++++++++++++++++ lib/security/guard_test.go | 52 ++++ lib/server/admin.go | 57 ++++ lib/server/admin_boxes.go | 33 ++- lib/server/admin_security.go | 331 +++++++++++++++++++++ lib/server/admin_security_test.go | 125 ++++++++ lib/server/admin_settings.go | 26 ++ lib/server/admin_settings_test.go | 29 ++ lib/server/cleanup.go | 62 ++++ lib/server/ip.go | 107 +++++++ lib/server/ip_test.go | 44 +++ lib/server/server.go | 24 +- lib/server/uploads.go | 17 ++ lib/server/validation.go | 37 +++ run.sh | 13 + static/css/activity.css | 63 ++++ static/css/security.css | 100 +++++++ static/js/admin/activity.js | 106 +++++++ static/js/admin/alerts.js | 355 ++++++++++++---------- static/js/admin/boxes.js | 33 +++ static/js/admin/security.js | 314 ++++++++++++++++++++ templates/admin/activity.html | 92 ++++++ templates/admin/alerts.html | 118 +------- templates/admin/boxes.html | 5 + templates/admin/partials/header.html | 2 + templates/admin/security.html | 179 +++++++++++ 43 files changed, 3268 insertions(+), 299 deletions(-) delete mode 100644 NOTICE create mode 100644 TO-DO.md delete mode 100644 TRADEMARK.md create mode 100644 docs/geoip-guide.md create mode 100644 docs/security-runbook.md create mode 100644 lib/activity/activity.go create mode 100644 lib/alerts/alerts.go create mode 100644 lib/boxstore/cleanup.go create mode 100644 lib/boxstore/cleanup_test.go create mode 100644 lib/security/guard.go create mode 100644 lib/security/guard_test.go create mode 100644 lib/server/admin_security.go create mode 100644 lib/server/admin_security_test.go create mode 100644 lib/server/cleanup.go create mode 100644 lib/server/ip.go create mode 100644 lib/server/ip_test.go create mode 100644 static/css/activity.css create mode 100644 static/css/security.css create mode 100644 static/js/admin/activity.js create mode 100644 static/js/admin/security.js create mode 100644 templates/admin/activity.html create mode 100644 templates/admin/security.html diff --git a/NOTICE b/NOTICE deleted file mode 100644 index a846cb4..0000000 --- a/NOTICE +++ /dev/null @@ -1,4 +0,0 @@ -WarpBox -Copyright (c) 2026 Daniel Legt - -This product includes software developed by Daniel Legt. \ No newline at end of file diff --git a/TO-DO.md b/TO-DO.md new file mode 100644 index 0000000..f996782 --- /dev/null +++ b/TO-DO.md @@ -0,0 +1,114 @@ +# WarpBox Security TO-DO + +## 1) High Priority (Do Next) + +- [ ] Persist IP bans across restarts + - Current: bans stored in-memory (`lib/security/guard.go`) + - Target: durable store in `DBDir` (similar style to `activity`/`alerts`) + - Include: startup load, expiry cleanup, atomic writes, corruption-safe fallback + +- [ ] Add trusted proxy CIDR config + - Current: forwarded headers trusted only when remote hop is private/local (`lib/server/ip.go`) + - Risk: heuristic-only trust model + - Target: + - `WARPBOX_TRUSTED_PROXY_CIDRS` setting + - trust `X-Forwarded-For` only when `RemoteAddr` in trusted CIDR + - fallback to direct remote IP otherwise + +- [ ] Add CIDR/range support for whitelists + - Current: exact IP match only (`WARPBOX_SECURITY_IP_WHITELIST`, `WARPBOX_SECURITY_ADMIN_IP_WHITELIST`) + - Target: support exact IP + CIDR entries + - Include strict parser + validation errors in settings save + +- [ ] Add unban / ban edit API audit trail hardening + - Ensure all manual ban/unban/ban-until actions always write: + - activity event + - alert (or policy-based selective alerting) + - Add tests for these paths + +## 2) Medium Priority + +- [ ] GeoIP integration for security detail pane + - Current: placeholder fields in `/admin/security` + - Target: wire geoipfast provider for country/region/ASN fields + - Add caching + timeout/failure-safe behavior + +- [ ] Expand malicious path detection rules + - Current: simple substring checks in `handleNoRoute` + - Target: + - rule list/pattern config + - normalize URL + decode checks + - classify severity by signature group + +- [ ] Add global abuse score per IP + - Combine signals: + - failed admin auth + - malicious path scans + - upload abuse + - Use score to escalate ban duration automatically + +- [ ] Ban duration policy ladder + - Current: fixed `WARPBOX_SECURITY_BAN_SECONDS` + - Target: + - progressive durations (e.g., 30m, 2h, 24h) + - reset after quiet period + +- [ ] Add security settings validation UX + - Ensure invalid values (negative, malformed lists, invalid CIDR) rejected with clear UI errors + - Add server tests for malformed security override payloads + +## 3) Admin UX Follow-Ups + +- [ ] Add dedicated “Active Bans” page-level controls + - bulk unban + - filter/sort by expiry and IP + - copy IP and quick search in activity/alerts + +- [ ] Add “why banned” detail + - link ban entry to latest triggering events and alerts + - show counts in active windows (login/scan/upload) + +- [ ] Add optional confirmation modal for destructive security actions + - unban all / bulk unban / long custom bans + +## 4) Testing & QA + +- [ ] Add unit tests for `lib/security/guard.go` + - `Ban`, `BanUntil`, `Unban`, `BanList` expiry pruning + - login/scan threshold behavior + - upload rate limiting behavior + +- [ ] Add tests for real-IP resolution edge cases (`lib/server/ip.go`) + - direct client + - trusted proxy chain + - spoofed forwarding headers from untrusted remote + +- [ ] Add integration tests for security endpoints + - `/admin/security/actions` ban/ban_until/unban + - `/admin/alerts/actions` + - admin login brute-force auto-ban flow + +- [ ] Add concurrency/race test pass in CI + - run `go test ./... -race` in workflow (where Go toolchain available) + +## 5) Operational / Deployment + +- [ ] Document reverse-proxy setup requirements + - Caddy / ingress config examples for forwarding headers + - guidance for trusted proxy CIDRs + +- [ ] Add security runbook + - how to investigate alerts + - how to ban/unban safely + - how to tune thresholds for low/high traffic environments + +- [ ] Add metrics hooks (future) + - counts: blocked requests, bans issued, unbans, alert volume + - expose to Prometheus-compatible endpoint later + +## 6) Nice-to-Have (Later) + +- [ ] Optional external enforcement bridge (fail2ban-compatible log format) +- [ ] Webhook notifications for high-severity security alerts +- [ ] Per-account/API-key limits once account system matures + diff --git a/TRADEMARK.md b/TRADEMARK.md deleted file mode 100644 index f97efd5..0000000 --- a/TRADEMARK.md +++ /dev/null @@ -1,2 +0,0 @@ -The name "WarpBox" and associated branding are not licensed under the Apache License 2.0. -You may not use them without permission. \ No newline at end of file diff --git a/docs/geoip-guide.md b/docs/geoip-guide.md new file mode 100644 index 0000000..1100ce6 --- /dev/null +++ b/docs/geoip-guide.md @@ -0,0 +1,19 @@ +# GeoIP Guide (Planning) + +This project intentionally does not enable GeoIP enforcement yet. + +Planned integration target: `github.com/rabuchaim/geoip2fast`. + +## Recommended approach + +1. Load one shared GeoIP provider instance at startup. +2. Add a small in-memory cache keyed by IP with TTL. +3. Apply lookup timeout and fallback to `unknown` values on failures. +4. Use results first in the admin security detail pane. +5. Add aggregated statistics only after detail pane behavior is stable. + +## Why this is safe + +- No request path should fail because GeoIP lookup fails. +- Lookup cost stays bounded with caching. +- Security decisions remain independent from GeoIP quality. diff --git a/docs/security-runbook.md b/docs/security-runbook.md new file mode 100644 index 0000000..2735377 --- /dev/null +++ b/docs/security-runbook.md @@ -0,0 +1,40 @@ +# Security Runbook + +## Trusted Proxy Setup (Caddy) + +Set `WARPBOX_TRUSTED_PROXY_CIDRS` to only the CIDRs of your reverse proxies/load balancers. + +Example: + +```bash +WARPBOX_TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16 +``` + +Caddy example: + +```caddyfile +:443 { + reverse_proxy 127.0.0.1:8080 { + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Real-IP {http.request.remote.host} + } +} +``` + +WarpBox will trust `X-Forwarded-For` only if the direct remote IP is inside `WARPBOX_TRUSTED_PROXY_CIDRS`. + +## IP Ban Operations + +- Use temporary bans by default. +- Use `ban_until` only for active incidents requiring explicit windows. +- Before unbanning, inspect related activity and alerts for repeated abuse patterns. +- For destructive actions (`bulk_unban`, `unban_all`), require explicit confirmation. + +## Tuning Guidance + +- Low traffic deployments: reduce max-attempt thresholds to catch abuse faster. +- High traffic deployments: increase windows and max-attempts incrementally to reduce false positives. +- Watch for: + - repeated `auth.admin.failed` + - repeated `security.scan` + - frequent `security.upload_limit` diff --git a/go.mod b/go.mod index 3e3cd58..dd7a2b6 100644 --- a/go.mod +++ b/go.mod @@ -3,43 +3,51 @@ module warpbox go 1.23.0 require ( + github.com/dgraph-io/badger/v4 v4.9.1 github.com/gin-contrib/gzip v1.0.1 github.com/gin-gonic/gin v1.10.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.41.0 ) require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/go-cmp v0.7.0 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a4f01fb..4c8fb84 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE= @@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -29,6 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -36,15 +52,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -58,10 +73,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -86,21 +99,29 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/lib/activity/activity.go b/lib/activity/activity.go new file mode 100644 index 0000000..7727934 --- /dev/null +++ b/lib/activity/activity.go @@ -0,0 +1,116 @@ +package activity + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Event struct { + ID string `json:"id"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Message string `json:"message"` + Actor string `json:"actor"` + IP string `json:"ip"` + Path string `json:"path"` + Method string `json:"method"` + CreatedAt time.Time `json:"created_at"` + Meta map[string]string `json:"meta,omitempty"` +} + +type Store struct { + path string + mu sync.Mutex +} + +func NewStore(path string) *Store { + return &Store{path: path} +} + +func (s *Store) Append(event Event, retentionSeconds int64) error { + s.mu.Lock() + defer s.mu.Unlock() + + events, err := s.readLocked() + if err != nil { + return err + } + if event.CreatedAt.IsZero() { + event.CreatedAt = time.Now().UTC() + } + if event.ID == "" { + event.ID = event.CreatedAt.Format("20060102T150405.000000000") + } + + events = append(events, event) + events = pruneByRetention(events, retentionSeconds) + return s.writeLocked(events) +} + +func (s *Store) List(limit int, retentionSeconds int64) ([]Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + + events, err := s.readLocked() + if err != nil { + return nil, err + } + events = pruneByRetention(events, retentionSeconds) + if err := s.writeLocked(events); err != nil { + return nil, err + } + sort.Slice(events, func(i, j int) bool { + return events[i].CreatedAt.After(events[j].CreatedAt) + }) + if limit > 0 && len(events) > limit { + return events[:limit], nil + } + return events, nil +} + +func pruneByRetention(events []Event, retentionSeconds int64) []Event { + if retentionSeconds <= 0 { + return events + } + cutoff := time.Now().UTC().Add(-time.Duration(retentionSeconds) * time.Second) + out := make([]Event, 0, len(events)) + for _, event := range events { + if event.CreatedAt.IsZero() || event.CreatedAt.After(cutoff) { + out = append(out, event) + } + } + return out +} + +func (s *Store) readLocked() ([]Event, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return []Event{}, nil + } + return nil, err + } + if len(data) == 0 { + return []Event{}, nil + } + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return []Event{}, nil + } + return events, nil +} + +func (s *Store) writeLocked(events []Event) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(events, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} diff --git a/lib/alerts/alerts.go b/lib/alerts/alerts.go new file mode 100644 index 0000000..a065a0a --- /dev/null +++ b/lib/alerts/alerts.go @@ -0,0 +1,151 @@ +package alerts + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "time" +) + +type Status string + +const ( + StatusOpen Status = "open" + StatusAcked Status = "acked" + StatusClosed Status = "closed" +) + +type Alert struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status Status `json:"status"` + Group string `json:"group"` + Code string `json:"code"` + Trace string `json:"trace"` + Message string `json:"message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Meta map[string]string `json:"meta,omitempty"` +} + +type Store struct { + path string + mu sync.Mutex +} + +func NewStore(path string) *Store { + return &Store{path: path} +} + +func (s *Store) Add(alert Alert) error { + s.mu.Lock() + defer s.mu.Unlock() + + alertsList, err := s.readLocked() + if err != nil { + return err + } + now := time.Now().UTC() + if alert.ID == "" { + alert.ID = strconv.FormatInt(now.UnixNano(), 10) + } + if alert.Status == "" { + alert.Status = StatusOpen + } + if alert.CreatedAt.IsZero() { + alert.CreatedAt = now + } + alert.UpdatedAt = now + alertsList = append(alertsList, alert) + return s.writeLocked(alertsList) +} + +func (s *Store) List(limit int) ([]Alert, error) { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return nil, err + } + sort.Slice(alertsList, func(i, j int) bool { + return alertsList[i].CreatedAt.After(alertsList[j].CreatedAt) + }) + if limit > 0 && len(alertsList) > limit { + return alertsList[:limit], nil + } + return alertsList, nil +} + +func (s *Store) SetStatus(ids []string, status Status) error { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return err + } + target := map[string]bool{} + for _, id := range ids { + target[id] = true + } + now := time.Now().UTC() + for i := range alertsList { + if target[alertsList[i].ID] { + alertsList[i].Status = status + alertsList[i].UpdatedAt = now + } + } + return s.writeLocked(alertsList) +} + +func (s *Store) Delete(ids []string) error { + s.mu.Lock() + defer s.mu.Unlock() + alertsList, err := s.readLocked() + if err != nil { + return err + } + target := map[string]bool{} + for _, id := range ids { + target[id] = true + } + kept := make([]Alert, 0, len(alertsList)) + for _, alert := range alertsList { + if !target[alert.ID] { + kept = append(kept, alert) + } + } + return s.writeLocked(kept) +} + +func (s *Store) readLocked() ([]Alert, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return []Alert{}, nil + } + return nil, err + } + if len(data) == 0 { + return []Alert{}, nil + } + var alertsList []Alert + if err := json.Unmarshal(data, &alertsList); err != nil { + return []Alert{}, nil + } + return alertsList, nil +} + +func (s *Store) writeLocked(alertsList []Alert) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(alertsList, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} diff --git a/lib/boxstore/cleanup.go b/lib/boxstore/cleanup.go new file mode 100644 index 0000000..acf8331 --- /dev/null +++ b/lib/boxstore/cleanup.go @@ -0,0 +1,60 @@ +package boxstore + +import ( + "fmt" + "os" +) + +type CleanupExpiredResult struct { + Scanned int + Deleted int + Skipped int + DeletedIDs []string + Warnings []string +} + +func CleanupExpiredBoxes() (CleanupExpiredResult, error) { + entries, err := os.ReadDir(uploadRoot) + if err != nil { + if os.IsNotExist(err) { + return CleanupExpiredResult{}, nil + } + return CleanupExpiredResult{}, err + } + + result := CleanupExpiredResult{ + DeletedIDs: make([]string, 0), + Warnings: make([]string, 0), + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + boxID := entry.Name() + if !ValidBoxID(boxID) { + continue + } + result.Scanned++ + + manifest, err := ReadManifest(boxID) + if err != nil { + result.Skipped++ + if !os.IsNotExist(err) { + result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err)) + } + continue + } + if !IsExpired(manifest) { + continue + } + if err := DeleteBox(boxID); err != nil { + result.Skipped++ + result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err)) + continue + } + result.Deleted++ + result.DeletedIDs = append(result.DeletedIDs, boxID) + } + + return result, nil +} diff --git a/lib/boxstore/cleanup_test.go b/lib/boxstore/cleanup_test.go new file mode 100644 index 0000000..d7f588a --- /dev/null +++ b/lib/boxstore/cleanup_test.go @@ -0,0 +1,58 @@ +package boxstore + +import ( + "os" + "path/filepath" + "testing" + "time" + + "warpbox/lib/models" +) + +func TestCleanupExpiredBoxesDeletesOnlyExpiredManifestBoxes(t *testing.T) { + root := filepath.Join(t.TempDir(), "uploads") + previousRoot := UploadRoot() + t.Cleanup(func() { SetUploadRoot(previousRoot) }) + SetUploadRoot(root) + + expiredID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + activeID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + legacyID := "cccccccccccccccccccccccccccccccc" + + if err := os.MkdirAll(BoxPath(expiredID), 0755); err != nil { + t.Fatalf("mkdir expired: %v", err) + } + if err := os.MkdirAll(BoxPath(activeID), 0755); err != nil { + t.Fatalf("mkdir active: %v", err) + } + if err := os.MkdirAll(BoxPath(legacyID), 0755); err != nil { + t.Fatalf("mkdir legacy: %v", err) + } + + if err := WriteManifest(expiredID, models.BoxManifest{CreatedAt: time.Now().UTC().Add(-2 * time.Hour), ExpiresAt: time.Now().UTC().Add(-time.Minute)}); err != nil { + t.Fatalf("write expired manifest: %v", err) + } + if err := WriteManifest(activeID, models.BoxManifest{CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour)}); err != nil { + t.Fatalf("write active manifest: %v", err) + } + + result, err := CleanupExpiredBoxes() + if err != nil { + t.Fatalf("cleanup failed: %v", err) + } + if result.Deleted != 1 { + t.Fatalf("expected 1 deleted box, got %d", result.Deleted) + } + if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != expiredID { + t.Fatalf("expected deleted id %s, got %#v", expiredID, result.DeletedIDs) + } + if _, err := os.Stat(BoxPath(expiredID)); !os.IsNotExist(err) { + t.Fatalf("expected expired box dir removed, stat err=%v", err) + } + if _, err := os.Stat(BoxPath(activeID)); err != nil { + t.Fatalf("expected active box to remain, stat err=%v", err) + } + if _, err := os.Stat(BoxPath(legacyID)); err != nil { + t.Fatalf("expected legacy box to remain, stat err=%v", err) + } +} diff --git a/lib/config/config_test.go b/lib/config/config_test.go index 3b32d59..5667273 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -22,6 +22,9 @@ func TestDefaults(t *testing.T) { if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled { t.Fatal("expected default guest/API/download toggles to be enabled") } + if !cfg.SecurityEnabled { + t.Fatal("expected security features to be enabled by default") + } if cfg.AdminUsername != "admin" { t.Fatalf("unexpected admin username: %s", cfg.AdminUsername) } @@ -39,6 +42,7 @@ func TestEnvironmentOverrides(t *testing.T) { t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") + t.Setenv("WARPBOX_SECURITY_ENABLED", "false") cfg, err := Load() if err != nil { @@ -63,6 +67,9 @@ func TestEnvironmentOverrides(t *testing.T) { if !cfg.OneTimeDownloadRetryOnFailure { t.Fatal("expected one-time retry-on-failure env override to be applied") } + if cfg.SecurityEnabled { + t.Fatal("expected security features toggle from environment to be applied") + } if cfg.Source(SettingAPIEnabled) != SourceEnv { t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled)) } @@ -191,6 +198,8 @@ func clearConfigEnv(t *testing.T) { "WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", + "WARPBOX_SECURITY_ENABLED", + "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", } { t.Setenv(name, "") } diff --git a/lib/config/definitions.go b/lib/config/definitions.go index 9f03059..b9ea3c0 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -20,6 +20,20 @@ var Definitions = []SettingDefinition{ {Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, + {Key: SettingSecurityEnabled, EnvName: "WARPBOX_SECURITY_ENABLED", Label: "Security features enabled", Type: SettingTypeBool, Editable: true}, + {Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true}, + {Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true}, + {Key: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true}, + {Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityScanWindowSecs, EnvName: "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", Label: "Malicious path window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityScanMaxAttempts, EnvName: "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", Label: "Malicious path max attempts", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityUploadWindowSecs, EnvName: "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", Label: "Upload limit window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, + {Key: SettingSecurityUploadMaxRequests, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", Label: "Upload max requests per window", Type: SettingTypeInt, Editable: true, Minimum: 1}, + {Key: SettingSecurityUploadMaxGB, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_GB", Label: "Upload max total GB per window", Type: SettingTypeSizeGB, Editable: true, Minimum: 0}, + {Key: SettingExpiredCleanupIntervalSecs, EnvName: "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", Label: "Expired boxes cleanup interval seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, } func (cfg *Config) SettingRows() []SettingRow { diff --git a/lib/config/load.go b/lib/config/load.go index eebde15..7bfe7d0 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -26,6 +26,17 @@ func Load() (*Config, error) { BoxPollIntervalMS: 5000, ThumbnailBatchSize: 10, ThumbnailIntervalSeconds: 30, + ActivityRetentionSeconds: 7 * 24 * 60 * 60, + SecurityEnabled: true, + SecurityLoginWindowSeconds: 10 * 60, + SecurityLoginMaxAttempts: 8, + SecurityBanSeconds: 30 * 60, + SecurityScanWindowSeconds: 5 * 60, + SecurityScanMaxAttempts: 12, + SecurityUploadWindowSeconds: 60, + SecurityUploadMaxRequests: 20, + SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024, + ExpiredCleanupIntervalSeconds: 300, sources: make(map[string]Source), values: make(map[string]string), defaults: make(map[string]string), @@ -47,6 +58,15 @@ func Load() (*Config, error) { if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil { return nil, err } + if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil { + return nil, err + } + if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil { + return nil, err + } + if err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); err != nil { + return nil, err + } if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" { mode := AdminEnabledMode(strings.ToLower(raw)) if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { @@ -73,6 +93,7 @@ func Load() (*Config, error) { {SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, + {SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled}, } for _, item := range envBools { if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil { @@ -90,6 +111,12 @@ func Load() (*Config, error) { {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, + {SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds}, + {SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds}, + {SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds}, + {SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds}, + {SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds}, + {SettingExpiredCleanupIntervalSecs, "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", 0, &cfg.ExpiredCleanupIntervalSeconds}, } for _, item := range envInt64s { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { @@ -107,6 +134,7 @@ func Load() (*Config, error) { {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes}, + {SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes}, } for _, item := range sizeEnvVars { if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil { @@ -123,6 +151,9 @@ func Load() (*Config, error) { {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, + {SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts}, + {SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts}, + {SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests}, } for _, item := range envInts { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { @@ -138,6 +169,15 @@ func Load() (*Config, error) { return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty") } cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail) + if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil { + return nil, err + } + if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil { + return nil, err + } + if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil { + return nil, err + } cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads") cfg.DBDir = filepath.Join(cfg.DataDir, "db") cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir)) @@ -172,6 +212,20 @@ func (cfg *Config) captureDefaults() { cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS)) cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize)) cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds)) + cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityEnabled, formatBool(cfg.SecurityEnabled)) + cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist) + cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist) + cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs) + cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts)) + cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityScanWindowSecs, strconv.FormatInt(cfg.SecurityScanWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityScanMaxAttempts, strconv.Itoa(cfg.SecurityScanMaxAttempts)) + cfg.captureDefaultValue(SettingSecurityUploadWindowSecs, strconv.FormatInt(cfg.SecurityUploadWindowSeconds, 10)) + cfg.captureDefaultValue(SettingSecurityUploadMaxRequests, strconv.Itoa(cfg.SecurityUploadMaxRequests)) + cfg.captureDefaultValue(SettingSecurityUploadMaxGB, formatGigabytesFromBytes(cfg.SecurityUploadMaxBytes)) + cfg.captureDefaultValue(SettingExpiredCleanupIntervalSecs, strconv.FormatInt(cfg.ExpiredCleanupIntervalSeconds, 10)) } func (cfg *Config) captureDefaultValue(key string, value string) { diff --git a/lib/config/models.go b/lib/config/models.go index 118aa3a..6522564 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -36,6 +36,20 @@ const ( SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingDataDir = "data_dir" + SettingActivityRetentionSeconds = "activity_retention_seconds" + SettingSecurityEnabled = "security_enabled" + SettingSecurityIPWhitelist = "security_ip_whitelist" + SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist" + SettingTrustedProxyCIDRs = "trusted_proxy_cidrs" + SettingSecurityLoginWindowSecs = "security_login_window_seconds" + SettingSecurityLoginMaxAttempts = "security_login_max_attempts" + SettingSecurityBanSeconds = "security_ban_seconds" + SettingSecurityScanWindowSecs = "security_scan_window_seconds" + SettingSecurityScanMaxAttempts = "security_scan_max_attempts" + SettingSecurityUploadWindowSecs = "security_upload_window_seconds" + SettingSecurityUploadMaxRequests = "security_upload_max_requests" + SettingSecurityUploadMaxGB = "security_upload_max_gb" + SettingExpiredCleanupIntervalSecs = "expired_cleanup_interval_seconds" ) type SettingType string @@ -95,6 +109,20 @@ type Config struct { BoxPollIntervalMS int ThumbnailBatchSize int ThumbnailIntervalSeconds int + ActivityRetentionSeconds int64 + SecurityEnabled bool + SecurityIPWhitelist string + SecurityAdminIPWhitelist string + TrustedProxyCIDRs string + SecurityLoginWindowSeconds int64 + SecurityLoginMaxAttempts int + SecurityBanSeconds int64 + SecurityScanWindowSeconds int64 + SecurityScanMaxAttempts int + SecurityUploadWindowSeconds int64 + SecurityUploadMaxRequests int + SecurityUploadMaxBytes int64 + ExpiredCleanupIntervalSeconds int64 sources map[string]Source values map[string]string diff --git a/lib/config/overrides.go b/lib/config/overrides.go index bd2f449..b42a567 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -3,6 +3,9 @@ package config import ( "fmt" "strconv" + "strings" + + "warpbox/lib/security" ) func (cfg *Config) ApplyOverrides(overrides map[string]string) error { @@ -26,6 +29,11 @@ func (cfg *Config) ApplyOverride(key string, value string) error { return fmt.Errorf("setting %q cannot be changed from the admin UI", key) } + value = strings.TrimSpace(value) + if err := validateSecurityTextSetting(key, value); err != nil { + return err + } + switch def.Type { case SettingTypeBool: parsed, err := parseBool(value) @@ -51,11 +59,28 @@ func (cfg *Config) ApplyOverride(key string, value string) error { return fmt.Errorf("%s: %w", key, err) } cfg.assignInt(key, int(parsed64), SourceDB) + case SettingTypeText: + cfg.assignText(key, value, SourceDB) default: return fmt.Errorf("setting %q is not runtime editable", key) } return nil } + +func validateSecurityTextSetting(key string, value string) error { + switch key { + case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist: + if _, err := security.ParseIPMatchers(value, true); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + case SettingTrustedProxyCIDRs: + if _, err := security.ParseCIDRList(value); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + } + return nil +} + func (cfg *Config) assignBool(key string, value bool, source Source) { switch key { case SettingGuestUploadsEnabled: @@ -70,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) { cfg.RenewOnAccessEnabled = value case SettingRenewOnDownloadEnabled: cfg.RenewOnDownloadEnabled = value + case SettingSecurityEnabled: + cfg.SecurityEnabled = value } cfg.setValue(key, formatBool(value), source) } @@ -92,8 +119,22 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) { cfg.DefaultUserMaxBoxSizeBytes = value case SettingSessionTTLSeconds: cfg.SessionTTLSeconds = value + case SettingActivityRetentionSeconds: + cfg.ActivityRetentionSeconds = value + case SettingSecurityLoginWindowSecs: + cfg.SecurityLoginWindowSeconds = value + case SettingSecurityBanSeconds: + cfg.SecurityBanSeconds = value + case SettingSecurityScanWindowSecs: + cfg.SecurityScanWindowSeconds = value + case SettingSecurityUploadWindowSecs: + cfg.SecurityUploadWindowSeconds = value + case SettingSecurityUploadMaxGB: + cfg.SecurityUploadMaxBytes = value + case SettingExpiredCleanupIntervalSecs: + cfg.ExpiredCleanupIntervalSeconds = value } - if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes { + if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB { cfg.setValue(key, formatGigabytesFromBytes(value), source) return } @@ -108,10 +149,28 @@ func (cfg *Config) assignInt(key string, value int, source Source) { cfg.ThumbnailBatchSize = value case SettingThumbnailIntervalSeconds: cfg.ThumbnailIntervalSeconds = value + case SettingSecurityLoginMaxAttempts: + cfg.SecurityLoginMaxAttempts = value + case SettingSecurityScanMaxAttempts: + cfg.SecurityScanMaxAttempts = value + case SettingSecurityUploadMaxRequests: + cfg.SecurityUploadMaxRequests = value } cfg.setValue(key, strconv.Itoa(value), source) } +func (cfg *Config) assignText(key string, value string, source Source) { + switch key { + case SettingSecurityIPWhitelist: + cfg.SecurityIPWhitelist = value + case SettingSecurityAdminIPWhitelist: + cfg.SecurityAdminIPWhitelist = value + case SettingTrustedProxyCIDRs: + cfg.TrustedProxyCIDRs = value + } + cfg.setValue(key, value, source) +} + func (cfg *Config) setValue(key string, value string, source Source) { if key == "" { return diff --git a/lib/routing/routes.go b/lib/routing/routes.go index e1f1a0b..f60185f 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -26,6 +26,10 @@ type Handlers struct { AdminBoxes gin.HandlerFunc AdminBoxesAction gin.HandlerFunc AdminUsers gin.HandlerFunc + AdminActivity gin.HandlerFunc + AdminSecurity gin.HandlerFunc + AdminAlertsAction gin.HandlerFunc + AdminSecurityAction gin.HandlerFunc AdminSettings gin.HandlerFunc AdminSettingsExport gin.HandlerFunc AdminSettingsSave gin.HandlerFunc @@ -62,9 +66,13 @@ func Register(router *gin.Engine, handlers Handlers) { protected := router.Group("/admin", handlers.AdminAuth) protected.GET("/dashboard", handlers.AdminDashboard) protected.GET("/alerts", handlers.AdminAlerts) + protected.POST("/alerts/actions", handlers.AdminAlertsAction) protected.GET("/boxes", handlers.AdminBoxes) protected.POST("/boxes/actions", handlers.AdminBoxesAction) protected.GET("/users", handlers.AdminUsers) + protected.GET("/activity", handlers.AdminActivity) + protected.GET("/security", handlers.AdminSecurity) + protected.POST("/security/actions", handlers.AdminSecurityAction) protected.GET("/settings", handlers.AdminSettings) protected.GET("/settings/export", handlers.AdminSettingsExport) protected.POST("/settings/save", handlers.AdminSettingsSave) diff --git a/lib/security/guard.go b/lib/security/guard.go new file mode 100644 index 0000000..23345be --- /dev/null +++ b/lib/security/guard.go @@ -0,0 +1,426 @@ +package security + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/dgraph-io/badger/v4" +) + +type Config struct { + IPWhitelist string + AdminIPWhitelist string + LoginWindowSeconds int64 + LoginMaxAttempts int + BanSeconds int64 + ScanWindowSeconds int64 + ScanMaxAttempts int + UploadWindowSeconds int64 + UploadMaxRequests int + UploadMaxBytes int64 +} + +type Guard struct { + mu sync.Mutex + failedLogins map[string][]time.Time + scanAttempts map[string][]time.Time + uploadEvents map[string][]uploadEvent + bannedUntil map[string]time.Time + ipWhitelist []ipMatcher + adminWhitelist []ipMatcher + banDB *badger.DB +} + +type ipMatcher struct { + exact net.IP + prefix *net.IPNet +} + +type uploadEvent struct { + at time.Time + bytes int64 +} + +type BanEntry struct { + IP string `json:"ip"` + Until time.Time `json:"until"` +} + +const banKeyPrefix = "ban:" + +func NewGuard() *Guard { + return &Guard{ + failedLogins: map[string][]time.Time{}, + scanAttempts: map[string][]time.Time{}, + uploadEvents: map[string][]uploadEvent{}, + bannedUntil: map[string]time.Time{}, + ipWhitelist: []ipMatcher{}, + adminWhitelist: []ipMatcher{}, + } +} + +func (g *Guard) Close() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.banDB == nil { + return nil + } + err := g.banDB.Close() + g.banDB = nil + return err +} + +func (g *Guard) EnableBanPersistence(path string) error { + if strings.TrimSpace(path) == "" { + return nil + } + + g.mu.Lock() + defer g.mu.Unlock() + + if g.banDB != nil { + return nil + } + + opts := badger.DefaultOptions(path) + opts.Logger = nil + db, err := badger.Open(opts) + if err != nil { + // Corruption-safe fallback: quarantine badger files and start fresh. + _ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405")) + db, err = badger.Open(opts) + } + if err != nil { + return err + } + g.banDB = db + + if err := g.loadBansLocked(); err != nil { + _ = g.banDB.Close() + g.banDB = nil + return err + } + return nil +} + +func (g *Guard) Reload(cfg Config) error { + ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true) + if err != nil { + return fmt.Errorf("ip whitelist: %w", err) + } + adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true) + if err != nil { + return fmt.Errorf("admin ip whitelist: %w", err) + } + + g.mu.Lock() + defer g.mu.Unlock() + g.ipWhitelist = ipWhitelist + g.adminWhitelist = adminWhitelist + return nil +} + +func (g *Guard) IsWhitelisted(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return matchIP(g.ipWhitelist, ip) +} + +func (g *Guard) IsAdminWhitelisted(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return matchIP(g.adminWhitelist, ip) || matchIP(g.ipWhitelist, ip) +} + +func (g *Guard) IsBanned(ip string) bool { + g.mu.Lock() + defer g.mu.Unlock() + until, ok := g.bannedUntil[ip] + if !ok { + return false + } + if time.Now().UTC().After(until) { + delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) + return false + } + return true +} + +func (g *Guard) Ban(ip string, seconds int64) { + if seconds <= 0 || ip == "" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + until := time.Now().UTC().Add(time.Duration(seconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) +} + +func (g *Guard) BanUntil(ip string, until time.Time) { + if ip == "" || until.IsZero() { + return + } + g.mu.Lock() + defer g.mu.Unlock() + until = until.UTC() + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) +} + +func (g *Guard) Unban(ip string) { + if ip == "" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) +} + +func (g *Guard) BanList() []BanEntry { + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + out := make([]BanEntry, 0, len(g.bannedUntil)) + for ip, until := range g.bannedUntil { + if now.After(until) { + delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) + continue + } + out = append(out, BanEntry{IP: ip, Until: until}) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Until.Before(out[j].Until) + }) + return out +} + +func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) { + if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 { + return false, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + attempts := pruneTimes(g.failedLogins[ip], cutoff) + attempts = append(attempts, now) + g.failedLogins[ip] = attempts + if len(attempts) >= maxAttempts { + until := now.Add(time.Duration(banSeconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) + return true, len(attempts) + } + return false, len(attempts) +} + +func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) { + if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 { + return false, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + attempts := pruneTimes(g.scanAttempts[ip], cutoff) + attempts = append(attempts, now) + g.scanAttempts[ip] = attempts + if len(attempts) >= maxAttempts { + until := now.Add(time.Duration(banSeconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) + return true, len(attempts) + } + return false, len(attempts) +} + +func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxRequests int, maxBytes int64) (bool, int, int64) { + if ip == "" || windowSeconds <= 0 || maxRequests <= 0 { + return true, 0, 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now().UTC() + cutoff := now.Add(-time.Duration(windowSeconds) * time.Second) + events := g.uploadEvents[ip] + kept := make([]uploadEvent, 0, len(events)+1) + totalBytes := int64(0) + for _, event := range events { + if event.at.After(cutoff) { + kept = append(kept, event) + totalBytes += event.bytes + } + } + nextCount := len(kept) + 1 + nextBytes := totalBytes + size + if nextCount > maxRequests { + return false, nextCount, nextBytes + } + if maxBytes > 0 && nextBytes > maxBytes { + return false, nextCount, nextBytes + } + kept = append(kept, uploadEvent{at: now, bytes: size}) + g.uploadEvents[ip] = kept + return true, nextCount, nextBytes +} + +func ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) { + entries := []ipMatcher{} + for _, chunk := range strings.Split(raw, ",") { + value := strings.TrimSpace(chunk) + if value == "" { + continue + } + if strings.Contains(value, "/") { + if !allowCIDR { + return nil, fmt.Errorf("%q must be a CIDR", value) + } + _, network, err := net.ParseCIDR(value) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q", value) + } + entries = append(entries, ipMatcher{prefix: network}) + continue + } + parsed := net.ParseIP(value) + if parsed == nil { + return nil, fmt.Errorf("invalid IP %q", value) + } + entries = append(entries, ipMatcher{exact: parsed}) + } + return entries, nil +} + +func ParseCIDRList(raw string) ([]net.IPNet, error) { + entries := []net.IPNet{} + for _, chunk := range strings.Split(raw, ",") { + value := strings.TrimSpace(chunk) + if value == "" { + continue + } + _, network, err := net.ParseCIDR(value) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q", value) + } + entries = append(entries, *network) + } + return entries, nil +} + +func pruneTimes(values []time.Time, cutoff time.Time) []time.Time { + kept := make([]time.Time, 0, len(values)) + for _, value := range values { + if value.After(cutoff) { + kept = append(kept, value) + } + } + return kept +} + +func matchIP(rules []ipMatcher, value string) bool { + ip := net.ParseIP(strings.TrimSpace(value)) + if ip == nil { + return false + } + for _, rule := range rules { + if rule.exact != nil && rule.exact.Equal(ip) { + return true + } + if rule.prefix != nil && rule.prefix.Contains(ip) { + return true + } + } + return false +} + +func (g *Guard) saveBanLocked(ip string, until time.Time) { + if g.banDB == nil || ip == "" || until.IsZero() { + return + } + seconds := int64(time.Until(until).Seconds()) + if seconds <= 0 { + _ = g.banDB.Update(func(txn *badger.Txn) error { + return txn.Delete([]byte(banKeyPrefix + ip)) + }) + return + } + + value := make([]byte, 8) + binary.BigEndian.PutUint64(value, uint64(until.Unix())) + _ = g.banDB.Update(func(txn *badger.Txn) error { + entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second) + return txn.SetEntry(entry) + }) +} + +func (g *Guard) deleteBanLocked(ip string) { + if g.banDB == nil || ip == "" { + return + } + _ = g.banDB.Update(func(txn *badger.Txn) error { + return txn.Delete([]byte(banKeyPrefix + ip)) + }) +} + +func (g *Guard) loadBansLocked() error { + if g.banDB == nil { + return nil + } + now := time.Now().UTC() + loaded := map[string]time.Time{} + expired := [][]byte{} + + err := g.banDB.View(func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() { + item := it.Item() + key := string(item.Key()) + ip := strings.TrimPrefix(key, banKeyPrefix) + err := item.Value(func(val []byte) error { + if len(val) != 8 { + expired = append(expired, append([]byte(nil), item.Key()...)) + return nil + } + unix := int64(binary.BigEndian.Uint64(val)) + until := time.Unix(unix, 0).UTC() + if now.After(until) { + expired = append(expired, append([]byte(nil), item.Key()...)) + return nil + } + loaded[ip] = until + return nil + }) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + g.bannedUntil = loaded + if len(expired) == 0 { + return nil + } + return g.banDB.Update(func(txn *badger.Txn) error { + for _, key := range expired { + if err := txn.Delete(key); err != nil { + return err + } + } + return nil + }) +} diff --git a/lib/security/guard_test.go b/lib/security/guard_test.go new file mode 100644 index 0000000..abc07a9 --- /dev/null +++ b/lib/security/guard_test.go @@ -0,0 +1,52 @@ +package security + +import ( + "path/filepath" + "testing" + "time" +) + +func TestGuardWhitelistSupportsIPAndCIDR(t *testing.T) { + g := NewGuard() + if err := g.Reload(Config{IPWhitelist: "203.0.113.10,10.0.0.0/8", AdminIPWhitelist: "192.168.1.0/24"}); err != nil { + t.Fatalf("Reload returned error: %v", err) + } + if !g.IsWhitelisted("203.0.113.10") || !g.IsWhitelisted("10.2.3.4") { + t.Fatal("expected IP and CIDR entries to match") + } + if !g.IsAdminWhitelisted("192.168.1.5") { + t.Fatal("expected admin CIDR whitelist match") + } +} + +func TestGuardBanPersistenceAcrossRestart(t *testing.T) { + dir := filepath.Join(t.TempDir(), "bans.badger") + g1 := NewGuard() + if err := g1.EnableBanPersistence(dir); err != nil { + t.Fatalf("EnableBanPersistence returned error: %v", err) + } + g1.Ban("198.51.100.4", 3600) + if err := g1.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + g2 := NewGuard() + if err := g2.EnableBanPersistence(dir); err != nil { + t.Fatalf("EnableBanPersistence returned error: %v", err) + } + defer g2.Close() + if !g2.IsBanned("198.51.100.4") { + t.Fatal("expected ban to persist across guard restart") + } +} + +func TestGuardBanListPrunesExpired(t *testing.T) { + g := NewGuard() + g.BanUntil("198.51.100.7", time.Now().UTC().Add(-time.Minute)) + if g.IsBanned("198.51.100.7") { + t.Fatal("expected expired ban to be treated as inactive") + } + if len(g.BanList()) != 0 { + t.Fatal("expected BanList to prune expired entries") + } +} diff --git a/lib/server/admin.go b/lib/server/admin.go index 8ed3ecf..1f95896 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -2,11 +2,14 @@ package server import ( "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" + "warpbox/lib/alerts" "warpbox/lib/config" + "warpbox/lib/security" ) const adminSessionCookie = "warpbox_admin_session" @@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, "/") return } + ip := app.clientIP(ctx) + guard := app.securityGuard + if app.securityFeaturesEnabled() && guard == nil { + guard = security.NewGuard() + app.securityGuard = guard + } + if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) { + app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil) + ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{ + "ErrorMessage": "Too many failed attempts. Try again later.", + }) + return + } username := strings.TrimSpace(ctx.PostForm("username")) password := ctx.PostForm("password") if username != app.config.AdminUsername || password != app.config.AdminPassword { + if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) { + banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds) + app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)}) + if banned { + app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)}) + app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)}) + } + } ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{ "ErrorMessage": "Invalid username or password.", }) return } + app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil) secure := app.config.AdminCookieSecure maxAge := int(app.config.SessionTTLSeconds) @@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) { return } + alertsList := []alerts.Alert{} + if app.alertStore != nil { + var err error + alertsList, err = app.alertStore.List(500) + if err != nil { + ctx.String(http.StatusInternalServerError, "Could not load alerts") + return + } + } + openCount := 0 + highCount := 0 + ackedCount := 0 + closedCount := 0 + for _, alert := range alertsList { + switch string(alert.Status) { + case "open": + openCount++ + case "acked": + ackedCount++ + case "closed": + closedCount++ + } + if alert.Severity == "high" && string(alert.Status) != "closed" { + highCount++ + } + } + ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, "ActivePage": "alerts", + "Alerts": alertsList, + "OpenCount": strconv.Itoa(openCount), + "HighCount": strconv.Itoa(highCount), + "AckCount": strconv.Itoa(ackedCount), + "ClosedCount": strconv.Itoa(closedCount), }) } diff --git a/lib/server/admin_boxes.go b/lib/server/admin_boxes.go index 8e79227..a490c9b 100644 --- a/lib/server/admin_boxes.go +++ b/lib/server/admin_boxes.go @@ -84,22 +84,41 @@ func (app *App) handleAdminBoxesAction(ctx *gin.Context) { return } - if len(request.BoxIDs) == 0 { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"}) - return - } - switch request.Action { - case "delete", "expire", "bump": + case "delete", "expire", "bump", "cleanup_expired": default: ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) return } + if request.Action != "cleanup_expired" && len(request.BoxIDs) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"}) + return + } + if request.Action == "bump" && request.DeltaSeconds <= 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"}) return } + if request.Action == "cleanup_expired" { + result, err := app.runExpiredCleanup("admin") + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Expired cleanup job failed"}) + return + } + boxes, listErr := app.listAdminBoxes() + if listErr != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Cleanup finished, but boxes could not be reloaded"}) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "ok": len(result.Warnings) == 0, + "message": fmt.Sprintf("Expired cleanup done: deleted %d box(es), skipped %d", result.Deleted, result.Skipped), + "warnings": result.Warnings, + "boxes": boxes, + }) + return + } processed := 0 warnings := make([]string, 0) @@ -299,6 +318,8 @@ func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) s return fmt.Sprintf("Expired %d box(es)", processed) case "bump": return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds)) + case "cleanup_expired": + return fmt.Sprintf("Expired cleanup processed %d box(es)", processed) default: return "Action complete" } diff --git a/lib/server/admin_security.go b/lib/server/admin_security.go new file mode 100644 index 0000000..5fbb3a9 --- /dev/null +++ b/lib/server/admin_security.go @@ -0,0 +1,331 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "warpbox/lib/activity" + "warpbox/lib/alerts" + "warpbox/lib/security" +) + +type adminAlertsActionRequest struct { + Action string `json:"action"` + IDs []string `json:"ids"` +} + +type adminSecurityActionRequest struct { + Action string `json:"action"` + IP string `json:"ip"` + IPs []string `json:"ips"` + BanUntil string `json:"ban_until"` +} + +func (app *App) reloadSecurityConfig() error { + if app == nil || app.config == nil { + return fmt.Errorf("app or config is nil") + } + if !app.securityFeaturesEnabled() { + if app.securityGuard != nil { + _ = app.securityGuard.Close() + } + app.securityGuard = nil + return nil + } + if app.securityGuard == nil { + app.securityGuard = security.NewGuard() + } + if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil { + return fmt.Errorf("enable ban persistence: %w", err) + } + if err := app.securityGuard.Reload(security.Config{ + IPWhitelist: app.config.SecurityIPWhitelist, + AdminIPWhitelist: app.config.SecurityAdminIPWhitelist, + LoginWindowSeconds: app.config.SecurityLoginWindowSeconds, + LoginMaxAttempts: app.config.SecurityLoginMaxAttempts, + BanSeconds: app.config.SecurityBanSeconds, + ScanWindowSeconds: app.config.SecurityScanWindowSeconds, + ScanMaxAttempts: app.config.SecurityScanMaxAttempts, + UploadWindowSeconds: app.config.SecurityUploadWindowSeconds, + UploadMaxRequests: app.config.SecurityUploadMaxRequests, + UploadMaxBytes: app.config.SecurityUploadMaxBytes, + }); err != nil { + return fmt.Errorf("reload guard config: %w", err) + } + return nil +} + +func (app *App) securityFeaturesEnabled() bool { + return app != nil && app.config != nil && app.config.SecurityEnabled +} + +func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) { + if app.activityStore == nil { + return + } + event := activity.Event{ + Kind: kind, + Severity: severity, + Message: message, + CreatedAt: time.Now().UTC(), + Meta: meta, + } + if ctx != nil { + event.IP = app.clientIP(ctx) + event.Path = ctx.Request.URL.Path + event.Method = ctx.Request.Method + } + _ = app.activityStore.Append(event, app.config.ActivityRetentionSeconds) +} + +func (app *App) createAlert(title string, severity string, group string, code string, trace string, message string, meta map[string]string) { + if app.alertStore == nil { + return + } + _ = app.alertStore.Add(alerts.Alert{ + Title: title, + Severity: severity, + Group: group, + Code: code, + Trace: trace, + Message: message, + Status: alerts.StatusOpen, + Meta: meta, + }) +} + +func (app *App) securityMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + if !app.securityFeaturesEnabled() { + ctx.Next() + return + } + if app.securityGuard == nil { + ctx.Next() + return + } + ip := app.clientIP(ctx) + if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { + ctx.Next() + return + } + if app.securityGuard.IsBanned(ip) { + app.logActivity("security.block", "high", "Blocked banned IP", ctx, nil) + ctx.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many abusive requests. Try again later."}) + return + } + ctx.Next() + } +} + +func (app *App) handleNoRoute(ctx *gin.Context) { + if !app.securityFeaturesEnabled() { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + if app.securityGuard == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + path := strings.ToLower(ctx.Request.URL.Path) + suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env") + if suspicious { + ip := app.clientIP(ctx) + if !app.securityGuard.IsWhitelisted(ip) { + banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds) + app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)}) + if banned { + app.createAlert("IP auto-banned after malicious path scans", "high", "security", "410", "security.scan.autoban", "Repeated malicious path scans triggered temporary ban.", map[string]string{"ip": ip, "attempts": intToString(attempts)}) + app.logActivity("security.ban", "high", "IP auto-banned after scans", ctx, map[string]string{"attempts": intToString(attempts)}) + } + } + } + ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) +} + +func (app *App) handleAdminActivity(ctx *gin.Context) { + if app.activityStore == nil { + ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{ + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "ActivePage": "activity", + "Events": []activity.Event{}, + }) + return + } + events, err := app.activityStore.List(400, app.config.ActivityRetentionSeconds) + if err != nil { + ctx.String(http.StatusInternalServerError, "Could not load activity") + return + } + ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{ + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "ActivePage": "activity", + "Events": events, + }) +} + +func (app *App) handleAdminSecurity(ctx *gin.Context) { + if !app.securityFeaturesEnabled() { + ctx.String(http.StatusNotFound, "Security features are disabled") + return + } + events := []activity.Event{} + alertsList := []alerts.Alert{} + if app.activityStore != nil { + events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds) + } + if app.alertStore != nil { + alertsList, _ = app.alertStore.List(120) + } + bans := []security.BanEntry{} + if app.securityGuard != nil { + bans = app.securityGuard.BanList() + } + ctx.HTML(http.StatusOK, "admin/security.html", gin.H{ + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "ActivePage": "security", + "Events": events, + "Alerts": alertsList, + "Bans": bans, + }) +} + +func (app *App) handleAdminAlertsAction(ctx *gin.Context) { + if app.alertStore == nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Alert store unavailable"}) + return + } + var request adminAlertsActionRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"}) + return + } + switch request.Action { + case "ack": + if err := app.alertStore.SetStatus(request.IDs, alerts.StatusAcked); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"}) + return + } + case "close": + if err := app.alertStore.SetStatus(request.IDs, alerts.StatusClosed); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"}) + return + } + case "delete": + if err := app.alertStore.Delete(request.IDs); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete alerts"}) + return + } + default: + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) + return + } + app.logActivity("alerts.action", "low", "Admin changed alert state", ctx, map[string]string{"action": request.Action, "count": intToString(len(request.IDs))}) + alertsList, _ := app.alertStore.List(500) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList}) +} + +func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) { + metaCopy := map[string]string{"ip": ip} + for k, v := range meta { + metaCopy[k] = v + } + app.logActivity(kind, severity, message, ctx, metaCopy) + app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy) +} + +func (app *App) handleAdminSecurityAction(ctx *gin.Context) { + if !app.securityFeaturesEnabled() { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Security features are disabled"}) + return + } + if app.securityGuard == nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"}) + return + } + var request adminSecurityActionRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"}) + return + } + ip := strings.TrimSpace(request.IP) + if ip != "" && net.ParseIP(ip) == nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"}) + return + } + + switch request.Action { + case "ban": + if ip == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"}) + return + } + app.securityGuard.Ban(ip, app.config.SecurityBanSeconds) + app.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.") + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()}) + case "ban_until": + if ip == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"}) + return + } + until, err := time.Parse(time.RFC3339, strings.TrimSpace(request.BanUntil)) + if err != nil || until.Before(time.Now().UTC()) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ban expiration"}) + return + } + app.securityGuard.BanUntil(ip, until) + meta := map[string]string{"until": until.UTC().Format(time.RFC3339)} + app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.") + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()}) + case "unban": + if ip == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"}) + return + } + app.securityGuard.Unban(ip) + app.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.") + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()}) + case "bulk_unban": + if len(request.IPs) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"}) + return + } + count := 0 + for _, candidate := range request.IPs { + candidate = strings.TrimSpace(candidate) + if net.ParseIP(candidate) == nil { + continue + } + app.securityGuard.Unban(candidate) + count++ + } + app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)}) + app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)}) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()}) + case "unban_all": + current := app.securityGuard.BanList() + for _, ban := range current { + app.securityGuard.Unban(ban.IP) + } + count := len(current) + app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)}) + app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)}) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()}) + default: + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) + } +} + +func intToString(value int) string { + return strconv.Itoa(value) +} diff --git a/lib/server/admin_security_test.go b/lib/server/admin_security_test.go new file mode 100644 index 0000000..3634cfb --- /dev/null +++ b/lib/server/admin_security_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "warpbox/lib/activity" + "warpbox/lib/alerts" + "warpbox/lib/config" + "warpbox/lib/security" +) + +func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) { + app, router := setupAdminSecurityTest(t) + + for _, body := range []string{ + `{"action":"ban","ip":"203.0.113.7"}`, + `{"action":"unban","ip":"203.0.113.7"}`, + } { + request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String()) + } + } + + events, err := app.activityStore.List(100, app.config.ActivityRetentionSeconds) + if err != nil { + t.Fatalf("activity list error: %v", err) + } + if len(events) < 2 { + t.Fatalf("expected activity events, got %d", len(events)) + } + alertsList, err := app.alertStore.List(100) + if err != nil { + t.Fatalf("alerts list error: %v", err) + } + if len(alertsList) < 2 { + t.Fatalf("expected alerts for manual actions, got %d", len(alertsList)) + } +} + +func TestAdminSecurityBulkUnbanAndUnbanAll(t *testing.T) { + app, router := setupAdminSecurityTest(t) + app.securityGuard.Ban("203.0.113.8", 300) + app.securityGuard.Ban("203.0.113.9", 300) + + request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"bulk_unban","ips":["203.0.113.8"]}`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("bulk_unban expected 200, got %d", response.Code) + } + if app.securityGuard.IsBanned("203.0.113.8") { + t.Fatal("expected selected IP to be unbanned") + } + if !app.securityGuard.IsBanned("203.0.113.9") { + t.Fatal("expected non-selected IP to remain banned") + } + + requestAll := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"unban_all"}`)) + requestAll.Header.Set("Content-Type", "application/json") + requestAll.AddCookie(authCookie(app)) + responseAll := httptest.NewRecorder() + router.ServeHTTP(responseAll, requestAll) + if responseAll.Code != http.StatusOK { + t.Fatalf("unban_all expected 200, got %d", responseAll.Code) + } + if len(app.securityGuard.BanList()) != 0 { + t.Fatal("expected all bans to be removed") + } +} + +func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + cwd, _ := os.Getwd() + root := filepath.Clean(filepath.Join(cwd, "..", "..")) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + clearAdminSettingsEnv(t) + t.Setenv("WARPBOX_DATA_DIR", t.TempDir()) + t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret") + t.Setenv("WARPBOX_ADMIN_ENABLED", "true") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config load: %v", err) + } + if err := cfg.EnsureDirectories(); err != nil { + t.Fatalf("ensure dirs: %v", err) + } + + app := &App{ + config: cfg, + activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity.json")), + alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")), + securityGuard: security.NewGuard(), + } + if err := app.reloadSecurityConfig(); err != nil { + t.Fatalf("reload security config: %v", err) + } + t.Cleanup(func() { _ = app.securityGuard.Close() }) + + router := gin.New() + admin := router.Group("/admin") + admin.GET("/login", app.handleAdminLogin) + protected := router.Group("/admin", app.adminAuthMiddleware) + protected.POST("/security/actions", app.handleAdminSecurityAction) + return app, router +} diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go index 77e81fc..3a10473 100644 --- a/lib/server/admin_settings.go +++ b/lib/server/admin_settings.go @@ -269,6 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti app.config = nextCfg applyBoxstoreRuntimeConfig(app.config) + if err := app.reloadSecurityConfig(); err != nil { + return nil, nil, err + } rows, _ := app.buildAdminSettingsRows() return rows, warnings, nil } @@ -399,6 +402,8 @@ func settingsCategoryMeta() []settingsCategoryInfo { {Key: "uploads", Label: "Uploads", Icon: "↥"}, {Key: "downloads", Label: "Downloads", Icon: "↧"}, {Key: "retention", Label: "Retention", Icon: "⌛"}, + {Key: "security", Label: "Security", Icon: "🔒"}, + {Key: "activity", Label: "Activity", Icon: "☰"}, {Key: "accounts", Label: "Accounts", Icon: "☺"}, {Key: "api", Label: "API", Icon: "{ }"}, {Key: "storage", Label: "Storage", Icon: "▥"}, @@ -428,10 +433,16 @@ func settingsCategoryForKey(key string) string { switch key { case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes: return "uploads" + case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB: + return "uploads" case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled: return "downloads" case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail: return "retention" + case config.SettingSecurityEnabled, config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts: + return "security" + case config.SettingActivityRetentionSeconds: + return "activity" case config.SettingSessionTTLSeconds: return "accounts" case config.SettingAPIEnabled: @@ -440,6 +451,8 @@ func settingsCategoryForKey(key string) string { return "storage" case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds: return "workers" + case config.SettingExpiredCleanupIntervalSecs: + return "workers" default: return "accounts" } @@ -466,6 +479,19 @@ func settingsDescription(key string) string { config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.", config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.", config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.", + config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.", + config.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.", + config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.", + config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.", + config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.", + config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.", + config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.", + config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.", + config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.", + config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.", + config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.", + config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.", + config.SettingExpiredCleanupIntervalSecs: "Background interval for deleting expired boxes. Set 0 to disable periodic cleanup.", } return descriptions[key] } diff --git a/lib/server/admin_settings_test.go b/lib/server/admin_settings_test.go index 7000880..89e92eb 100644 --- a/lib/server/admin_settings_test.go +++ b/lib/server/admin_settings_test.go @@ -265,7 +265,36 @@ func clearAdminSettingsEnv(t *testing.T) { "WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", + "WARPBOX_SECURITY_ENABLED", + "WARPBOX_SECURITY_IP_WHITELIST", + "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", + "WARPBOX_TRUSTED_PROXY_CIDRS", + "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", + "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", + "WARPBOX_SECURITY_BAN_SECONDS", + "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", + "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", + "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", + "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", + "WARPBOX_SECURITY_UPLOAD_MAX_GB", + "WARPBOX_SECURITY_UPLOAD_MAX_MB", + "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", + "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", } { t.Setenv(name, "") } } + +func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) { + app, router := setupAdminSettingsTest(t) + + request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", response.Code) + } +} diff --git a/lib/server/cleanup.go b/lib/server/cleanup.go new file mode 100644 index 0000000..57c1600 --- /dev/null +++ b/lib/server/cleanup.go @@ -0,0 +1,62 @@ +package server + +import ( + "log" + "strings" + "time" + + "warpbox/lib/boxstore" +) + +func (app *App) runExpiredCleanup(trigger string) (boxstore.CleanupExpiredResult, error) { + result, err := boxstore.CleanupExpiredBoxes() + if err != nil { + log.Printf("warpbox cleanup[%s] failed: %v", trigger, err) + app.logActivity("boxes.cleanup.failed", "high", "Expired boxes cleanup failed", nil, map[string]string{ + "trigger": trigger, + "error": err.Error(), + }) + return result, err + } + + meta := map[string]string{ + "trigger": trigger, + "scanned": intToString(result.Scanned), + "deleted": intToString(result.Deleted), + "skipped": intToString(result.Skipped), + } + if len(result.DeletedIDs) > 0 { + limit := len(result.DeletedIDs) + if limit > 20 { + limit = 20 + } + meta["deleted_ids"] = strings.Join(result.DeletedIDs[:limit], ",") + } + if len(result.Warnings) > 0 { + limit := len(result.Warnings) + if limit > 3 { + limit = 3 + } + meta["warnings"] = strings.Join(result.Warnings[:limit], " | ") + } + app.logActivity("boxes.cleanup", "medium", "Expired boxes cleanup run completed", nil, meta) + log.Printf("warpbox cleanup[%s] scanned=%d deleted=%d skipped=%d", trigger, result.Scanned, result.Deleted, result.Skipped) + return result, nil +} + +func (app *App) startExpiredCleanupWorker() { + if app == nil || app.config == nil { + return + } + go func() { + for { + interval := app.config.ExpiredCleanupIntervalSeconds + if interval <= 0 { + time.Sleep(30 * time.Second) + continue + } + time.Sleep(time.Duration(interval) * time.Second) + _, _ = app.runExpiredCleanup("worker") + } + }() +} diff --git a/lib/server/ip.go b/lib/server/ip.go new file mode 100644 index 0000000..31c8bae --- /dev/null +++ b/lib/server/ip.go @@ -0,0 +1,107 @@ +package server + +import ( + "net" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "warpbox/lib/security" +) + +func (app *App) clientIP(ctx *gin.Context) string { + if ctx == nil || ctx.Request == nil { + return "" + } + remoteIP := remoteAddrIP(ctx.Request) + trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs) + if err != nil { + return remoteIP + } + if !remoteIsTrusted(remoteIP, trusted) { + return remoteIP + } + for _, candidate := range headerIPs(ctx.Request.Header) { + if isPublicIP(candidate) { + return candidate + } + } + candidates := headerIPs(ctx.Request.Header) + if len(candidates) > 0 { + return candidates[0] + } + return remoteIP +} + +func remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool { + ip := net.ParseIP(strings.TrimSpace(remoteIP)) + if ip == nil { + return false + } + for _, prefix := range trusted { + if prefix.Contains(ip) { + return true + } + } + return false +} + +func headerIPs(header http.Header) []string { + keys := []string{ + "X-Forwarded-For", + "X-Real-Ip", + "CF-Connecting-IP", + "X-Envoy-External-Address", + "Fly-Client-IP", + } + out := make([]string, 0, 4) + seen := map[string]bool{} + for _, key := range keys { + raw := strings.TrimSpace(header.Get(key)) + if raw == "" { + continue + } + for _, part := range strings.Split(raw, ",") { + ip := normalizeIP(strings.TrimSpace(part)) + if ip == "" || seen[ip] { + continue + } + seen[ip] = true + out = append(out, ip) + } + } + return out +} + +func remoteAddrIP(request *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr)) + if err != nil { + return normalizeIP(strings.TrimSpace(request.RemoteAddr)) + } + return normalizeIP(host) +} + +func normalizeIP(raw string) string { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return "" + } + return ip.String() +} + +func isPublicIP(value string) bool { + ip := net.ParseIP(value) + if ip == nil || !ip.IsGlobalUnicast() { + return false + } + return !isPrivateOrLoopback(value) +} + +func isPrivateOrLoopback(value string) bool { + ip := net.ParseIP(value) + if ip == nil { + return true + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} diff --git a/lib/server/ip_test.go b/lib/server/ip_test.go new file mode 100644 index 0000000..5678104 --- /dev/null +++ b/lib/server/ip_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "warpbox/lib/config" +) + +func TestClientIPDirectClient(t *testing.T) { + app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}} + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil) + ctx.Request.RemoteAddr = "198.51.100.10:1234" + ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.4") + if got := app.clientIP(ctx); got != "198.51.100.10" { + t.Fatalf("expected direct remote IP, got %q", got) + } +} + +func TestClientIPTrustedProxyChain(t *testing.T) { + app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}} + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil) + ctx.Request.RemoteAddr = "10.1.2.3:8080" + ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.44, 10.0.0.5") + if got := app.clientIP(ctx); got != "203.0.113.44" { + t.Fatalf("expected forwarded public client IP, got %q", got) + } +} + +func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) { + app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}} + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil) + ctx.Request.RemoteAddr = "203.0.113.200:8080" + ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55") + if got := app.clientIP(ctx); got != "203.0.113.200" { + t.Fatalf("expected untrusted remote IP, got %q", got) + } +} diff --git a/lib/server/server.go b/lib/server/server.go index d9cfcbf..8e68c40 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -9,14 +9,20 @@ import ( "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" + "warpbox/lib/activity" + "warpbox/lib/alerts" "warpbox/lib/boxstore" "warpbox/lib/config" "warpbox/lib/routing" + "warpbox/lib/security" ) type App struct { config *config.Config settingsOverridesPath string + activityStore *activity.Store + alertStore *alerts.Store + securityGuard *security.Guard } func Run(addr string) error { @@ -38,9 +44,20 @@ func Run(addr string) error { applyBoxstoreRuntimeConfig(cfg) - app := &App{config: cfg, settingsOverridesPath: overridesPath} + app := &App{ + config: cfg, + settingsOverridesPath: overridesPath, + activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")), + alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")), + securityGuard: security.NewGuard(), + } + if err := app.reloadSecurityConfig(); err != nil { + return err + } router := gin.Default() + router.Use(app.securityMiddleware()) + router.NoRoute(app.handleNoRoute) htmlTemplates, err := loadHTMLTemplates() if err != nil { return err @@ -71,6 +88,10 @@ func Run(addr string) error { AdminBoxes: app.handleAdminBoxes, AdminBoxesAction: app.handleAdminBoxesAction, AdminUsers: app.handleAdminUsers, + AdminActivity: app.handleAdminActivity, + AdminSecurity: app.handleAdminSecurity, + AdminAlertsAction: app.handleAdminAlertsAction, + AdminSecurityAction: app.handleAdminSecurityAction, AdminSettings: app.handleAdminSettings, AdminSettingsExport: app.handleAdminSettingsExport, AdminSettingsSave: app.handleAdminSettingsSave, @@ -83,6 +104,7 @@ func Run(addr string) error { compressed.Static("/static", "./static") boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second) + app.startExpiredCleanupWorker() return router.Run(addr) } diff --git a/lib/server/uploads.go b/lib/server/uploads.go index 33f1b2e..d50777e 100644 --- a/lib/server/uploads.go +++ b/lib/server/uploads.go @@ -39,6 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + totalSize := int64(0) + for _, file := range request.Files { + totalSize += file.Size + } + if !app.enforceUploadRateLimit(ctx, totalSize) { + return + } files, err := boxstore.CreateManifest(boxID, request) if err != nil { @@ -73,6 +80,10 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, file.Size) { + boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) + return + } savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file) if err != nil { @@ -141,6 +152,9 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, file.Size) { + return + } savedFile, err := boxstore.SaveUpload(boxID, file) if err != nil { @@ -180,6 +194,9 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !app.enforceUploadRateLimit(ctx, totalSize) { + return + } boxID, err := boxstore.NewBoxID() if err != nil { diff --git a/lib/server/validation.go b/lib/server/validation.go index 1aa41bd..b8cfdb1 100644 --- a/lib/server/validation.go +++ b/lib/server/validation.go @@ -3,6 +3,7 @@ package server import ( "fmt" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -153,3 +154,39 @@ func (app *App) maxRequestBodyBytes() int64 { } return limit + 10*1024*1024 } + +func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool { + if !app.securityFeaturesEnabled() || app.securityGuard == nil { + return true + } + ip := app.clientIP(ctx) + if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { + return true + } + allowed, requestCount, totalBytes := app.securityGuard.AllowUpload( + ip, + size, + app.config.SecurityUploadWindowSeconds, + app.config.SecurityUploadMaxRequests, + app.config.SecurityUploadMaxBytes, + ) + if allowed { + return true + } + + app.logActivity("security.upload_limit", "high", "Upload rate limit exceeded", ctx, map[string]string{ + "requests": strconv.Itoa(requestCount), + "bytes": strconv.FormatInt(totalBytes, 10), + }) + app.createAlert( + "Upload rate limit triggered", + "medium", + "security", + "430", + "security.upload.rate_limit", + "Per-IP upload rate limit blocked request.", + map[string]string{"ip": ip, "requests": strconv.Itoa(requestCount)}, + ) + ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many uploads from this IP. Try again later."}) + return false +} diff --git a/run.sh b/run.sh index dbc2fe8..c0376db 100755 --- a/run.sh +++ b/run.sh @@ -25,6 +25,19 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172 export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}" export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}" export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}" +export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}" +export WARPBOX_SECURITY_ENABLED="${WARPBOX_SECURITY_ENABLED:-true}" +export WARPBOX_SECURITY_IP_WHITELIST="${WARPBOX_SECURITY_IP_WHITELIST:-}" +export WARPBOX_SECURITY_ADMIN_IP_WHITELIST="${WARPBOX_SECURITY_ADMIN_IP_WHITELIST:-}" +export WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS="${WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS:-600}" +export WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS="${WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS:-8}" +export WARPBOX_SECURITY_BAN_SECONDS="${WARPBOX_SECURITY_BAN_SECONDS:-1800}" +export WARPBOX_SECURITY_SCAN_WINDOW_SECONDS="${WARPBOX_SECURITY_SCAN_WINDOW_SECONDS:-300}" +export WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS="${WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS:-12}" +export WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS="${WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS:-60}" +export WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS="${WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS:-20}" +export WARPBOX_SECURITY_UPLOAD_MAX_GB="${WARPBOX_SECURITY_UPLOAD_MAX_GB:-10}" +export WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS="${WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS:-300}" # Data location. export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}" diff --git a/static/css/activity.css b/static/css/activity.css new file mode 100644 index 0000000..76f127a --- /dev/null +++ b/static/css/activity.css @@ -0,0 +1,63 @@ +.activity-page-body { display: grid; gap: 10px; } +.activity-panel { + min-height: 0; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 10px; +} +.activity-toolbar-grid { + display: grid; + grid-template-columns: minmax(220px, 1.2fr) minmax(130px, .4fr) minmax(150px, .5fr); + gap: 8px; + margin-bottom: 8px; +} +.activity-input, +.activity-select { + width: 100%; + min-width: 0; + height: 28px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} +.activity-table-wrap { + min-height: 420px; + height: 520px; + overflow: auto; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} +.activity-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + line-height: 14px; +} +.activity-table th, +.activity-table td { + padding: 6px; + border-bottom: 1px solid #e1e1e1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.activity-table th { + position: sticky; + top: 0; + background: #dfdfdf; + z-index: 2; +} diff --git a/static/css/security.css b/static/css/security.css new file mode 100644 index 0000000..b0981f1 --- /dev/null +++ b/static/css/security.css @@ -0,0 +1,100 @@ +.security-page-body { display: grid; gap: 10px; } +.security-grid { display: grid; grid-template-columns: minmax(260px, .65fr) minmax(0, 1.35fr); gap: 10px; } +.security-panel { + min-height: 0; + display: flex; + flex-direction: column; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; +} +.security-panel-header { + min-height: 34px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; +} +.security-panel-header span { color: #444444; font-size: 12px; } +.security-panel-body { flex: 1 1 auto; min-height: 0; padding: 10px; overflow: auto; } +.security-field { display: grid; gap: 4px; font-size: 12px; } +.security-input { + width: 100%; + min-width: 0; + height: 28px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} +.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; } +.security-danger { color: #7a0000; } +.security-note { + margin-top: 8px; + padding: 8px; + color: #000000; + background: #ffffcc; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a08000; + border-bottom: 1px solid #a08000; + font-size: 12px; + line-height: 15px; +} +.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; } +.security-ban-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr); gap: 10px; } +.security-table-toolbar { display: grid; grid-template-columns: 1fr 180px; gap: 8px; margin-bottom: 8px; } +.security-bans-wrap { height: 260px; min-height: 260px; } +.security-ip-detail { + min-height: 0; + padding: 10px; + background: #f5f5f5; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; +} +.security-ip-detail h3 { margin: 0 0 8px; font-size: 16px; line-height: 16px; } +.security-ip-detail ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 12px; } +.security-bans-body-row.is-selected { background: #c5dcff; } +.security-table-wrap { + min-height: 280px; + height: 320px; + overflow: auto; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} +.security-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; line-height: 14px; } +.security-table th, +.security-table td { padding: 6px; border-bottom: 1px solid #e1e1e1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.security-table th { position: sticky; top: 0; background: #dfdfdf; z-index: 2; } +.security-docs h4 { margin: 4px 0; font-size: 13px; } +.security-docs p { margin: 0 0 8px; font-size: 12px; line-height: 1.4; } +.security-docs pre { + margin: 0 0 10px; + padding: 8px; + background: #f2f2f2; + border: 1px solid #c0c0c0; + overflow: auto; + font-size: 11px; +} + +@media (max-width: 980px) { + .security-grid, + .security-ban-grid, + .security-table-toolbar { + grid-template-columns: 1fr; + } +} diff --git a/static/js/admin/activity.js b/static/js/admin/activity.js new file mode 100644 index 0000000..e43d3bc --- /dev/null +++ b/static/js/admin/activity.js @@ -0,0 +1,106 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} }; + const dataNode = document.getElementById("activity-data"); + const body = document.getElementById("activity-body"); + const searchInput = document.getElementById("activity-search"); + const severityFilter = document.getElementById("activity-severity"); + const kindFilter = document.getElementById("activity-kind"); + const statusLeft = document.getElementById("activity-status-left"); + const toast = document.getElementById("toast"); + + if (!dataNode || !body || !searchInput) return; + const events = parseData(); + const initialQuery = new URLSearchParams(window.location.search).get("q"); + if (initialQuery) searchInput.value = initialQuery; + + function parseData() { + try { + return JSON.parse(dataNode.textContent || "[]"); + } catch (_) { + return []; + } + } + + function showToast(message, type = "info", duration = 1800) { + window.WarpBoxUI?.toast?.(message, type, { target: toast, duration }); + } + + function renderKindFilter() { + const kinds = new Set(events.map((event) => event.kind || "unknown")); + Array.from(kinds).sort().forEach((kind) => { + const option = document.createElement("option"); + option.value = kind; + option.textContent = kind; + kindFilter.appendChild(option); + }); + } + + function createdLabel(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return "-"; + return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC"; + } + + function filtered() { + const query = searchInput.value.trim().toLowerCase(); + const severity = severityFilter.value; + const kind = kindFilter.value; + return events.filter((event) => { + const haystack = [event.kind, event.message, event.ip, event.path, event.method].join(" ").toLowerCase(); + const matchesQuery = !query || haystack.includes(query); + const matchesSeverity = severity === "all" || event.severity === severity; + const matchesKind = kind === "all" || event.kind === kind; + return matchesQuery && matchesSeverity && matchesKind; + }); + } + + function render() { + const rows = filtered(); + body.innerHTML = ""; + rows.forEach((event) => { + const row = document.createElement("tr"); + row.innerHTML = ` + ${createdLabel(event.created_at)} + ${escapeHtml(event.kind || "-")} + ${escapeHtml(event.severity || "-")} + ${escapeHtml(event.ip || "-")} + ${escapeHtml(event.method || "-")} + ${escapeHtml(event.path || "-")} + ${escapeHtml(event.message || "-")} + `; + body.appendChild(row); + }); + statusLeft.textContent = `${rows.length} activity event(s) visible`; + } + + function escapeHtml(value) { + return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? ""); + } + + [searchInput, severityFilter, kindFilter].forEach((element) => { + element.addEventListener(element.tagName === "INPUT" ? "input" : "change", render); + }); + + document.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => { + menuController.close(); + if (button.dataset.command === "refresh") { + window.location.reload(); + return; + } + if (button.dataset.command === "export") { + const blob = new Blob([JSON.stringify(filtered(), null, 2)], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `warpbox-activity-${new Date().toISOString().replaceAll(":", "-")}.json`; + anchor.click(); + URL.revokeObjectURL(url); + showToast("Visible activity exported"); + } + }); + }); + + renderKindFilter(); + render(); +})(); diff --git a/static/js/admin/alerts.js b/static/js/admin/alerts.js index e50cf30..075300f 100644 --- a/static/js/admin/alerts.js +++ b/static/js/admin/alerts.js @@ -1,25 +1,16 @@ (() => { - const menuController = window.WarpBoxUI?.bindMenuBar?.() || { - close() { - document.querySelectorAll(".menu-item.is-open").forEach((item) => { - item.classList.remove("is-open"); - item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); - }); - } - }; - const toast = document.getElementById("toast"); + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} }; + const dataNode = document.getElementById("alerts-data"); + const alertsBody = document.getElementById("alerts-body"); const searchInput = document.getElementById("search-input"); const severityFilter = document.getElementById("severity-filter"); const statusFilter = document.getElementById("status-filter"); const sourceFilter = document.getElementById("source-filter"); const sortFilter = document.getElementById("sort-filter"); - const alertsBody = document.getElementById("alerts-body"); - const selectedCountEl = document.getElementById("selected-count"); - const openCountEl = document.querySelector("[data-open-count]"); - const highCountEl = document.querySelector("[data-high-count]"); - const ackCountEl = document.querySelector("[data-ack-count]"); - const closedCountEl = document.querySelector("[data-closed-count]"); const selectAll = document.getElementById("select-all"); + const selectedCountEl = document.getElementById("selected-count"); + const totalPill = document.getElementById("alerts-total-pill"); + const toast = document.getElementById("toast"); const detailEls = { title: document.getElementById("detail-title"), @@ -32,185 +23,245 @@ metadata: document.getElementById("detail-metadata") }; - if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return; + if (!dataNode || !alertsBody) return; + + const state = { + alerts: parseData(), + selected: new Set(), + activeID: null + }; + const initialQuery = new URLSearchParams(window.location.search).get("q"); + if (initialQuery) searchInput.value = initialQuery; + + function parseData() { + try { + return JSON.parse(dataNode.textContent || "[]"); + } catch (_) { + return []; + } + } function showToast(message, type = "info", duration = 1800) { - if (window.WarpBoxUI) { - window.WarpBoxUI.toast(message, type, { target: toast, duration }); - return; - } - if (!toast) return; - toast.textContent = message; - toast.classList.add("is-visible"); - window.setTimeout(() => toast.classList.remove("is-visible"), duration); + window.WarpBoxUI?.toast?.(message, type, { target: toast, duration }); } - function allRows() { - return Array.from(alertsBody.querySelectorAll("tr")); + function createdLabel(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return "-"; + return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC"; } - function visibleRows() { - return allRows().filter((row) => row.style.display !== "none"); + function allAlerts() { + return state.alerts.slice(); } - function selectedRows() { - return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none"); - } - - function updateSelectedCount() { - selectedCountEl.textContent = `Selected: ${selectedRows().length}`; - } - - function updateSummaryCounts() { - const rows = visibleRows(); - openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length); - highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length); - ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length); - closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length); - } - - function updateDetails(row) { - if (!row) return; - allRows().forEach((item) => item.classList.remove("is-selected")); - row.classList.add("is-selected"); - detailEls.title.textContent = row.dataset.title || ""; - detailEls.severity.textContent = row.dataset.severity || ""; - detailEls.status.textContent = row.dataset.status || ""; - detailEls.code.textContent = row.dataset.code || ""; - detailEls.trace.textContent = row.dataset.trace || ""; - detailEls.time.textContent = row.dataset.time || ""; - detailEls.description.textContent = row.dataset.description || ""; - try { - detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2); - } catch (_) { - detailEls.metadata.textContent = row.dataset.metadata || "{}"; - } - } - - function applyFilters() { - const search = searchInput.value.trim().toLowerCase(); + function filteredAlerts() { + const query = searchInput.value.trim().toLowerCase(); const severity = severityFilter.value; const status = statusFilter.value; const group = sourceFilter.value; - - allRows().forEach((row) => { + const rows = allAlerts().filter((alert) => { const haystack = [ - row.dataset.title, - row.dataset.description, - row.dataset.code, - row.dataset.trace, - row.dataset.group + alert.title, + alert.message, + alert.code, + alert.trace, + alert.group ].join(" ").toLowerCase(); - const matchesSearch = !search || haystack.includes(search); - const matchesSeverity = severity === "all" || row.dataset.severity === severity; - const matchesStatus = status === "all" || row.dataset.status === status; - const matchesGroup = group === "all" || row.dataset.group === group; - row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none"; + const matchesSearch = !query || haystack.includes(query); + const matchesSeverity = severity === "all" || alert.severity === severity; + const matchesStatus = status === "all" || alert.status === status; + const matchesGroup = group === "all" || alert.group === group; + return matchesSearch && matchesSeverity && matchesStatus && matchesGroup; }); - const order = { high: 3, medium: 2, low: 1 }; - visibleRows().sort((a, b) => { - if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity]; - if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id); - return Number(b.dataset.id) - Number(a.dataset.id); - }).forEach((row) => alertsBody.appendChild(row)); - - const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected")); - if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]); - updateSelectedCount(); - updateSummaryCounts(); + rows.sort((a, b) => { + if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0); + if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at)); + return String(b.created_at).localeCompare(String(a.created_at)); + }); + return rows; } - function setRowStatus(row, nextStatus) { - row.dataset.status = nextStatus; - const statusCell = row.children[3]?.querySelector(".alerts-pill"); - if (!statusCell) return; - statusCell.className = `alerts-pill ${nextStatus}`; - statusCell.textContent = nextStatus; + function ensureActive(rows) { + if (rows.length === 0) { + state.activeID = null; + return null; + } + const found = rows.find((item) => item.id === state.activeID); + if (found) return found; + state.activeID = rows[0].id; + return rows[0]; } - function changeSelectedStatus(nextStatus) { - const rows = selectedRows(); - if (!rows.length) { + function render() { + const rows = filteredAlerts(); + alertsBody.innerHTML = ""; + rows.forEach((alert) => alertsBody.appendChild(buildRow(alert))); + const active = ensureActive(rows); + if (active) renderDetails(active); + renderSummary(rows); + syncSelected(); + syncSelectAll(rows); + } + + function buildRow(alert) { + const row = document.createElement("tr"); + if (state.activeID === alert.id) row.classList.add("is-selected"); + row.innerHTML = ` + + ${escapeHtml(alert.title || "-")} + ${escapeHtml(alert.severity || "low")} + ${escapeHtml(alert.status || "open")} + ${escapeHtml(alert.code || "-")} + ${escapeHtml(alert.trace || "-")} + ${createdLabel(alert.created_at)} + + `; + row.addEventListener("click", (event) => { + if (event.target.closest("button") || event.target.closest("input")) return; + state.activeID = alert.id; + render(); + }); + row.querySelector(".row-open")?.addEventListener("click", () => { + state.activeID = alert.id; + render(); + }); + row.querySelector(".row-check")?.addEventListener("change", (event) => { + if (event.target.checked) state.selected.add(alert.id); + else state.selected.delete(alert.id); + syncSelected(); + syncSelectAll(filteredAlerts()); + }); + return row; + } + + function renderDetails(alert) { + detailEls.title.textContent = alert.title || ""; + detailEls.severity.textContent = alert.severity || ""; + detailEls.status.textContent = alert.status || ""; + detailEls.code.textContent = alert.code || ""; + detailEls.trace.textContent = alert.trace || ""; + detailEls.time.textContent = createdLabel(alert.created_at); + detailEls.description.textContent = alert.message || ""; + detailEls.metadata.textContent = JSON.stringify(alert.meta || {}, null, 2); + } + + function renderSummary(rows) { + const open = rows.filter((item) => item.status === "open").length; + const high = rows.filter((item) => item.severity === "high" && item.status !== "closed").length; + const ack = rows.filter((item) => item.status === "acked").length; + const closed = rows.filter((item) => item.status === "closed").length; + document.querySelector("[data-open-count]").textContent = String(open); + document.querySelector("[data-high-count]").textContent = String(high); + document.querySelector("[data-ack-count]").textContent = String(ack); + document.querySelector("[data-closed-count]").textContent = String(closed); + totalPill.textContent = `${rows.length} alerts`; + } + + function syncSelected() { + selectedCountEl.textContent = `Selected: ${state.selected.size}`; + } + + function syncSelectAll(rows) { + selectAll.checked = rows.length > 0 && rows.every((alert) => state.selected.has(alert.id)); + } + + async function postAction(action, ids) { + const response = await fetch("/admin/alerts/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ids }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Request failed"); + state.alerts = payload.alerts || []; + } + + async function runAction(action) { + const ids = Array.from(state.selected); + if (!ids.length && (action === "ack" || action === "close" || action === "delete")) { showToast("Select one or more alerts first", "warning"); return; } - - rows.forEach((row) => { - setRowStatus(row, nextStatus); - row.querySelector(".row-check").checked = false; - }); - if (selectAll) selectAll.checked = false; - updateSelectedCount(); - updateSummaryCounts(); - - const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0]; - if (currentRow) updateDetails(currentRow); - showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed"); + if (action === "open-only") { + statusFilter.value = "open"; + render(); + showToast("Showing open alerts only"); + return; + } + if (action === "refresh") { + window.location.reload(); + return; + } + if (action === "copy-meta") { + const active = allAlerts().find((item) => item.id === state.activeID); + if (active) { + navigator.clipboard?.writeText(JSON.stringify(active.meta || {}, null, 2)).catch(() => {}); + } + showToast("Metadata copied"); + return; + } + if (action === "export") { + const blob = new Blob([JSON.stringify(filteredAlerts(), null, 2)], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `warpbox-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`; + anchor.click(); + URL.revokeObjectURL(url); + showToast("Visible alerts exported"); + return; + } + if (action === "help-codes") { + showToast("Codes map to internal security and service traces."); + return; + } + if (action === "help-meta") { + showToast("Metadata shows extra context for each alert."); + return; + } + await postAction(action, ids); + state.selected.clear(); + render(); + showToast(`Action complete: ${action}`, "success"); } - const commandMessages = { - refresh: "Alerts refreshed in mock view", - export: "Visible alerts exported in mock view", - "copy-meta": "Metadata copied in mock view", - "help-codes": "Each alert code maps to a unique trigger point and trace identifier.", - "help-meta": "Metadata explains why the alert happened and includes extra context." - }; - - function runCommand(command) { - switch (command) { - case "ack": - changeSelectedStatus("acked"); - return; - case "close": - changeSelectedStatus("closed"); - return; - case "open-only": - statusFilter.value = "open"; - applyFilters(); - showToast("Showing open alerts only"); - return; - default: - showToast(commandMessages[command] || `Mock action: ${command}`); - } + function escapeHtml(value) { + return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? ""); } [searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => { - control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters); - }); - - allRows().forEach((row) => { - row.addEventListener("click", (event) => { - if (event.target.closest("button") || event.target.closest("input")) return; - updateDetails(row); - }); - row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row)); - row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount); + control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render); }); selectAll?.addEventListener("change", () => { - visibleRows().forEach((row) => { - const checkbox = row.querySelector(".row-check"); - if (checkbox) checkbox.checked = selectAll.checked; + const rows = filteredAlerts(); + rows.forEach((alert) => { + if (selectAll.checked) state.selected.add(alert.id); + else state.selected.delete(alert.id); }); - updateSelectedCount(); + render(); }); document.querySelectorAll("[data-command]").forEach((button) => { - button.addEventListener("click", () => { + button.addEventListener("click", async () => { menuController.close(); - runCommand(button.dataset.command); + try { + await runAction(button.dataset.command); + } catch (error) { + showToast(error.message, "error", 3200); + } }); }); - document.addEventListener("keydown", (event) => { + document.addEventListener("keydown", async (event) => { if (event.key === "Escape") menuController.close(); if (event.key === "F5") { event.preventDefault(); - runCommand("refresh"); + await runAction("refresh"); } }); - applyFilters(); - updateDetails(allRows()[0]); + render(); })(); diff --git a/static/js/admin/boxes.js b/static/js/admin/boxes.js index 0c88596..11e8f36 100644 --- a/static/js/admin/boxes.js +++ b/static/js/admin/boxes.js @@ -373,6 +373,35 @@ } } + async function runCleanupAction() { + try { + const response = await fetch("/admin/boxes/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "cleanup_expired", box_ids: [] }) + }); + const payload = await response.json(); + if (!response.ok) { + const message = payload.error || payload.message || "Cleanup failed"; + showToast(message, "error", 3200); + return; + } + state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes; + state.selected.clear(); + if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) { + state.activeId = null; + } + renderTable(); + let message = payload.message || "Expired cleanup completed"; + if (Array.isArray(payload.warnings) && payload.warnings.length) { + message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`; + } + showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 3200); + } catch (_) { + showToast("Network error while running cleanup", "error", 3200); + } + } + function selectedIDsOrActive() { if (state.selected.size) return Array.from(state.selected); const active = currentActiveBox(); @@ -419,6 +448,10 @@ if (!window.confirm("Delete selected boxes? This removes stored files.")) return; await runBulkAction("delete", selectedIDsOrActive()); return; + case "cleanup-expired": + if (!window.confirm("Run cleanup for expired boxes now?")) return; + await runCleanupAction(); + return; case "help-scope": showToast("Ownership filter waits for account + box owner data in backend", "info", 3400); return; diff --git a/static/js/admin/security.js b/static/js/admin/security.js new file mode 100644 index 0000000..7390d18 --- /dev/null +++ b/static/js/admin/security.js @@ -0,0 +1,314 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} }; + const eventsNode = document.getElementById("security-events-data"); + const alertsNode = document.getElementById("security-alerts-data"); + const bansNode = document.getElementById("security-bans-data"); + const ipInput = document.getElementById("security-ip-input"); + const banUntilInput = document.getElementById("security-ban-until"); + const alertList = document.getElementById("security-alert-list"); + const activityBody = document.getElementById("security-activity-body"); + const bansBody = document.getElementById("security-bans-body"); + const bansCount = document.getElementById("security-bans-count"); + const filterInput = document.getElementById("security-ban-filter"); + const sortSelect = document.getElementById("security-ban-sort"); + const selectAll = document.getElementById("security-select-all"); + const copyIPButton = document.getElementById("security-copy-ip"); + const openActivityButton = document.getElementById("security-open-activity"); + const openAlertsButton = document.getElementById("security-open-alerts"); + const toast = document.getElementById("toast"); + + const detail = { + ip: document.getElementById("security-detail-ip"), + risk: document.getElementById("security-detail-risk"), + threat: document.getElementById("security-detail-threat"), + geo: document.getElementById("security-detail-geo"), + asn: document.getElementById("security-detail-asn"), + until: document.getElementById("security-detail-until"), + why: document.getElementById("security-detail-why") + }; + + if (!eventsNode || !alertsNode || !bansNode) return; + const state = { + events: parse(eventsNode), + alerts: parse(alertsNode), + bans: parse(bansNode), + selectedIP: "", + selectedIPs: new Set() + }; + + setDefaultBanUntil(); + + function parse(node) { + try { return JSON.parse(node.textContent || "[]"); } catch (_) { return []; } + } + + function showToast(message, type = "info", duration = 1800) { + window.WarpBoxUI?.toast?.(message, type, { target: toast, duration }); + } + + function createdLabel(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return "-"; + return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC"; + } + + function setDefaultBanUntil() { + const base = new Date(Date.now() + 30 * 60 * 1000); + const yyyy = String(base.getUTCFullYear()); + const mm = String(base.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(base.getUTCDate()).padStart(2, "0"); + const hh = String(base.getUTCHours()).padStart(2, "0"); + const mi = String(base.getUTCMinutes()).padStart(2, "0"); + banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`; + } + + function toRFC3339FromLocalUTC(datetimeLocalValue) { + if (!datetimeLocalValue) return ""; + const date = new Date(datetimeLocalValue + ":00Z"); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString(); + } + + function setSelectedIP(ip) { + state.selectedIP = ip || ""; + if (state.selectedIP) ipInput.value = state.selectedIP; + renderBans(); + renderIPDetails(); + } + + function render() { + renderAlerts(); + renderActivity(); + renderBans(); + renderIPDetails(); + } + + function renderAlerts() { + alertList.innerHTML = ""; + state.alerts.slice(0, 12).forEach((alert) => { + const entry = document.createElement("li"); + entry.textContent = `${createdLabel(alert.created_at)} | ${alert.severity || "low"} | ${alert.title || "-"}`; + alertList.appendChild(entry); + }); + } + + function renderActivity() { + activityBody.innerHTML = ""; + state.events + .filter((event) => String(event.kind || "").startsWith("security") || String(event.kind || "").startsWith("auth.admin")) + .slice(0, 60) + .forEach((event) => { + const row = document.createElement("tr"); + row.innerHTML = ` + ${createdLabel(event.created_at)} + ${escapeHtml(event.kind || "-")} + ${escapeHtml(event.severity || "-")} + ${escapeHtml(event.ip || "-")} + ${escapeHtml(event.path || "-")} + ${escapeHtml(event.message || "-")} + `; + activityBody.appendChild(row); + }); + } + + function rowData() { + const banMap = new Map(state.bans.map((entry) => [entry.ip, entry])); + const filter = String(filterInput?.value || "").trim().toLowerCase(); + let rows = state.bans.map((entry) => ({ ip: entry.ip, status: "banned", until: entry.until, ban: entry })); + if (filter) rows = rows.filter((row) => row.ip.toLowerCase().includes(filter)); + const sort = sortSelect?.value || "expiry_asc"; + rows.sort((a, b) => { + if (sort === "ip_asc") return a.ip.localeCompare(b.ip); + if (sort === "ip_desc") return b.ip.localeCompare(a.ip); + const av = new Date(a.until).getTime(); + const bv = new Date(b.until).getTime(); + return sort === "expiry_desc" ? bv - av : av - bv; + }); + return { rows, banMap }; + } + + function renderBans() { + bansBody.innerHTML = ""; + const { rows } = rowData(); + rows.forEach((rowData) => { + const row = document.createElement("tr"); + row.className = "security-bans-body-row"; + if (rowData.ip === state.selectedIP) row.classList.add("is-selected"); + row.innerHTML = ` + + ${escapeHtml(rowData.ip || "-")} + ${rowData.status} + ${createdLabel(rowData.until)} + `; + row.addEventListener("click", (event) => { + if (event.target && event.target.classList.contains("security-row-select")) return; + setSelectedIP(rowData.ip); + }); + bansBody.appendChild(row); + }); + bansBody.querySelectorAll(".security-row-select").forEach((checkbox) => { + checkbox.addEventListener("change", () => { + const ip = checkbox.getAttribute("data-ip"); + if (!ip) return; + if (checkbox.checked) state.selectedIPs.add(ip); else state.selectedIPs.delete(ip); + }); + }); + bansCount.textContent = `${state.bans.length} active bans`; + } + + function renderIPDetails() { + const ip = state.selectedIP || String(ipInput.value || "").trim(); + if (!ip) { + detail.ip.textContent = "No IP selected"; + detail.risk.textContent = "-"; + detail.threat.textContent = "-"; + detail.geo.textContent = "GeoIP not enabled yet"; + detail.asn.textContent = "GeoIP not enabled yet"; + detail.until.textContent = "-"; + detail.why.textContent = "-"; + return; + } + const ban = state.bans.find((entry) => entry.ip === ip); + const matchingEvents = state.events.filter((event) => String(event.ip || "") === ip); + const matchingAlerts = state.alerts.filter((alert) => String(alert?.meta?.ip || "") === ip); + const lastEvent = matchingEvents[0] || null; + detail.ip.textContent = ip; + detail.risk.textContent = ban ? "high" : "medium"; + detail.threat.textContent = ban ? "Temporary banned source" : "Observed source"; + detail.geo.textContent = "GeoIP not enabled yet"; + detail.asn.textContent = "GeoIP not enabled yet"; + detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned"; + detail.why.textContent = `${matchingEvents.length} events, ${matchingAlerts.length} alerts${lastEvent ? `, latest=${lastEvent.kind}` : ""}`; + if (ban && ban.until) { + const parsed = new Date(ban.until); + if (!Number.isNaN(parsed.getTime())) { + const yyyy = String(parsed.getUTCFullYear()); + const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(parsed.getUTCDate()).padStart(2, "0"); + const hh = String(parsed.getUTCHours()).padStart(2, "0"); + const mi = String(parsed.getUTCMinutes()).padStart(2, "0"); + banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`; + } + } + } + + function escapeHtml(value) { + return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? ""); + } + + async function postAction(action, payload = {}) { + const response = await fetch("/admin/security/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ...payload }) + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || "Request failed"); + if (Array.isArray(result.bans)) state.bans = result.bans; + return result; + } + + async function banIP() { + const ip = String(ipInput.value || "").trim(); + if (!ip) return showToast("Enter IP first", "warning"); + const payload = await postAction("ban", { ip }); + setSelectedIP(ip); + showToast(payload.message || "IP banned", "success"); + } + + async function banUntil() { + const ip = String(ipInput.value || "").trim(); + if (!ip) return showToast("Enter IP first", "warning"); + const banUntil = toRFC3339FromLocalUTC(banUntilInput.value); + if (!banUntil) return showToast("Set valid expiration date", "warning"); + if (!window.confirm("Apply custom ban expiration?")) return; + const payload = await postAction("ban_until", { ip, ban_until: banUntil }); + setSelectedIP(ip); + showToast(payload.message || "IP ban expiration updated", "success"); + } + + async function unbanIP() { + const ip = state.selectedIP || String(ipInput.value || "").trim(); + if (!ip) return showToast("Select or enter IP first", "warning"); + if (!window.confirm(`Unban ${ip}?`)) return; + const payload = await postAction("unban", { ip }); + state.selectedIPs.delete(ip); + setSelectedIP(""); + showToast(payload.message || "IP unbanned", "success"); + } + + async function bulkUnban() { + const ips = Array.from(state.selectedIPs); + if (ips.length === 0) return showToast("Select at least one banned IP", "warning"); + if (!window.confirm(`Unban ${ips.length} selected IPs?`)) return; + const payload = await postAction("bulk_unban", { ips }); + state.selectedIPs.clear(); + setSelectedIP(""); + showToast(payload.message || "Bulk unban complete", "success"); + } + + async function unbanAll() { + if (!window.confirm("Unban all active bans?")) return; + const payload = await postAction("unban_all"); + state.selectedIPs.clear(); + setSelectedIP(""); + showToast(payload.message || "All bans cleared", "success"); + } + + document.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", async () => { + menuController.close(); + try { + const command = button.dataset.command; + if (command === "refresh") return window.location.reload(); + if (command === "ban-ip") return await banIP(); + if (command === "ban-until") return await banUntil(); + if (command === "unban-ip") return await unbanIP(); + if (command === "bulk-unban") return await bulkUnban(); + if (command === "unban-all") return await unbanAll(); + } catch (error) { + showToast(error.message, "error", 3200); + } finally { + renderBans(); + renderIPDetails(); + } + }); + }); + + ipInput.addEventListener("input", () => renderIPDetails()); + filterInput?.addEventListener("input", () => renderBans()); + sortSelect?.addEventListener("change", () => renderBans()); + selectAll?.addEventListener("change", () => { + if (selectAll.checked) state.bans.forEach((ban) => state.selectedIPs.add(ban.ip)); + else state.selectedIPs.clear(); + renderBans(); + }); + + copyIPButton?.addEventListener("click", async () => { + const ip = state.selectedIP || String(ipInput.value || "").trim(); + if (!ip) return showToast("No IP selected", "warning"); + await navigator.clipboard.writeText(ip); + showToast("IP copied", "success"); + }); + openActivityButton?.addEventListener("click", () => { + const ip = state.selectedIP || String(ipInput.value || "").trim(); + if (!ip) return showToast("No IP selected", "warning"); + window.location.href = `/admin/activity?q=${encodeURIComponent(ip)}`; + }); + openAlertsButton?.addEventListener("click", () => { + const ip = state.selectedIP || String(ipInput.value || "").trim(); + if (!ip) return showToast("No IP selected", "warning"); + window.location.href = `/admin/alerts?q=${encodeURIComponent(ip)}`; + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") menuController.close(); + if (event.key === "F5") { + event.preventDefault(); + window.location.reload(); + } + }); + + if (state.bans.length > 0) setSelectedIP(state.bans[0].ip); + render(); +})(); diff --git a/templates/admin/activity.html b/templates/admin/activity.html new file mode 100644 index 0000000..300e062 --- /dev/null +++ b/templates/admin/activity.html @@ -0,0 +1,92 @@ +{{ define "admin/activity.html" }} + + + + + + WarpBox Admin Activity + + + + + + + + + +
+
+ {{ template "admin/header.html" . }} +
+
+
+ +

WarpBox Activity

+
+ +
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + + + + + + +
TimeKindSeverityIPMethodPathMessage
+
+
+
+ +
+ {{ len .Events }} events loaded + retention from settings + admin only +
+
+
+
+ +
+ + + + + +{{ end }} diff --git a/templates/admin/alerts.html b/templates/admin/alerts.html index 579e87f..b398bb1 100644 --- a/templates/admin/alerts.html +++ b/templates/admin/alerts.html @@ -61,22 +61,22 @@

Open alerts

-

5

+

{{ .OpenCount }}

Requires attention

High severity

-

2

+

{{ .HighCount }}

Escalate first

Acknowledged

-

3

+

{{ .AckCount }}

Seen but not closed

Closed today

-

2

+

{{ .ClosedCount }}

History stays lightweight

@@ -134,108 +134,7 @@ Actions - - - - Storage connector unavailable - high - open - 301 - storage.connector.health_failed - today 14:08 - - - - - Thumbnail generation failed - medium - open - 601 - thumbnail.generate.failed - today 13:40 - - - - - Large upload nearing account cap - low - acked - 124 - upload.quota.nearing_cap - today 12:58 - - - - - Repeated admin login failures - high - open - 211 - auth.admin.failed_login_burst - today 12:10 - - - - - Cleanup skipped locked files - medium - acked - 342 - cleanup.skip.locked_files - today 10:22 - - - - - Archive completed with warnings - low - closed - 145 - archive.complete.with_warning - today 09:02 - - - - - Upload session expired mid-transfer - medium - open - 156 - upload.session.expired_mid_transfer - yesterday - - - - - Thumbnail worker restarted - low - closed - 602 - thumbnail.worker.restarted - yesterday - - - - - User invited without email delivery confirmation - medium - acked - 224 - auth.invite.delivery_unknown - 2 days ago - - - - - Secondary connector caught up - low - closed - 329 - storage.secondary.sync_recovered - 2 days ago - - - + @@ -287,10 +186,11 @@
+
- CURRENTLY_MOCKED_LEAVE_AS_IS: alerts use a lightweight lifecycle for now: open, acknowledged, closed. + Alerts persist until deleted. Acknowledge and close update state; delete removes permanently.
@@ -301,11 +201,12 @@
@@ -314,6 +215,7 @@
+ diff --git a/templates/admin/boxes.html b/templates/admin/boxes.html index 86e02d8..0b6ba7c 100644 --- a/templates/admin/boxes.html +++ b/templates/admin/boxes.html @@ -54,6 +54,7 @@ + @@ -112,6 +113,7 @@ + @@ -211,6 +213,9 @@ +
+ +
diff --git a/templates/admin/partials/header.html b/templates/admin/partials/header.html index f3200a2..01da28d 100644 --- a/templates/admin/partials/header.html +++ b/templates/admin/partials/header.html @@ -8,7 +8,9 @@ Dashboard Alerts Boxes + Activity Users + Security Settings
diff --git a/templates/admin/security.html b/templates/admin/security.html new file mode 100644 index 0000000..98a38c9 --- /dev/null +++ b/templates/admin/security.html @@ -0,0 +1,179 @@ +{{ define "admin/security.html" }} + + + + + + WarpBox Admin Security + + + + + + + + + +
+
+ {{ template "admin/header.html" . }} +
+
+
+ +

WarpBox Security

+
+ +
+ + + +
+
+
+
Manual controlsadmin actions
+
+ + + + + + + +
Ban duration, whitelist rules and trusted proxies are managed in Settings - Security.
+
+
+ +
+
Recent alerts{{ len .Alerts }} total
+
+
    +
    +
    +
    + +
    +
    Active bans{{ len .Bans }} active bans
    +
    +
    +
    + + +
    +
    + + + + + + + + + + +
    IPStatusBan expires (UTC)
    +
    +
    +
    +

    No IP selected

    +
      +
    • Risk: -
    • +
    • Threat: -
    • +
    • Geo: GeoIP not enabled yet
    • +
    • ASN: GeoIP not enabled yet
    • +
    • Ban until: -
    • +
    • Why banned: -
    • +
    • +
    • +
    • +
    +
    +
    +
    + +
    +
    Recent security activity{{ len .Events }} rows
    +
    +
    + + + + + + + + + + + + +
    TimeKindSeverityIPPathMessage
    +
    +
    +
    + +
    +
    Security Runbookops quick reference
    +
    +

    Reverse Proxy and Trusted CIDRs

    +

    Set WARPBOX_TRUSTED_PROXY_CIDRS to the CIDRs of your proxy nodes only. WarpBox will trust forwarding headers only when the direct remote IP is in this list.

    +
    Caddyfile
    +:443 {
    +  reverse_proxy 127.0.0.1:8080 {
    +    header_up X-Forwarded-For {http.request.remote.host}
    +    header_up X-Real-IP {http.request.remote.host}
    +  }
    +}
    +

    Ban / Unban Safety

    +

    Use custom ban durations only for active incidents. Prefer temporary bans. Review the "why banned" detail before unbanning to avoid immediate re-abuse.

    +

    Tuning Guidance

    +

    Low traffic: lower security_*_max_attempts. High traffic: increase windows and attempt thresholds gradually, then monitor alerts/activity for false positives.

    +

    GeoIP Guide (planned)

    +

    For geoip2fast, keep lookups async-safe with a single loaded database, add a short timeout per lookup, cache by IP with TTL, and degrade gracefully to "unknown" on failures. Start with security detail pane only, then aggregate stats later.

    +
    +
    +
    + +
    + Security controls active + alerts + activity linked + admin only +
    +
    +
    +
    + +
    + + + + + + + +{{ end }}