feat(security): Implemented more security information

This commit is contained in:
2026-05-03 22:46:54 +03:00
parent 88ab6e808b
commit 9d9db5cf0b
20 changed files with 902 additions and 193 deletions

19
docs/geoip-guide.md Normal file
View File

@@ -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.

40
docs/security-runbook.md Normal file
View File

@@ -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`

26
go.mod
View File

@@ -3,43 +3,51 @@ module warpbox
go 1.23.0 go 1.23.0
require ( require (
github.com/dgraph-io/badger/v4 v4.9.1
github.com/gin-contrib/gzip v1.0.1 github.com/gin-contrib/gzip v1.0.1
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6 github.com/spf13/pflag v1.0.6
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.41.0
) )
require ( require (
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // 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/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // 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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // 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/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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.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/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // 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/arch v0.8.0 // indirect
golang.org/x/net v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.26.0 // indirect golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

53
go.sum
View File

@@ -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 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 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= 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-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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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= 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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= 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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 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/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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= 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/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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 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= 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/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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -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: 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: 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: 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: 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: 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: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},

View File

@@ -62,6 +62,9 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil { if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err 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 != "" { if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw)) mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { 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") return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
} }
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail) 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.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db") cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir)) 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(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist) cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist) cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs)
cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10)) cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10))
cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts)) cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts))
cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10)) cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10))

View File

@@ -39,6 +39,7 @@ const (
SettingActivityRetentionSeconds = "activity_retention_seconds" SettingActivityRetentionSeconds = "activity_retention_seconds"
SettingSecurityIPWhitelist = "security_ip_whitelist" SettingSecurityIPWhitelist = "security_ip_whitelist"
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist" SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
SettingTrustedProxyCIDRs = "trusted_proxy_cidrs"
SettingSecurityLoginWindowSecs = "security_login_window_seconds" SettingSecurityLoginWindowSecs = "security_login_window_seconds"
SettingSecurityLoginMaxAttempts = "security_login_max_attempts" SettingSecurityLoginMaxAttempts = "security_login_max_attempts"
SettingSecurityBanSeconds = "security_ban_seconds" SettingSecurityBanSeconds = "security_ban_seconds"
@@ -109,6 +110,7 @@ type Config struct {
ActivityRetentionSeconds int64 ActivityRetentionSeconds int64
SecurityIPWhitelist string SecurityIPWhitelist string
SecurityAdminIPWhitelist string SecurityAdminIPWhitelist string
TrustedProxyCIDRs string
SecurityLoginWindowSeconds int64 SecurityLoginWindowSeconds int64
SecurityLoginMaxAttempts int SecurityLoginMaxAttempts int
SecurityBanSeconds int64 SecurityBanSeconds int64

View File

@@ -3,6 +3,9 @@ package config
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"warpbox/lib/security"
) )
func (cfg *Config) ApplyOverrides(overrides map[string]string) error { 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) 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 { switch def.Type {
case SettingTypeBool: case SettingTypeBool:
parsed, err := parseBool(value) parsed, err := parseBool(value)
@@ -58,6 +66,21 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
} }
return nil 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) { func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key { switch key {
case SettingGuestUploadsEnabled: case SettingGuestUploadsEnabled:
@@ -138,6 +161,8 @@ func (cfg *Config) assignText(key string, value string, source Source) {
cfg.SecurityIPWhitelist = value cfg.SecurityIPWhitelist = value
case SettingSecurityAdminIPWhitelist: case SettingSecurityAdminIPWhitelist:
cfg.SecurityAdminIPWhitelist = value cfg.SecurityAdminIPWhitelist = value
case SettingTrustedProxyCIDRs:
cfg.TrustedProxyCIDRs = value
} }
cfg.setValue(key, value, source) cfg.setValue(key, value, source)
} }

View File

@@ -1,10 +1,16 @@
package security package security
import ( import (
"encoding/binary"
"fmt"
"net"
"os"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/dgraph-io/badger/v4"
) )
type Config struct { type Config struct {
@@ -26,8 +32,14 @@ type Guard struct {
scanAttempts map[string][]time.Time scanAttempts map[string][]time.Time
uploadEvents map[string][]uploadEvent uploadEvents map[string][]uploadEvent
bannedUntil map[string]time.Time bannedUntil map[string]time.Time
ipWhitelist map[string]bool ipWhitelist []ipMatcher
adminWhitelist map[string]bool adminWhitelist []ipMatcher
banDB *badger.DB
}
type ipMatcher struct {
exact net.IP
prefix *net.IPNet
} }
type uploadEvent struct { type uploadEvent struct {
@@ -40,34 +52,90 @@ type BanEntry struct {
Until time.Time `json:"until"` Until time.Time `json:"until"`
} }
const banKeyPrefix = "ban:"
func NewGuard() *Guard { func NewGuard() *Guard {
return &Guard{ return &Guard{
failedLogins: map[string][]time.Time{}, failedLogins: map[string][]time.Time{},
scanAttempts: map[string][]time.Time{}, scanAttempts: map[string][]time.Time{},
uploadEvents: map[string][]uploadEvent{}, uploadEvents: map[string][]uploadEvent{},
bannedUntil: map[string]time.Time{}, bannedUntil: map[string]time.Time{},
ipWhitelist: map[string]bool{}, ipWhitelist: []ipMatcher{},
adminWhitelist: map[string]bool{}, adminWhitelist: []ipMatcher{},
} }
} }
func (g *Guard) Reload(cfg Config) { func (g *Guard) Close() error {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
g.ipWhitelist = parseList(cfg.IPWhitelist) if g.banDB == nil {
g.adminWhitelist = parseList(cfg.AdminIPWhitelist) 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 { func (g *Guard) IsWhitelisted(ip string) bool {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
return g.ipWhitelist[ip] return matchIP(g.ipWhitelist, ip)
} }
func (g *Guard) IsAdminWhitelisted(ip string) bool { func (g *Guard) IsAdminWhitelisted(ip string) bool {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() 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 { func (g *Guard) IsBanned(ip string) bool {
@@ -79,6 +147,7 @@ func (g *Guard) IsBanned(ip string) bool {
} }
if time.Now().UTC().After(until) { if time.Now().UTC().After(until) {
delete(g.bannedUntil, ip) delete(g.bannedUntil, ip)
g.deleteBanLocked(ip)
return false return false
} }
return true return true
@@ -90,7 +159,9 @@ func (g *Guard) Ban(ip string, seconds int64) {
} }
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() 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) { 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() g.mu.Lock()
defer g.mu.Unlock() 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) { func (g *Guard) Unban(ip string) {
@@ -109,6 +182,7 @@ func (g *Guard) Unban(ip string) {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
delete(g.bannedUntil, ip) delete(g.bannedUntil, ip)
g.deleteBanLocked(ip)
} }
func (g *Guard) BanList() []BanEntry { func (g *Guard) BanList() []BanEntry {
@@ -119,6 +193,7 @@ func (g *Guard) BanList() []BanEntry {
for ip, until := range g.bannedUntil { for ip, until := range g.bannedUntil {
if now.After(until) { if now.After(until) {
delete(g.bannedUntil, ip) delete(g.bannedUntil, ip)
g.deleteBanLocked(ip)
continue continue
} }
out = append(out, BanEntry{IP: ip, Until: until}) 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) attempts = append(attempts, now)
g.failedLogins[ip] = attempts g.failedLogins[ip] = attempts
if len(attempts) >= maxAttempts { 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 true, len(attempts)
} }
return false, len(attempts) return false, len(attempts)
@@ -159,7 +236,9 @@ func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts
attempts = append(attempts, now) attempts = append(attempts, now)
g.scanAttempts[ip] = attempts g.scanAttempts[ip] = attempts
if len(attempts) >= maxAttempts { 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 true, len(attempts)
} }
return false, 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 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 { func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
kept := make([]time.Time, 0, len(values)) kept := make([]time.Time, 0, len(values))
for _, value := range values { for _, value := range values {
@@ -205,13 +327,100 @@ func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
return kept return kept
} }
func parseList(raw string) map[string]bool { func matchIP(rules []ipMatcher, value string) bool {
out := map[string]bool{} ip := net.ParseIP(strings.TrimSpace(value))
for _, chunk := range strings.Split(raw, ",") { if ip == nil {
value := strings.TrimSpace(chunk) return false
if value != "" { }
out[value] = true 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
})
} }

View File

@@ -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")
}
}

View File

@@ -62,7 +62,7 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/") ctx.Redirect(http.StatusSeeOther, "/")
return return
} }
ip := clientIP(ctx) ip := app.clientIP(ctx)
guard := app.securityGuard guard := app.securityGuard
if guard == nil { if guard == nil {
guard = security.NewGuard() guard = security.NewGuard()

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"net" "net"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -20,16 +21,20 @@ type adminAlertsActionRequest struct {
} }
type adminSecurityActionRequest struct { type adminSecurityActionRequest struct {
Action string `json:"action"` Action string `json:"action"`
IP string `json:"ip"` IP string `json:"ip"`
BanUntil string `json:"ban_until"` IPs []string `json:"ips"`
BanUntil string `json:"ban_until"`
} }
func (app *App) reloadSecurityConfig() { func (app *App) reloadSecurityConfig() {
if app.securityGuard == nil { if app.securityGuard == nil {
app.securityGuard = security.NewGuard() 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, IPWhitelist: app.config.SecurityIPWhitelist,
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist, AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds, LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
@@ -55,7 +60,7 @@ func (app *App) logActivity(kind string, severity string, message string, ctx *g
Meta: meta, Meta: meta,
} }
if ctx != nil { if ctx != nil {
event.IP = clientIP(ctx) event.IP = app.clientIP(ctx)
event.Path = ctx.Request.URL.Path event.Path = ctx.Request.URL.Path
event.Method = ctx.Request.Method event.Method = ctx.Request.Method
} }
@@ -84,7 +89,7 @@ func (app *App) securityMiddleware() gin.HandlerFunc {
ctx.Next() ctx.Next()
return return
} }
ip := clientIP(ctx) ip := app.clientIP(ctx)
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) { if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
ctx.Next() ctx.Next()
return return
@@ -106,7 +111,7 @@ func (app *App) handleNoRoute(ctx *gin.Context) {
path := strings.ToLower(ctx.Request.URL.Path) path := strings.ToLower(ctx.Request.URL.Path)
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env") suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
if suspicious { if suspicious {
ip := clientIP(ctx) ip := app.clientIP(ctx)
if !app.securityGuard.IsWhitelisted(ip) { if !app.securityGuard.IsWhitelisted(ip) {
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds) 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)}) 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{} events := []activity.Event{}
alertsList := []alerts.Alert{} alertsList := []alerts.Alert{}
if app.activityStore != nil { if app.activityStore != nil {
events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds) events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
} }
if app.alertStore != nil { if app.alertStore != nil {
alertsList, _ = app.alertStore.List(50) alertsList, _ = app.alertStore.List(120)
} }
bans := []security.BanEntry{} bans := []security.BanEntry{}
if app.securityGuard != nil { 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}) 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) { func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
if app.securityGuard == nil { if app.securityGuard == nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"}) 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"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
return return
} }
switch request.Action { switch request.Action {
case "ban": case "ban":
if ip == "" { if ip == "" {
@@ -222,8 +237,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
return return
} }
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds) app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
app.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, 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.")
app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip})
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()}) ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
case "ban_until": case "ban_until":
if ip == "" { if ip == "" {
@@ -236,8 +250,8 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
return return
} }
app.securityGuard.BanUntil(ip, until) 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)}) meta := map[string]string{"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)}) 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()}) ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
case "unban": case "unban":
if ip == "" { if ip == "" {
@@ -245,9 +259,34 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
return return
} }
app.securityGuard.Unban(ip) app.securityGuard.Unban(ip)
app.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, 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.")
app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip})
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()}) 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: default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
} }

View File

@@ -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
}

View File

@@ -265,7 +265,34 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS", "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, "") 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)
}
}

View File

@@ -6,29 +6,47 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "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 { if ctx == nil || ctx.Request == nil {
return "" return ""
} }
remoteIP := remoteAddrIP(ctx.Request) remoteIP := remoteAddrIP(ctx.Request)
trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs)
// Only trust forwarding headers when remote hop looks like local/internal proxy. if err != nil {
if isPrivateOrLoopback(remoteIP) { return remoteIP
for _, candidate := range headerIPs(ctx.Request.Header) { }
if isPublicIP(candidate) { if !remoteIsTrusted(remoteIP, trusted) {
return candidate return remoteIP
} }
} for _, candidate := range headerIPs(ctx.Request.Header) {
candidates := headerIPs(ctx.Request.Header) if isPublicIP(candidate) {
if len(candidates) > 0 { return candidate
return candidates[0]
} }
} }
candidates := headerIPs(ctx.Request.Header)
if len(candidates) > 0 {
return candidates[0]
}
return remoteIP 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 { func headerIPs(header http.Header) []string {
keys := []string{ keys := []string{
"X-Forwarded-For", "X-Forwarded-For",

44
lib/server/ip_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -156,7 +156,7 @@ func (app *App) maxRequestBodyBytes() int64 {
} }
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool { 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) { if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
return true return true
} }

View File

@@ -38,6 +38,7 @@
font-size: 13px; font-size: 13px;
} }
.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; } .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 { .security-note {
margin-top: 8px; margin-top: 8px;
padding: 8px; padding: 8px;
@@ -51,12 +52,9 @@
line-height: 15px; line-height: 15px;
} }
.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; } .security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; }
.security-ban-grid { .security-ban-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr); gap: 10px; }
display: grid; .security-table-toolbar { display: grid; grid-template-columns: 1fr 180px; gap: 8px; margin-bottom: 8px; }
grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr); .security-bans-wrap { height: 260px; min-height: 260px; }
gap: 10px;
}
.security-bans-wrap { height: 220px; min-height: 220px; }
.security-ip-detail { .security-ip-detail {
min-height: 0; min-height: 0;
padding: 10px; padding: 10px;
@@ -66,19 +64,8 @@
border-right: 1px solid #b0b0b0; border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0; border-bottom: 1px solid #b0b0b0;
} }
.security-ip-detail h3 { .security-ip-detail h3 { margin: 0 0 8px; font-size: 16px; line-height: 16px; }
margin: 0 0 8px; .security-ip-detail ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 12px; }
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-bans-body-row.is-selected { background: #c5dcff; }
.security-table-wrap { .security-table-wrap {
min-height: 280px; min-height: 280px;
@@ -89,27 +76,25 @@
border-right: 2px solid #ffffff; border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff; border-bottom: 2px solid #ffffff;
} }
.security-table { .security-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; line-height: 14px; }
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.security-table th, .security-table th,
.security-table td { .security-table td { padding: 6px; border-bottom: 1px solid #e1e1e1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
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-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) { @media (max-width: 980px) {
.security-grid, .security-grid,
.security-ban-grid { .security-ban-grid,
.security-table-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -9,6 +9,12 @@
const activityBody = document.getElementById("security-activity-body"); const activityBody = document.getElementById("security-activity-body");
const bansBody = document.getElementById("security-bans-body"); const bansBody = document.getElementById("security-bans-body");
const bansCount = document.getElementById("security-bans-count"); 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 toast = document.getElementById("toast");
const detail = { const detail = {
@@ -17,7 +23,8 @@
threat: document.getElementById("security-detail-threat"), threat: document.getElementById("security-detail-threat"),
geo: document.getElementById("security-detail-geo"), geo: document.getElementById("security-detail-geo"),
asn: document.getElementById("security-detail-asn"), 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; if (!eventsNode || !alertsNode || !bansNode) return;
@@ -25,17 +32,14 @@
events: parse(eventsNode), events: parse(eventsNode),
alerts: parse(alertsNode), alerts: parse(alertsNode),
bans: parse(bansNode), bans: parse(bansNode),
selectedIP: "" selectedIP: "",
selectedIPs: new Set()
}; };
setDefaultBanUntil(); setDefaultBanUntil();
function parse(node) { function parse(node) {
try { try { return JSON.parse(node.textContent || "[]"); } catch (_) { return []; }
return JSON.parse(node.textContent || "[]");
} catch (_) {
return [];
}
} }
function showToast(message, type = "info", duration = 1800) { 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() { function renderBans() {
bansBody.innerHTML = ""; bansBody.innerHTML = "";
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry])); const { rows } = rowData();
const ips = new Set(); rows.forEach((rowData) => {
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 row = document.createElement("tr"); const row = document.createElement("tr");
row.className = "security-bans-body-row"; 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 = ` row.innerHTML = `
<td>${escapeHtml(ip || "-")}</td> <td><input type="checkbox" class="security-row-select" data-ip="${escapeHtml(rowData.ip)}" ${state.selectedIPs.has(rowData.ip) ? "checked" : ""}></td>
<td>${status}</td> <td>${escapeHtml(rowData.ip || "-")}</td>
<td>${ban ? createdLabel(ban.until) : "-"}</td> <td>${rowData.status}</td>
<td>${createdLabel(rowData.until)}</td>
`; `;
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.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`; bansCount.textContent = `${state.bans.length} active bans`;
} }
@@ -143,18 +162,23 @@
detail.ip.textContent = "No IP selected"; detail.ip.textContent = "No IP selected";
detail.risk.textContent = "-"; detail.risk.textContent = "-";
detail.threat.textContent = "-"; detail.threat.textContent = "-";
detail.geo.textContent = "Placeholder (geoipfast later)"; detail.geo.textContent = "GeoIP not enabled yet";
detail.asn.textContent = "Placeholder"; detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = "-"; detail.until.textContent = "-";
detail.why.textContent = "-";
return; return;
} }
const ban = state.bans.find((entry) => entry.ip === ip); 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.ip.textContent = ip;
detail.risk.textContent = ban ? "high" : "medium"; detail.risk.textContent = ban ? "high" : "medium";
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source"; detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
detail.geo.textContent = "Placeholder country/region lookup"; detail.geo.textContent = "GeoIP not enabled yet";
detail.asn.textContent = "Placeholder ASN/provider lookup"; detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned"; 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) { if (ban && ban.until) {
const parsed = new Date(ban.until); const parsed = new Date(ban.until);
if (!Number.isNaN(parsed.getTime())) { if (!Number.isNaN(parsed.getTime())) {
@@ -186,10 +210,7 @@
async function banIP() { async function banIP() {
const ip = String(ipInput.value || "").trim(); const ip = String(ipInput.value || "").trim();
if (!ip) { if (!ip) return showToast("Enter IP first", "warning");
showToast("Enter IP first", "warning");
return;
}
const payload = await postAction("ban", { ip }); const payload = await postAction("ban", { ip });
setSelectedIP(ip); setSelectedIP(ip);
showToast(payload.message || "IP banned", "success"); showToast(payload.message || "IP banned", "success");
@@ -197,15 +218,10 @@
async function banUntil() { async function banUntil() {
const ip = String(ipInput.value || "").trim(); const ip = String(ipInput.value || "").trim();
if (!ip) { if (!ip) return showToast("Enter IP first", "warning");
showToast("Enter IP first", "warning");
return;
}
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value); const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
if (!banUntil) { if (!banUntil) return showToast("Set valid expiration date", "warning");
showToast("Set valid expiration date", "warning"); if (!window.confirm("Apply custom ban expiration?")) return;
return;
}
const payload = await postAction("ban_until", { ip, ban_until: banUntil }); const payload = await postAction("ban_until", { ip, ban_until: banUntil });
setSelectedIP(ip); setSelectedIP(ip);
showToast(payload.message || "IP ban expiration updated", "success"); showToast(payload.message || "IP ban expiration updated", "success");
@@ -213,36 +229,43 @@
async function unbanIP() { async function unbanIP() {
const ip = state.selectedIP || String(ipInput.value || "").trim(); const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) { if (!ip) return showToast("Select or enter IP first", "warning");
showToast("Select or enter IP first", "warning"); if (!window.confirm(`Unban ${ip}?`)) return;
return;
}
const payload = await postAction("unban", { ip }); const payload = await postAction("unban", { ip });
state.selectedIPs.delete(ip);
setSelectedIP(""); setSelectedIP("");
showToast(payload.message || "IP unbanned", "success"); 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) => { document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
menuController.close(); menuController.close();
try { try {
const command = button.dataset.command; const command = button.dataset.command;
if (command === "refresh") { if (command === "refresh") return window.location.reload();
window.location.reload(); if (command === "ban-ip") return await banIP();
return; if (command === "ban-until") return await banUntil();
} if (command === "unban-ip") return await unbanIP();
if (command === "ban-ip") { if (command === "bulk-unban") return await bulkUnban();
await banIP(); if (command === "unban-all") return await unbanAll();
return;
}
if (command === "ban-until") {
await banUntil();
return;
}
if (command === "unban-ip") {
await unbanIP();
return;
}
} catch (error) { } catch (error) {
showToast(error.message, "error", 3200); showToast(error.message, "error", 3200);
} finally { } finally {
@@ -253,6 +276,30 @@
}); });
ipInput.addEventListener("input", () => 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) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close(); if (event.key === "Escape") menuController.close();
@@ -262,11 +309,6 @@
} }
}); });
if (state.bans.length > 0) { if (state.bans.length > 0) setSelectedIP(state.bans[0].ip);
setSelectedIP(state.bans[0].ip);
} else {
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
}
render(); render();
})(); })();

