From 9d9db5cf0b7f9077bf3ba50f6d84e6f0bb7f112a Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 3 May 2026 22:46:54 +0300 Subject: [PATCH] feat(security): Implemented more security information --- docs/geoip-guide.md | 19 +++ docs/security-runbook.md | 40 +++++ go.mod | 26 ++-- go.sum | 53 +++++-- lib/config/definitions.go | 1 + lib/config/load.go | 13 ++ lib/config/models.go | 2 + lib/config/overrides.go | 25 +++ lib/security/guard.go | 249 +++++++++++++++++++++++++++--- lib/security/guard_test.go | 52 +++++++ lib/server/admin.go | 2 +- lib/server/admin_security.go | 69 +++++++-- lib/server/admin_security_test.go | 123 +++++++++++++++ lib/server/admin_settings_test.go | 27 ++++ lib/server/ip.go | 42 +++-- lib/server/ip_test.go | 44 ++++++ lib/server/validation.go | 2 +- static/css/security.css | 55 +++---- static/js/admin/security.js | 178 +++++++++++++-------- templates/admin/security.html | 73 +++++++-- 20 files changed, 902 insertions(+), 193 deletions(-) create mode 100644 docs/geoip-guide.md create mode 100644 docs/security-runbook.md create mode 100644 lib/security/guard_test.go create mode 100644 lib/server/admin_security_test.go create mode 100644 lib/server/ip_test.go 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/config/definitions.go b/lib/config/definitions.go index f3d6ecc..f4aba9f 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -23,6 +23,7 @@ var Definitions = []SettingDefinition{ {Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, {Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true}, {Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true}, + {Key: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true}, {Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, {Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10}, diff --git a/lib/config/load.go b/lib/config/load.go index 6f0cbcd..08e37a7 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -62,6 +62,9 @@ func Load() (*Config, error) { if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil { return nil, err } + if err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); err != nil { + return nil, err + } if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" { mode := AdminEnabledMode(strings.ToLower(raw)) if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { @@ -162,6 +165,15 @@ func Load() (*Config, error) { return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty") } cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail) + if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil { + return nil, err + } + if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil { + return nil, err + } + if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil { + return nil, err + } cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads") cfg.DBDir = filepath.Join(cfg.DataDir, "db") cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir)) @@ -199,6 +211,7 @@ func (cfg *Config) captureDefaults() { cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10)) cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist) cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist) + cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs) cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10)) cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts)) cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10)) diff --git a/lib/config/models.go b/lib/config/models.go index d471cd5..7397733 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -39,6 +39,7 @@ const ( SettingActivityRetentionSeconds = "activity_retention_seconds" SettingSecurityIPWhitelist = "security_ip_whitelist" SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist" + SettingTrustedProxyCIDRs = "trusted_proxy_cidrs" SettingSecurityLoginWindowSecs = "security_login_window_seconds" SettingSecurityLoginMaxAttempts = "security_login_max_attempts" SettingSecurityBanSeconds = "security_ban_seconds" @@ -109,6 +110,7 @@ type Config struct { ActivityRetentionSeconds int64 SecurityIPWhitelist string SecurityAdminIPWhitelist string + TrustedProxyCIDRs string SecurityLoginWindowSeconds int64 SecurityLoginMaxAttempts int SecurityBanSeconds int64 diff --git a/lib/config/overrides.go b/lib/config/overrides.go index 79bd351..17387b2 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) @@ -58,6 +66,21 @@ func (cfg *Config) ApplyOverride(key string, value string) error { } return nil } + +func validateSecurityTextSetting(key string, value string) error { + switch key { + case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist: + if _, err := security.ParseIPMatchers(value, true); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + case SettingTrustedProxyCIDRs: + if _, err := security.ParseCIDRList(value); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + } + return nil +} + func (cfg *Config) assignBool(key string, value bool, source Source) { switch key { case SettingGuestUploadsEnabled: @@ -138,6 +161,8 @@ func (cfg *Config) assignText(key string, value string, source Source) { cfg.SecurityIPWhitelist = value case SettingSecurityAdminIPWhitelist: cfg.SecurityAdminIPWhitelist = value + case SettingTrustedProxyCIDRs: + cfg.TrustedProxyCIDRs = value } cfg.setValue(key, value, source) } diff --git a/lib/security/guard.go b/lib/security/guard.go index 568d1f2..23345be 100644 --- a/lib/security/guard.go +++ b/lib/security/guard.go @@ -1,10 +1,16 @@ package security import ( + "encoding/binary" + "fmt" + "net" + "os" "sort" "strings" "sync" "time" + + "github.com/dgraph-io/badger/v4" ) type Config struct { @@ -26,8 +32,14 @@ type Guard struct { scanAttempts map[string][]time.Time uploadEvents map[string][]uploadEvent bannedUntil map[string]time.Time - ipWhitelist map[string]bool - adminWhitelist map[string]bool + ipWhitelist []ipMatcher + adminWhitelist []ipMatcher + banDB *badger.DB +} + +type ipMatcher struct { + exact net.IP + prefix *net.IPNet } type uploadEvent struct { @@ -40,34 +52,90 @@ type BanEntry struct { Until time.Time `json:"until"` } +const banKeyPrefix = "ban:" + func NewGuard() *Guard { return &Guard{ failedLogins: map[string][]time.Time{}, scanAttempts: map[string][]time.Time{}, uploadEvents: map[string][]uploadEvent{}, bannedUntil: map[string]time.Time{}, - ipWhitelist: map[string]bool{}, - adminWhitelist: map[string]bool{}, + ipWhitelist: []ipMatcher{}, + adminWhitelist: []ipMatcher{}, } } -func (g *Guard) Reload(cfg Config) { +func (g *Guard) Close() error { g.mu.Lock() defer g.mu.Unlock() - g.ipWhitelist = parseList(cfg.IPWhitelist) - g.adminWhitelist = parseList(cfg.AdminIPWhitelist) + if g.banDB == nil { + return nil + } + err := g.banDB.Close() + g.banDB = nil + return err +} + +func (g *Guard) EnableBanPersistence(path string) error { + if strings.TrimSpace(path) == "" { + return nil + } + + g.mu.Lock() + defer g.mu.Unlock() + + if g.banDB != nil { + return nil + } + + opts := badger.DefaultOptions(path) + opts.Logger = nil + db, err := badger.Open(opts) + if err != nil { + // Corruption-safe fallback: quarantine badger files and start fresh. + _ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405")) + db, err = badger.Open(opts) + } + if err != nil { + return err + } + g.banDB = db + + if err := g.loadBansLocked(); err != nil { + _ = g.banDB.Close() + g.banDB = nil + return err + } + return nil +} + +func (g *Guard) Reload(cfg Config) error { + ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true) + if err != nil { + return fmt.Errorf("ip whitelist: %w", err) + } + adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true) + if err != nil { + return fmt.Errorf("admin ip whitelist: %w", err) + } + + g.mu.Lock() + defer g.mu.Unlock() + g.ipWhitelist = ipWhitelist + g.adminWhitelist = adminWhitelist + return nil } func (g *Guard) IsWhitelisted(ip string) bool { g.mu.Lock() defer g.mu.Unlock() - return g.ipWhitelist[ip] + return matchIP(g.ipWhitelist, ip) } func (g *Guard) IsAdminWhitelisted(ip string) bool { g.mu.Lock() defer g.mu.Unlock() - return g.adminWhitelist[ip] || g.ipWhitelist[ip] + return matchIP(g.adminWhitelist, ip) || matchIP(g.ipWhitelist, ip) } func (g *Guard) IsBanned(ip string) bool { @@ -79,6 +147,7 @@ func (g *Guard) IsBanned(ip string) bool { } if time.Now().UTC().After(until) { delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) return false } return true @@ -90,7 +159,9 @@ func (g *Guard) Ban(ip string, seconds int64) { } g.mu.Lock() defer g.mu.Unlock() - g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second) + until := time.Now().UTC().Add(time.Duration(seconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) } func (g *Guard) BanUntil(ip string, until time.Time) { @@ -99,7 +170,9 @@ func (g *Guard) BanUntil(ip string, until time.Time) { } g.mu.Lock() defer g.mu.Unlock() - g.bannedUntil[ip] = until.UTC() + until = until.UTC() + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) } func (g *Guard) Unban(ip string) { @@ -109,6 +182,7 @@ func (g *Guard) Unban(ip string) { g.mu.Lock() defer g.mu.Unlock() delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) } func (g *Guard) BanList() []BanEntry { @@ -119,6 +193,7 @@ func (g *Guard) BanList() []BanEntry { for ip, until := range g.bannedUntil { if now.After(until) { delete(g.bannedUntil, ip) + g.deleteBanLocked(ip) continue } out = append(out, BanEntry{IP: ip, Until: until}) @@ -141,7 +216,9 @@ func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts attempts = append(attempts, now) g.failedLogins[ip] = attempts if len(attempts) >= maxAttempts { - g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second) + until := now.Add(time.Duration(banSeconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) return true, len(attempts) } return false, len(attempts) @@ -159,7 +236,9 @@ func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts attempts = append(attempts, now) g.scanAttempts[ip] = attempts if len(attempts) >= maxAttempts { - g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second) + until := now.Add(time.Duration(banSeconds) * time.Second) + g.bannedUntil[ip] = until + g.saveBanLocked(ip, until) return true, len(attempts) } return false, len(attempts) @@ -195,6 +274,49 @@ func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxReque return true, nextCount, nextBytes } +func ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) { + entries := []ipMatcher{} + for _, chunk := range strings.Split(raw, ",") { + value := strings.TrimSpace(chunk) + if value == "" { + continue + } + if strings.Contains(value, "/") { + if !allowCIDR { + return nil, fmt.Errorf("%q must be a CIDR", value) + } + _, network, err := net.ParseCIDR(value) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q", value) + } + entries = append(entries, ipMatcher{prefix: network}) + continue + } + parsed := net.ParseIP(value) + if parsed == nil { + return nil, fmt.Errorf("invalid IP %q", value) + } + entries = append(entries, ipMatcher{exact: parsed}) + } + return entries, nil +} + +func ParseCIDRList(raw string) ([]net.IPNet, error) { + entries := []net.IPNet{} + for _, chunk := range strings.Split(raw, ",") { + value := strings.TrimSpace(chunk) + if value == "" { + continue + } + _, network, err := net.ParseCIDR(value) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q", value) + } + entries = append(entries, *network) + } + return entries, nil +} + func pruneTimes(values []time.Time, cutoff time.Time) []time.Time { kept := make([]time.Time, 0, len(values)) for _, value := range values { @@ -205,13 +327,100 @@ func pruneTimes(values []time.Time, cutoff time.Time) []time.Time { return kept } -func parseList(raw string) map[string]bool { - out := map[string]bool{} - for _, chunk := range strings.Split(raw, ",") { - value := strings.TrimSpace(chunk) - if value != "" { - out[value] = true +func matchIP(rules []ipMatcher, value string) bool { + ip := net.ParseIP(strings.TrimSpace(value)) + if ip == nil { + return false + } + for _, rule := range rules { + if rule.exact != nil && rule.exact.Equal(ip) { + return true + } + if rule.prefix != nil && rule.prefix.Contains(ip) { + return true } } - return out + return false +} + +func (g *Guard) saveBanLocked(ip string, until time.Time) { + if g.banDB == nil || ip == "" || until.IsZero() { + return + } + seconds := int64(time.Until(until).Seconds()) + if seconds <= 0 { + _ = g.banDB.Update(func(txn *badger.Txn) error { + return txn.Delete([]byte(banKeyPrefix + ip)) + }) + return + } + + value := make([]byte, 8) + binary.BigEndian.PutUint64(value, uint64(until.Unix())) + _ = g.banDB.Update(func(txn *badger.Txn) error { + entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second) + return txn.SetEntry(entry) + }) +} + +func (g *Guard) deleteBanLocked(ip string) { + if g.banDB == nil || ip == "" { + return + } + _ = g.banDB.Update(func(txn *badger.Txn) error { + return txn.Delete([]byte(banKeyPrefix + ip)) + }) +} + +func (g *Guard) loadBansLocked() error { + if g.banDB == nil { + return nil + } + now := time.Now().UTC() + loaded := map[string]time.Time{} + expired := [][]byte{} + + err := g.banDB.View(func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() { + item := it.Item() + key := string(item.Key()) + ip := strings.TrimPrefix(key, banKeyPrefix) + err := item.Value(func(val []byte) error { + if len(val) != 8 { + expired = append(expired, append([]byte(nil), item.Key()...)) + return nil + } + unix := int64(binary.BigEndian.Uint64(val)) + until := time.Unix(unix, 0).UTC() + if now.After(until) { + expired = append(expired, append([]byte(nil), item.Key()...)) + return nil + } + loaded[ip] = until + return nil + }) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + g.bannedUntil = loaded + if len(expired) == 0 { + return nil + } + return g.banDB.Update(func(txn *badger.Txn) error { + for _, key := range expired { + if err := txn.Delete(key); err != nil { + return err + } + } + return nil + }) } 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 42754ae..4c36de6 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -62,7 +62,7 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, "/") return } - ip := clientIP(ctx) + ip := app.clientIP(ctx) guard := app.securityGuard if guard == nil { guard = security.NewGuard() diff --git a/lib/server/admin_security.go b/lib/server/admin_security.go index 9d80caa..4563fd0 100644 --- a/lib/server/admin_security.go +++ b/lib/server/admin_security.go @@ -3,6 +3,7 @@ package server import ( "net" "net/http" + "path/filepath" "strconv" "strings" "time" @@ -20,16 +21,20 @@ type adminAlertsActionRequest struct { } type adminSecurityActionRequest struct { - Action string `json:"action"` - IP string `json:"ip"` - BanUntil string `json:"ban_until"` + Action string `json:"action"` + IP string `json:"ip"` + IPs []string `json:"ips"` + BanUntil string `json:"ban_until"` } func (app *App) reloadSecurityConfig() { if app.securityGuard == nil { app.securityGuard = security.NewGuard() } - app.securityGuard.Reload(security.Config{ + if app.config != nil { + _ = app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")) + } + _ = app.securityGuard.Reload(security.Config{ IPWhitelist: app.config.SecurityIPWhitelist, AdminIPWhitelist: app.config.SecurityAdminIPWhitelist, LoginWindowSeconds: app.config.SecurityLoginWindowSeconds, @@ -55,7 +60,7 @@ func (app *App) logActivity(kind string, severity string, message string, ctx *g Meta: meta, } if ctx != nil { - event.IP = clientIP(ctx) + event.IP = app.clientIP(ctx) event.Path = ctx.Request.URL.Path event.Method = ctx.Request.Method } @@ -84,7 +89,7 @@ func (app *App) securityMiddleware() gin.HandlerFunc { ctx.Next() return } - ip := clientIP(ctx) + ip := app.clientIP(ctx) if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { ctx.Next() return @@ -106,7 +111,7 @@ func (app *App) handleNoRoute(ctx *gin.Context) { path := strings.ToLower(ctx.Request.URL.Path) suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env") if suspicious { - ip := clientIP(ctx) + ip := app.clientIP(ctx) if !app.securityGuard.IsWhitelisted(ip) { banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds) app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)}) @@ -146,10 +151,10 @@ func (app *App) handleAdminSecurity(ctx *gin.Context) { events := []activity.Event{} alertsList := []alerts.Alert{} if app.activityStore != nil { - events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds) + events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds) } if app.alertStore != nil { - alertsList, _ = app.alertStore.List(50) + alertsList, _ = app.alertStore.List(120) } bans := []security.BanEntry{} if app.securityGuard != nil { @@ -200,6 +205,15 @@ func (app *App) handleAdminAlertsAction(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList}) } +func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) { + metaCopy := map[string]string{"ip": ip} + for k, v := range meta { + metaCopy[k] = v + } + app.logActivity(kind, severity, message, ctx, metaCopy) + app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy) +} + func (app *App) handleAdminSecurityAction(ctx *gin.Context) { if app.securityGuard == nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"}) @@ -215,6 +229,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"}) return } + switch request.Action { case "ban": if ip == "" { @@ -222,8 +237,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) { return } app.securityGuard.Ban(ip, app.config.SecurityBanSeconds) - app.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip}) - app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip}) + app.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.") ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()}) case "ban_until": if ip == "" { @@ -236,8 +250,8 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) { return } app.securityGuard.BanUntil(ip, until) - app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)}) - app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)}) + meta := map[string]string{"until": until.UTC().Format(time.RFC3339)} + app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.") ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()}) case "unban": if ip == "" { @@ -245,9 +259,34 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) { return } app.securityGuard.Unban(ip) - app.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip}) - app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip}) + app.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.") ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()}) + case "bulk_unban": + if len(request.IPs) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"}) + return + } + count := 0 + for _, candidate := range request.IPs { + candidate = strings.TrimSpace(candidate) + if net.ParseIP(candidate) == nil { + continue + } + app.securityGuard.Unban(candidate) + count++ + } + app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)}) + app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)}) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()}) + case "unban_all": + current := app.securityGuard.BanList() + for _, ban := range current { + app.securityGuard.Unban(ban.IP) + } + count := len(current) + app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)}) + app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)}) + ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()}) default: ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) } diff --git a/lib/server/admin_security_test.go b/lib/server/admin_security_test.go new file mode 100644 index 0000000..4a10cdd --- /dev/null +++ b/lib/server/admin_security_test.go @@ -0,0 +1,123 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "warpbox/lib/activity" + "warpbox/lib/alerts" + "warpbox/lib/config" + "warpbox/lib/security" +) + +func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) { + app, router := setupAdminSecurityTest(t) + + for _, body := range []string{ + `{"action":"ban","ip":"203.0.113.7"}`, + `{"action":"unban","ip":"203.0.113.7"}`, + } { + request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String()) + } + } + + events, err := app.activityStore.List(100, app.config.ActivityRetentionSeconds) + if err != nil { + t.Fatalf("activity list error: %v", err) + } + if len(events) < 2 { + t.Fatalf("expected activity events, got %d", len(events)) + } + alertsList, err := app.alertStore.List(100) + if err != nil { + t.Fatalf("alerts list error: %v", err) + } + if len(alertsList) < 2 { + t.Fatalf("expected alerts for manual actions, got %d", len(alertsList)) + } +} + +func TestAdminSecurityBulkUnbanAndUnbanAll(t *testing.T) { + app, router := setupAdminSecurityTest(t) + app.securityGuard.Ban("203.0.113.8", 300) + app.securityGuard.Ban("203.0.113.9", 300) + + request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"bulk_unban","ips":["203.0.113.8"]}`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("bulk_unban expected 200, got %d", response.Code) + } + if app.securityGuard.IsBanned("203.0.113.8") { + t.Fatal("expected selected IP to be unbanned") + } + if !app.securityGuard.IsBanned("203.0.113.9") { + t.Fatal("expected non-selected IP to remain banned") + } + + requestAll := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"unban_all"}`)) + requestAll.Header.Set("Content-Type", "application/json") + requestAll.AddCookie(authCookie(app)) + responseAll := httptest.NewRecorder() + router.ServeHTTP(responseAll, requestAll) + if responseAll.Code != http.StatusOK { + t.Fatalf("unban_all expected 200, got %d", responseAll.Code) + } + if len(app.securityGuard.BanList()) != 0 { + t.Fatal("expected all bans to be removed") + } +} + +func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + cwd, _ := os.Getwd() + root := filepath.Clean(filepath.Join(cwd, "..", "..")) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + clearAdminSettingsEnv(t) + t.Setenv("WARPBOX_DATA_DIR", t.TempDir()) + t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret") + t.Setenv("WARPBOX_ADMIN_ENABLED", "true") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config load: %v", err) + } + if err := cfg.EnsureDirectories(); err != nil { + t.Fatalf("ensure dirs: %v", err) + } + + app := &App{ + config: cfg, + activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity.json")), + alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")), + securityGuard: security.NewGuard(), + } + app.reloadSecurityConfig() + t.Cleanup(func() { _ = app.securityGuard.Close() }) + + router := gin.New() + admin := router.Group("/admin") + admin.GET("/login", app.handleAdminLogin) + protected := router.Group("/admin", app.adminAuthMiddleware) + protected.POST("/security/actions", app.handleAdminSecurityAction) + return app, router +} diff --git a/lib/server/admin_settings_test.go b/lib/server/admin_settings_test.go index 7000880..1f0487a 100644 --- a/lib/server/admin_settings_test.go +++ b/lib/server/admin_settings_test.go @@ -265,7 +265,34 @@ func clearAdminSettingsEnv(t *testing.T) { "WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", + "WARPBOX_SECURITY_IP_WHITELIST", + "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", + "WARPBOX_TRUSTED_PROXY_CIDRS", + "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", + "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", + "WARPBOX_SECURITY_BAN_SECONDS", + "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", + "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", + "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", + "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", + "WARPBOX_SECURITY_UPLOAD_MAX_GB", + "WARPBOX_SECURITY_UPLOAD_MAX_MB", + "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", } { t.Setenv(name, "") } } + +func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) { + app, router := setupAdminSettingsTest(t) + + request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(authCookie(app)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", response.Code) + } +} diff --git a/lib/server/ip.go b/lib/server/ip.go index 606d943..31c8bae 100644 --- a/lib/server/ip.go +++ b/lib/server/ip.go @@ -6,29 +6,47 @@ import ( "strings" "github.com/gin-gonic/gin" + + "warpbox/lib/security" ) -func clientIP(ctx *gin.Context) string { +func (app *App) clientIP(ctx *gin.Context) string { if ctx == nil || ctx.Request == nil { return "" } remoteIP := remoteAddrIP(ctx.Request) - - // Only trust forwarding headers when remote hop looks like local/internal proxy. - if isPrivateOrLoopback(remoteIP) { - for _, candidate := range headerIPs(ctx.Request.Header) { - if isPublicIP(candidate) { - return candidate - } - } - candidates := headerIPs(ctx.Request.Header) - if len(candidates) > 0 { - return candidates[0] + trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs) + if err != nil { + return remoteIP + } + if !remoteIsTrusted(remoteIP, trusted) { + return remoteIP + } + for _, candidate := range headerIPs(ctx.Request.Header) { + if isPublicIP(candidate) { + return candidate } } + candidates := headerIPs(ctx.Request.Header) + if len(candidates) > 0 { + return candidates[0] + } return remoteIP } +func remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool { + ip := net.ParseIP(strings.TrimSpace(remoteIP)) + if ip == nil { + return false + } + for _, prefix := range trusted { + if prefix.Contains(ip) { + return true + } + } + return false +} + func headerIPs(header http.Header) []string { keys := []string{ "X-Forwarded-For", 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/validation.go b/lib/server/validation.go index 84aa533..48debb5 100644 --- a/lib/server/validation.go +++ b/lib/server/validation.go @@ -156,7 +156,7 @@ func (app *App) maxRequestBodyBytes() int64 { } func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool { - ip := clientIP(ctx) + ip := app.clientIP(ctx) if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { return true } diff --git a/static/css/security.css b/static/css/security.css index bc9b68d..b0981f1 100644 --- a/static/css/security.css +++ b/static/css/security.css @@ -38,6 +38,7 @@ 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; @@ -51,12 +52,9 @@ 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-bans-wrap { height: 220px; min-height: 220px; } +.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; @@ -66,19 +64,8 @@ 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-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; @@ -89,27 +76,25 @@ 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 { 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 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-ban-grid, + .security-table-toolbar { grid-template-columns: 1fr; } } diff --git a/static/js/admin/security.js b/static/js/admin/security.js index 9907093..7390d18 100644 --- a/static/js/admin/security.js +++ b/static/js/admin/security.js @@ -9,6 +9,12 @@ 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 = { @@ -17,7 +23,8 @@ threat: document.getElementById("security-detail-threat"), geo: document.getElementById("security-detail-geo"), asn: document.getElementById("security-detail-asn"), - until: document.getElementById("security-detail-until") + until: document.getElementById("security-detail-until"), + why: document.getElementById("security-detail-why") }; if (!eventsNode || !alertsNode || !bansNode) return; @@ -25,17 +32,14 @@ events: parse(eventsNode), alerts: parse(alertsNode), bans: parse(bansNode), - selectedIP: "" + selectedIP: "", + selectedIPs: new Set() }; setDefaultBanUntil(); function parse(node) { - try { - return JSON.parse(node.textContent || "[]"); - } catch (_) { - return []; - } + try { return JSON.parse(node.textContent || "[]"); } catch (_) { return []; } } function showToast(message, type = "info", duration = 1800) { @@ -107,33 +111,48 @@ }); } + 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 banMap = new Map(state.bans.map((entry) => [entry.ip, entry])); - const ips = new Set(); - state.events.forEach((event) => { - const ip = String(event.ip || "").trim(); - if (ip) ips.add(ip); - }); - state.bans.forEach((entry) => { - const ip = String(entry.ip || "").trim(); - if (ip) ips.add(ip); - }); - const rows = Array.from(ips).sort(); - rows.forEach((ip) => { - const ban = banMap.get(ip) || null; - const status = ban ? "banned" : "observed"; + const { rows } = rowData(); + rows.forEach((rowData) => { const row = document.createElement("tr"); row.className = "security-bans-body-row"; - if (ip === state.selectedIP) row.classList.add("is-selected"); + if (rowData.ip === state.selectedIP) row.classList.add("is-selected"); row.innerHTML = ` - ${escapeHtml(ip || "-")} - ${status} - ${ban ? createdLabel(ban.until) : "-"} + + ${escapeHtml(rowData.ip || "-")} + ${rowData.status} + ${createdLabel(rowData.until)} `; - row.addEventListener("click", () => setSelectedIP(ip)); + 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`; } @@ -143,18 +162,23 @@ detail.ip.textContent = "No IP selected"; detail.risk.textContent = "-"; detail.threat.textContent = "-"; - detail.geo.textContent = "Placeholder (geoipfast later)"; - detail.asn.textContent = "Placeholder"; + 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 = "Placeholder country/region lookup"; - detail.asn.textContent = "Placeholder ASN/provider lookup"; + 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())) { @@ -186,10 +210,7 @@ async function banIP() { const ip = String(ipInput.value || "").trim(); - if (!ip) { - showToast("Enter IP first", "warning"); - return; - } + if (!ip) return showToast("Enter IP first", "warning"); const payload = await postAction("ban", { ip }); setSelectedIP(ip); showToast(payload.message || "IP banned", "success"); @@ -197,15 +218,10 @@ async function banUntil() { const ip = String(ipInput.value || "").trim(); - if (!ip) { - showToast("Enter IP first", "warning"); - return; - } + if (!ip) return showToast("Enter IP first", "warning"); const banUntil = toRFC3339FromLocalUTC(banUntilInput.value); - if (!banUntil) { - showToast("Set valid expiration date", "warning"); - return; - } + 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"); @@ -213,36 +229,43 @@ async function unbanIP() { const ip = state.selectedIP || String(ipInput.value || "").trim(); - if (!ip) { - showToast("Select or enter IP first", "warning"); - return; - } + 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") { - window.location.reload(); - return; - } - if (command === "ban-ip") { - await banIP(); - return; - } - if (command === "ban-until") { - await banUntil(); - return; - } - if (command === "unban-ip") { - await unbanIP(); - return; - } + 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 { @@ -253,6 +276,30 @@ }); 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(); @@ -262,11 +309,6 @@ } }); - if (state.bans.length > 0) { - setSelectedIP(state.bans[0].ip); - } else { - const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== ""); - if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim()); - } + if (state.bans.length > 0) setSelectedIP(state.bans[0].ip); render(); })(); diff --git a/templates/admin/security.html b/templates/admin/security.html index c90b87e..98a38c9 100644 --- a/templates/admin/security.html +++ b/templates/admin/security.html @@ -37,6 +37,8 @@ + + @@ -45,7 +47,7 @@
-
Manual controlsbasic first version
+
Manual controlsadmin actions
-
Ban duration and auto-ban thresholds come from Settings -> Security.
+ + +
Ban duration, whitelist rules and trusted proxies are managed in Settings - Security.
@@ -69,28 +73,44 @@
-
IP addresses{{ len .Bans }} active bans
+
Active bans{{ len .Bans }} active bans
-
- - - - - - - - - -
IPStatusBan expires (UTC)
+
+
+ + +
+
+ + + + + + + + + + +
IPStatusBan expires (UTC)
+

No IP selected

  • Risk: -
  • Threat: -
  • -
  • Geo: Placeholder (geoipfast later)
  • -
  • ASN: Placeholder
  • +
  • Geo: GeoIP not enabled yet
  • +
  • ASN: GeoIP not enabled yet
  • Ban until: -
  • +
  • Why banned: -
  • +
  • +
  • +
@@ -116,6 +136,27 @@
+ +
+
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.

+
+