feat/security #2
19
docs/geoip-guide.md
Normal file
19
docs/geoip-guide.md
Normal 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
40
docs/security-runbook.md
Normal 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
26
go.mod
@@ -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
53
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 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=
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
52
lib/security/guard_test.go
Normal file
52
lib/security/guard_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
123
lib/server/admin_security_test.go
Normal file
123
lib/server/admin_security_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
44
lib/server/ip_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user