View File

@@ -37,6 +37,8 @@
<button class="menu-action" type="button" data-command="ban-ip"><span>B</span><span>Ban IP now</span><span></span></button> <button class="menu-action" type="button" data-command="ban-ip"><span>B</span><span>Ban IP now</span><span></span></button>
<button class="menu-action" type="button" data-command="ban-until"><span>T</span><span>Set ban expiration</span><span></span></button> <button class="menu-action" type="button" data-command="ban-until"><span>T</span><span>Set ban expiration</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-ip"><span>U</span><span>Unban selected IP</span><span></span></button> <button class="menu-action" type="button" data-command="unban-ip"><span>U</span><span>Unban selected IP</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-unban"><span>K</span><span>Bulk unban selected</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-all"><span>A</span><span>Unban all</span><span></span></button>
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh data</span><span>F5</span></button> <button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh data</span><span>F5</span></button>
</div> </div>
</div> </div>
@@ -45,7 +47,7 @@
<div class="admin-workspace-body security-page-body"> <div class="admin-workspace-body security-page-body">
<section class="security-grid"> <section class="security-grid">
<section class="security-panel"> <section class="security-panel">
<div class="security-panel-header"><strong>Manual controls</strong><span>basic first version</span></div> <div class="security-panel-header"><strong>Manual controls</strong><span>admin actions</span></div>
<div class="security-panel-body"> <div class="security-panel-body">
<label class="security-field">IP address <label class="security-field">IP address
<input class="security-input" id="security-ip-input" type="text" placeholder="203.0.113.12"> <input class="security-input" id="security-ip-input" type="text" placeholder="203.0.113.12">
@@ -56,7 +58,9 @@
</label> </label>
<button class="win98-button security-button" type="button" data-command="ban-until">Set ban expiration</button> <button class="win98-button security-button" type="button" data-command="ban-until">Set ban expiration</button>
<button class="win98-button security-button" type="button" data-command="unban-ip">Unban selected IP</button> <button class="win98-button security-button" type="button" data-command="unban-ip">Unban selected IP</button>
<div class="security-note">Ban duration and auto-ban thresholds come from Settings -> Security.</div> <button class="win98-button security-button" type="button" data-command="bulk-unban">Bulk unban selected</button>
<button class="win98-button security-button security-danger" type="button" data-command="unban-all">Unban all</button>
<div class="security-note">Ban duration, whitelist rules and trusted proxies are managed in Settings - Security.</div>
</div> </div>
</section> </section>
@@ -69,28 +73,44 @@
</section> </section>
<section class="security-panel"> <section class="security-panel">
<div class="security-panel-header"><strong>IP addresses</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div> <div class="security-panel-header"><strong>Active bans</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div>
<div class="security-panel-body security-ban-grid"> <div class="security-panel-body security-ban-grid">
<div class="security-table-wrap security-bans-wrap"> <div>
<table class="security-table"> <div class="security-table-toolbar">
<thead> <input id="security-ban-filter" class="security-input" type="text" placeholder="Filter by IP">
<tr> <select id="security-ban-sort" class="security-input">
<th>IP</th> <option value="expiry_asc">Expiry ↑</option>
<th>Status</th> <option value="expiry_desc">Expiry ↓</option>
<th>Ban expires (UTC)</th> <option value="ip_asc">IP A-Z</option>
</tr> <option value="ip_desc">IP Z-A</option>
</thead> </select>
<tbody id="security-bans-body"></tbody> </div>
</table> <div class="security-table-wrap security-bans-wrap">
<table class="security-table">
<thead>
<tr>
<th><input id="security-select-all" type="checkbox" aria-label="Select all"></th>
<th>IP</th>
<th>Status</th>
<th>Ban expires (UTC)</th>
</tr>
</thead>
<tbody id="security-bans-body"></tbody>
</table>
</div>
</div> </div>
<div class="security-ip-detail"> <div class="security-ip-detail">
<h3 id="security-detail-ip">No IP selected</h3> <h3 id="security-detail-ip">No IP selected</h3>
<ul> <ul>
<li><strong>Risk:</strong> <span id="security-detail-risk">-</span></li> <li><strong>Risk:</strong> <span id="security-detail-risk">-</span></li>
<li><strong>Threat:</strong> <span id="security-detail-threat">-</span></li> <li><strong>Threat:</strong> <span id="security-detail-threat">-</span></li>
<li><strong>Geo:</strong> <span id="security-detail-geo">Placeholder (geoipfast later)</span></li> <li><strong>Geo:</strong> <span id="security-detail-geo">GeoIP not enabled yet</span></li>
<li><strong>ASN:</strong> <span id="security-detail-asn">Placeholder</span></li> <li><strong>ASN:</strong> <span id="security-detail-asn">GeoIP not enabled yet</span></li>
<li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li> <li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li>
<li><strong>Why banned:</strong> <span id="security-detail-why">-</span></li>
<li><button id="security-copy-ip" class="win98-button security-button" type="button">Copy IP</button></li>
<li><button id="security-open-activity" class="win98-button security-button" type="button">Search in activity</button></li>
<li><button id="security-open-alerts" class="win98-button security-button" type="button">Search in alerts</button></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -116,6 +136,27 @@
</div> </div>
</div> </div>
</section> </section>
<section class="security-panel">
<div class="security-panel-header"><strong>Security Runbook</strong><span>ops quick reference</span></div>
<div class="security-panel-body security-docs">
<h4>Reverse Proxy and Trusted CIDRs</h4>
<p>Set <code>WARPBOX_TRUSTED_PROXY_CIDRS</code> to the CIDRs of your proxy nodes only. WarpBox will trust forwarding headers only when the direct remote IP is in this list.</p>
<pre>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}
}
}</pre>
<h4>Ban / Unban Safety</h4>
<p>Use custom ban durations only for active incidents. Prefer temporary bans. Review the "why banned" detail before unbanning to avoid immediate re-abuse.</p>
<h4>Tuning Guidance</h4>
<p>Low traffic: lower <code>security_*_max_attempts</code>. High traffic: increase windows and attempt thresholds gradually, then monitor alerts/activity for false positives.</p>
<h4>GeoIP Guide (planned)</h4>
<p>For <code>geoip2fast</code>, 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.</p>
</div>
</section>
</div> </div>
<footer class="status-bar admin-dashboard-statusbar"> <footer class="status-bar admin-dashboard-statusbar">