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
|
||||
|
||||
require (
|
||||
github.com/dgraph-io/badger/v4 v4.9.1
|
||||
github.com/gin-contrib/gzip v1.0.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
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/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w=
|
||||
github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
||||
@@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -29,6 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -36,15 +52,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -58,10 +73,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -86,21 +99,29 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -23,6 +23,7 @@ var Definitions = []SettingDefinition{
|
||||
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||
{Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
{Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
|
||||
@@ -62,6 +62,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||
@@ -162,6 +165,15 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||
}
|
||||
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||
if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||
@@ -199,6 +211,7 @@ func (cfg *Config) captureDefaults() {
|
||||
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs)
|
||||
cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts))
|
||||
cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10))
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
SettingActivityRetentionSeconds = "activity_retention_seconds"
|
||||
SettingSecurityIPWhitelist = "security_ip_whitelist"
|
||||
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
|
||||
SettingTrustedProxyCIDRs = "trusted_proxy_cidrs"
|
||||
SettingSecurityLoginWindowSecs = "security_login_window_seconds"
|
||||
SettingSecurityLoginMaxAttempts = "security_login_max_attempts"
|
||||
SettingSecurityBanSeconds = "security_ban_seconds"
|
||||
@@ -109,6 +110,7 @@ type Config struct {
|
||||
ActivityRetentionSeconds int64
|
||||
SecurityIPWhitelist string
|
||||
SecurityAdminIPWhitelist string
|
||||
TrustedProxyCIDRs string
|
||||
SecurityLoginWindowSeconds int64
|
||||
SecurityLoginMaxAttempts int
|
||||
SecurityBanSeconds int64
|
||||
|
||||
@@ -3,6 +3,9 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||
@@ -26,6 +29,11 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
if err := validateSecurityTextSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch def.Type {
|
||||
case SettingTypeBool:
|
||||
parsed, err := parseBool(value)
|
||||
@@ -58,6 +66,21 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityTextSetting(key string, value string) error {
|
||||
switch key {
|
||||
case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist:
|
||||
if _, err := security.ParseIPMatchers(value, true); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
case SettingTrustedProxyCIDRs:
|
||||
if _, err := security.ParseCIDRList(value); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||
switch key {
|
||||
case SettingGuestUploadsEnabled:
|
||||
@@ -138,6 +161,8 @@ func (cfg *Config) assignText(key string, value string, source Source) {
|
||||
cfg.SecurityIPWhitelist = value
|
||||
case SettingSecurityAdminIPWhitelist:
|
||||
cfg.SecurityAdminIPWhitelist = value
|
||||
case SettingTrustedProxyCIDRs:
|
||||
cfg.TrustedProxyCIDRs = value
|
||||
}
|
||||
cfg.setValue(key, value, source)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -26,8 +32,14 @@ type Guard struct {
|
||||
scanAttempts map[string][]time.Time
|
||||
uploadEvents map[string][]uploadEvent
|
||||
bannedUntil map[string]time.Time
|
||||
ipWhitelist map[string]bool
|
||||
adminWhitelist map[string]bool
|
||||
ipWhitelist []ipMatcher
|
||||
adminWhitelist []ipMatcher
|
||||
banDB *badger.DB
|
||||
}
|
||||
|
||||
type ipMatcher struct {
|
||||
exact net.IP
|
||||
prefix *net.IPNet
|
||||
}
|
||||
|
||||
type uploadEvent struct {
|
||||
@@ -40,34 +52,90 @@ type BanEntry struct {
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
const banKeyPrefix = "ban:"
|
||||
|
||||
func NewGuard() *Guard {
|
||||
return &Guard{
|
||||
failedLogins: map[string][]time.Time{},
|
||||
scanAttempts: map[string][]time.Time{},
|
||||
uploadEvents: map[string][]uploadEvent{},
|
||||
bannedUntil: map[string]time.Time{},
|
||||
ipWhitelist: map[string]bool{},
|
||||
adminWhitelist: map[string]bool{},
|
||||
ipWhitelist: []ipMatcher{},
|
||||
adminWhitelist: []ipMatcher{},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) Reload(cfg Config) {
|
||||
func (g *Guard) Close() error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.ipWhitelist = parseList(cfg.IPWhitelist)
|
||||
g.adminWhitelist = parseList(cfg.AdminIPWhitelist)
|
||||
if g.banDB == nil {
|
||||
return nil
|
||||
}
|
||||
err := g.banDB.Close()
|
||||
g.banDB = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *Guard) EnableBanPersistence(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.banDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := badger.DefaultOptions(path)
|
||||
opts.Logger = nil
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
// Corruption-safe fallback: quarantine badger files and start fresh.
|
||||
_ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405"))
|
||||
db, err = badger.Open(opts)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.banDB = db
|
||||
|
||||
if err := g.loadBansLocked(); err != nil {
|
||||
_ = g.banDB.Close()
|
||||
g.banDB = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guard) Reload(cfg Config) error {
|
||||
ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip whitelist: %w", err)
|
||||
}
|
||||
adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin ip whitelist: %w", err)
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.ipWhitelist = ipWhitelist
|
||||
g.adminWhitelist = adminWhitelist
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guard) IsWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.ipWhitelist[ip]
|
||||
return matchIP(g.ipWhitelist, ip)
|
||||
}
|
||||
|
||||
func (g *Guard) IsAdminWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.adminWhitelist[ip] || g.ipWhitelist[ip]
|
||||
return matchIP(g.adminWhitelist, ip) || matchIP(g.ipWhitelist, ip)
|
||||
}
|
||||
|
||||
func (g *Guard) IsBanned(ip string) bool {
|
||||
@@ -79,6 +147,7 @@ func (g *Guard) IsBanned(ip string) bool {
|
||||
}
|
||||
if time.Now().UTC().After(until) {
|
||||
delete(g.bannedUntil, ip)
|
||||
g.deleteBanLocked(ip)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -90,7 +159,9 @@ func (g *Guard) Ban(ip string, seconds int64) {
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
until := time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
}
|
||||
|
||||
func (g *Guard) BanUntil(ip string, until time.Time) {
|
||||
@@ -99,7 +170,9 @@ func (g *Guard) BanUntil(ip string, until time.Time) {
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.bannedUntil[ip] = until.UTC()
|
||||
until = until.UTC()
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
}
|
||||
|
||||
func (g *Guard) Unban(ip string) {
|
||||
@@ -109,6 +182,7 @@ func (g *Guard) Unban(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.bannedUntil, ip)
|
||||
g.deleteBanLocked(ip)
|
||||
}
|
||||
|
||||
func (g *Guard) BanList() []BanEntry {
|
||||
@@ -119,6 +193,7 @@ func (g *Guard) BanList() []BanEntry {
|
||||
for ip, until := range g.bannedUntil {
|
||||
if now.After(until) {
|
||||
delete(g.bannedUntil, ip)
|
||||
g.deleteBanLocked(ip)
|
||||
continue
|
||||
}
|
||||
out = append(out, BanEntry{IP: ip, Until: until})
|
||||
@@ -141,7 +216,9 @@ func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts
|
||||
attempts = append(attempts, now)
|
||||
g.failedLogins[ip] = attempts
|
||||
if len(attempts) >= maxAttempts {
|
||||
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
|
||||
until := now.Add(time.Duration(banSeconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
return true, len(attempts)
|
||||
}
|
||||
return false, len(attempts)
|
||||
@@ -159,7 +236,9 @@ func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts
|
||||
attempts = append(attempts, now)
|
||||
g.scanAttempts[ip] = attempts
|
||||
if len(attempts) >= maxAttempts {
|
||||
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
|
||||
until := now.Add(time.Duration(banSeconds) * time.Second)
|
||||
g.bannedUntil[ip] = until
|
||||
g.saveBanLocked(ip, until)
|
||||
return true, len(attempts)
|
||||
}
|
||||
return false, len(attempts)
|
||||
@@ -195,6 +274,49 @@ func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxReque
|
||||
return true, nextCount, nextBytes
|
||||
}
|
||||
|
||||
func ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) {
|
||||
entries := []ipMatcher{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
if !allowCIDR {
|
||||
return nil, fmt.Errorf("%q must be a CIDR", value)
|
||||
}
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q", value)
|
||||
}
|
||||
entries = append(entries, ipMatcher{prefix: network})
|
||||
continue
|
||||
}
|
||||
parsed := net.ParseIP(value)
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("invalid IP %q", value)
|
||||
}
|
||||
entries = append(entries, ipMatcher{exact: parsed})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func ParseCIDRList(raw string) ([]net.IPNet, error) {
|
||||
entries := []net.IPNet{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q", value)
|
||||
}
|
||||
entries = append(entries, *network)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
|
||||
kept := make([]time.Time, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -205,13 +327,100 @@ func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
|
||||
return kept
|
||||
}
|
||||
|
||||
func parseList(raw string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
func matchIP(rules []ipMatcher, value string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(value))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.exact != nil && rule.exact.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
if rule.prefix != nil && rule.prefix.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return out
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Guard) saveBanLocked(ip string, until time.Time) {
|
||||
if g.banDB == nil || ip == "" || until.IsZero() {
|
||||
return
|
||||
}
|
||||
seconds := int64(time.Until(until).Seconds())
|
||||
if seconds <= 0 {
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete([]byte(banKeyPrefix + ip))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
value := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(value, uint64(until.Unix()))
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second)
|
||||
return txn.SetEntry(entry)
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Guard) deleteBanLocked(ip string) {
|
||||
if g.banDB == nil || ip == "" {
|
||||
return
|
||||
}
|
||||
_ = g.banDB.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete([]byte(banKeyPrefix + ip))
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Guard) loadBansLocked() error {
|
||||
if g.banDB == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
loaded := map[string]time.Time{}
|
||||
expired := [][]byte{}
|
||||
|
||||
err := g.banDB.View(func(txn *badger.Txn) error {
|
||||
it := txn.NewIterator(badger.DefaultIteratorOptions)
|
||||
defer it.Close()
|
||||
for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() {
|
||||
item := it.Item()
|
||||
key := string(item.Key())
|
||||
ip := strings.TrimPrefix(key, banKeyPrefix)
|
||||
err := item.Value(func(val []byte) error {
|
||||
if len(val) != 8 {
|
||||
expired = append(expired, append([]byte(nil), item.Key()...))
|
||||
return nil
|
||||
}
|
||||
unix := int64(binary.BigEndian.Uint64(val))
|
||||
until := time.Unix(unix, 0).UTC()
|
||||
if now.After(until) {
|
||||
expired = append(expired, append([]byte(nil), item.Key()...))
|
||||
return nil
|
||||
}
|
||||
loaded[ip] = until
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.bannedUntil = loaded
|
||||
if len(expired) == 0 {
|
||||
return nil
|
||||
}
|
||||
return g.banDB.Update(func(txn *badger.Txn) error {
|
||||
for _, key := range expired {
|
||||
if err := txn.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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, "/")
|
||||
return
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
ip := app.clientIP(ctx)
|
||||
guard := app.securityGuard
|
||||
if guard == nil {
|
||||
guard = security.NewGuard()
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,6 +23,7 @@ type adminAlertsActionRequest struct {
|
||||
type adminSecurityActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
IP string `json:"ip"`
|
||||
IPs []string `json:"ips"`
|
||||
BanUntil string `json:"ban_until"`
|
||||
}
|
||||
|
||||
@@ -29,7 +31,10 @@ func (app *App) reloadSecurityConfig() {
|
||||
if app.securityGuard == nil {
|
||||
app.securityGuard = security.NewGuard()
|
||||
}
|
||||
app.securityGuard.Reload(security.Config{
|
||||
if app.config != nil {
|
||||
_ = app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger"))
|
||||
}
|
||||
_ = app.securityGuard.Reload(security.Config{
|
||||
IPWhitelist: app.config.SecurityIPWhitelist,
|
||||
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
|
||||
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
|
||||
@@ -55,7 +60,7 @@ func (app *App) logActivity(kind string, severity string, message string, ctx *g
|
||||
Meta: meta,
|
||||
}
|
||||
if ctx != nil {
|
||||
event.IP = clientIP(ctx)
|
||||
event.IP = app.clientIP(ctx)
|
||||
event.Path = ctx.Request.URL.Path
|
||||
event.Method = ctx.Request.Method
|
||||
}
|
||||
@@ -84,7 +89,7 @@ func (app *App) securityMiddleware() gin.HandlerFunc {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
ip := app.clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
ctx.Next()
|
||||
return
|
||||
@@ -106,7 +111,7 @@ func (app *App) handleNoRoute(ctx *gin.Context) {
|
||||
path := strings.ToLower(ctx.Request.URL.Path)
|
||||
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
|
||||
if suspicious {
|
||||
ip := clientIP(ctx)
|
||||
ip := app.clientIP(ctx)
|
||||
if !app.securityGuard.IsWhitelisted(ip) {
|
||||
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)})
|
||||
@@ -146,10 +151,10 @@ func (app *App) handleAdminSecurity(ctx *gin.Context) {
|
||||
events := []activity.Event{}
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.activityStore != nil {
|
||||
events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds)
|
||||
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
if app.alertStore != nil {
|
||||
alertsList, _ = app.alertStore.List(50)
|
||||
alertsList, _ = app.alertStore.List(120)
|
||||
}
|
||||
bans := []security.BanEntry{}
|
||||
if app.securityGuard != nil {
|
||||
@@ -200,6 +205,15 @@ func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList})
|
||||
}
|
||||
|
||||
func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) {
|
||||
metaCopy := map[string]string{"ip": ip}
|
||||
for k, v := range meta {
|
||||
metaCopy[k] = v
|
||||
}
|
||||
app.logActivity(kind, severity, message, ctx, metaCopy)
|
||||
app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy)
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
||||
@@ -215,6 +229,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
|
||||
return
|
||||
}
|
||||
|
||||
switch request.Action {
|
||||
case "ban":
|
||||
if ip == "" {
|
||||
@@ -222,8 +237,7 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip})
|
||||
app.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.")
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
|
||||
case "ban_until":
|
||||
if ip == "" {
|
||||
@@ -236,8 +250,8 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
app.securityGuard.BanUntil(ip, until)
|
||||
app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
meta := map[string]string{"until": until.UTC().Format(time.RFC3339)}
|
||||
app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.")
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
|
||||
case "unban":
|
||||
if ip == "" {
|
||||
@@ -245,9 +259,34 @@ func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
app.securityGuard.Unban(ip)
|
||||
app.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip})
|
||||
app.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.")
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
||||
case "bulk_unban":
|
||||
if len(request.IPs) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"})
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, candidate := range request.IPs {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if net.ParseIP(candidate) == nil {
|
||||
continue
|
||||
}
|
||||
app.securityGuard.Unban(candidate)
|
||||
count++
|
||||
}
|
||||
app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)})
|
||||
app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()})
|
||||
case "unban_all":
|
||||
current := app.securityGuard.BanList()
|
||||
for _, ban := range current {
|
||||
app.securityGuard.Unban(ban.IP)
|
||||
}
|
||||
count := len(current)
|
||||
app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)})
|
||||
app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()})
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
}
|
||||
|
||||
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_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
"WARPBOX_SECURITY_IP_WHITELIST",
|
||||
"WARPBOX_SECURITY_ADMIN_IP_WHITELIST",
|
||||
"WARPBOX_TRUSTED_PROXY_CIDRS",
|
||||
"WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS",
|
||||
"WARPBOX_SECURITY_BAN_SECONDS",
|
||||
"WARPBOX_SECURITY_SCAN_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS",
|
||||
"WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_GB",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_MB",
|
||||
"WARPBOX_SECURITY_UPLOAD_MAX_BYTES",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,22 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
func clientIP(ctx *gin.Context) string {
|
||||
func (app *App) clientIP(ctx *gin.Context) string {
|
||||
if ctx == nil || ctx.Request == nil {
|
||||
return ""
|
||||
}
|
||||
remoteIP := remoteAddrIP(ctx.Request)
|
||||
|
||||
// Only trust forwarding headers when remote hop looks like local/internal proxy.
|
||||
if isPrivateOrLoopback(remoteIP) {
|
||||
trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs)
|
||||
if err != nil {
|
||||
return remoteIP
|
||||
}
|
||||
if !remoteIsTrusted(remoteIP, trusted) {
|
||||
return remoteIP
|
||||
}
|
||||
for _, candidate := range headerIPs(ctx.Request.Header) {
|
||||
if isPublicIP(candidate) {
|
||||
return candidate
|
||||
@@ -25,10 +31,22 @@ func clientIP(ctx *gin.Context) string {
|
||||
if len(candidates) > 0 {
|
||||
return candidates[0]
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(remoteIP))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func headerIPs(header http.Header) []string {
|
||||
keys := []string{
|
||||
"X-Forwarded-For",
|
||||
|
||||
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 {
|
||||
ip := clientIP(ctx)
|
||||
ip := app.clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; }
|
||||
.security-danger { color: #7a0000; }
|
||||
.security-note {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
@@ -51,12 +52,9 @@
|
||||
line-height: 15px;
|
||||
}
|
||||
.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; }
|
||||
.security-ban-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.security-bans-wrap { height: 220px; min-height: 220px; }
|
||||
.security-ban-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr); gap: 10px; }
|
||||
.security-table-toolbar { display: grid; grid-template-columns: 1fr 180px; gap: 8px; margin-bottom: 8px; }
|
||||
.security-bans-wrap { height: 260px; min-height: 260px; }
|
||||
.security-ip-detail {
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
@@ -66,19 +64,8 @@
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
}
|
||||
.security-ip-detail h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.security-ip-detail ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.security-ip-detail h3 { margin: 0 0 8px; font-size: 16px; line-height: 16px; }
|
||||
.security-ip-detail ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 12px; }
|
||||
.security-bans-body-row.is-selected { background: #c5dcff; }
|
||||
.security-table-wrap {
|
||||
min-height: 280px;
|
||||
@@ -89,27 +76,25 @@
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
.security-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.security-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; line-height: 14px; }
|
||||
.security-table th,
|
||||
.security-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.security-table td { padding: 6px; border-bottom: 1px solid #e1e1e1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.security-table th { position: sticky; top: 0; background: #dfdfdf; z-index: 2; }
|
||||
.security-docs h4 { margin: 4px 0; font-size: 13px; }
|
||||
.security-docs p { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
|
||||
.security-docs pre {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px;
|
||||
background: #f2f2f2;
|
||||
border: 1px solid #c0c0c0;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.security-grid,
|
||||
.security-ban-grid {
|
||||
.security-ban-grid,
|
||||
.security-table-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
const activityBody = document.getElementById("security-activity-body");
|
||||
const bansBody = document.getElementById("security-bans-body");
|
||||
const bansCount = document.getElementById("security-bans-count");
|
||||
const filterInput = document.getElementById("security-ban-filter");
|
||||
const sortSelect = document.getElementById("security-ban-sort");
|
||||
const selectAll = document.getElementById("security-select-all");
|
||||
const copyIPButton = document.getElementById("security-copy-ip");
|
||||
const openActivityButton = document.getElementById("security-open-activity");
|
||||
const openAlertsButton = document.getElementById("security-open-alerts");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
const detail = {
|
||||
@@ -17,7 +23,8 @@
|
||||
threat: document.getElementById("security-detail-threat"),
|
||||
geo: document.getElementById("security-detail-geo"),
|
||||
asn: document.getElementById("security-detail-asn"),
|
||||
until: document.getElementById("security-detail-until")
|
||||
until: document.getElementById("security-detail-until"),
|
||||
why: document.getElementById("security-detail-why")
|
||||
};
|
||||
|
||||
if (!eventsNode || !alertsNode || !bansNode) return;
|
||||
@@ -25,17 +32,14 @@
|
||||
events: parse(eventsNode),
|
||||
alerts: parse(alertsNode),
|
||||
bans: parse(bansNode),
|
||||
selectedIP: ""
|
||||
selectedIP: "",
|
||||
selectedIPs: new Set()
|
||||
};
|
||||
|
||||
setDefaultBanUntil();
|
||||
|
||||
function parse(node) {
|
||||
try {
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
try { return JSON.parse(node.textContent || "[]"); } catch (_) { return []; }
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
@@ -107,33 +111,48 @@
|
||||
});
|
||||
}
|
||||
|
||||
function rowData() {
|
||||
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
|
||||
const filter = String(filterInput?.value || "").trim().toLowerCase();
|
||||
let rows = state.bans.map((entry) => ({ ip: entry.ip, status: "banned", until: entry.until, ban: entry }));
|
||||
if (filter) rows = rows.filter((row) => row.ip.toLowerCase().includes(filter));
|
||||
const sort = sortSelect?.value || "expiry_asc";
|
||||
rows.sort((a, b) => {
|
||||
if (sort === "ip_asc") return a.ip.localeCompare(b.ip);
|
||||
if (sort === "ip_desc") return b.ip.localeCompare(a.ip);
|
||||
const av = new Date(a.until).getTime();
|
||||
const bv = new Date(b.until).getTime();
|
||||
return sort === "expiry_desc" ? bv - av : av - bv;
|
||||
});
|
||||
return { rows, banMap };
|
||||
}
|
||||
|
||||
function renderBans() {
|
||||
bansBody.innerHTML = "";
|
||||
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
|
||||
const ips = new Set();
|
||||
state.events.forEach((event) => {
|
||||
const ip = String(event.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
state.bans.forEach((entry) => {
|
||||
const ip = String(entry.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
const rows = Array.from(ips).sort();
|
||||
rows.forEach((ip) => {
|
||||
const ban = banMap.get(ip) || null;
|
||||
const status = ban ? "banned" : "observed";
|
||||
const { rows } = rowData();
|
||||
rows.forEach((rowData) => {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "security-bans-body-row";
|
||||
if (ip === state.selectedIP) row.classList.add("is-selected");
|
||||
if (rowData.ip === state.selectedIP) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(ip || "-")}</td>
|
||||
<td>${status}</td>
|
||||
<td>${ban ? createdLabel(ban.until) : "-"}</td>
|
||||
<td><input type="checkbox" class="security-row-select" data-ip="${escapeHtml(rowData.ip)}" ${state.selectedIPs.has(rowData.ip) ? "checked" : ""}></td>
|
||||
<td>${escapeHtml(rowData.ip || "-")}</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.querySelectorAll(".security-row-select").forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
const ip = checkbox.getAttribute("data-ip");
|
||||
if (!ip) return;
|
||||
if (checkbox.checked) state.selectedIPs.add(ip); else state.selectedIPs.delete(ip);
|
||||
});
|
||||
});
|
||||
bansCount.textContent = `${state.bans.length} active bans`;
|
||||
}
|
||||
|
||||
@@ -143,18 +162,23 @@
|
||||
detail.ip.textContent = "No IP selected";
|
||||
detail.risk.textContent = "-";
|
||||
detail.threat.textContent = "-";
|
||||
detail.geo.textContent = "Placeholder (geoipfast later)";
|
||||
detail.asn.textContent = "Placeholder";
|
||||
detail.geo.textContent = "GeoIP not enabled yet";
|
||||
detail.asn.textContent = "GeoIP not enabled yet";
|
||||
detail.until.textContent = "-";
|
||||
detail.why.textContent = "-";
|
||||
return;
|
||||
}
|
||||
const ban = state.bans.find((entry) => entry.ip === ip);
|
||||
const matchingEvents = state.events.filter((event) => String(event.ip || "") === ip);
|
||||
const matchingAlerts = state.alerts.filter((alert) => String(alert?.meta?.ip || "") === ip);
|
||||
const lastEvent = matchingEvents[0] || null;
|
||||
detail.ip.textContent = ip;
|
||||
detail.risk.textContent = ban ? "high" : "medium";
|
||||
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
|
||||
detail.geo.textContent = "Placeholder country/region lookup";
|
||||
detail.asn.textContent = "Placeholder ASN/provider lookup";
|
||||
detail.geo.textContent = "GeoIP not enabled yet";
|
||||
detail.asn.textContent = "GeoIP not enabled yet";
|
||||
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
|
||||
detail.why.textContent = `${matchingEvents.length} events, ${matchingAlerts.length} alerts${lastEvent ? `, latest=${lastEvent.kind}` : ""}`;
|
||||
if (ban && ban.until) {
|
||||
const parsed = new Date(ban.until);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
@@ -186,10 +210,7 @@
|
||||
|
||||
async function banIP() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
if (!ip) return showToast("Enter IP first", "warning");
|
||||
const payload = await postAction("ban", { ip });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP banned", "success");
|
||||
@@ -197,15 +218,10 @@
|
||||
|
||||
async function banUntil() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
if (!ip) return showToast("Enter IP first", "warning");
|
||||
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
|
||||
if (!banUntil) {
|
||||
showToast("Set valid expiration date", "warning");
|
||||
return;
|
||||
}
|
||||
if (!banUntil) return showToast("Set valid expiration date", "warning");
|
||||
if (!window.confirm("Apply custom ban expiration?")) return;
|
||||
const payload = await postAction("ban_until", { ip, ban_until: banUntil });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP ban expiration updated", "success");
|
||||
@@ -213,36 +229,43 @@
|
||||
|
||||
async function unbanIP() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Select or enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
if (!ip) return showToast("Select or enter IP first", "warning");
|
||||
if (!window.confirm(`Unban ${ip}?`)) return;
|
||||
const payload = await postAction("unban", { ip });
|
||||
state.selectedIPs.delete(ip);
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "IP unbanned", "success");
|
||||
}
|
||||
|
||||
async function bulkUnban() {
|
||||
const ips = Array.from(state.selectedIPs);
|
||||
if (ips.length === 0) return showToast("Select at least one banned IP", "warning");
|
||||
if (!window.confirm(`Unban ${ips.length} selected IPs?`)) return;
|
||||
const payload = await postAction("bulk_unban", { ips });
|
||||
state.selectedIPs.clear();
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "Bulk unban complete", "success");
|
||||
}
|
||||
|
||||
async function unbanAll() {
|
||||
if (!window.confirm("Unban all active bans?")) return;
|
||||
const payload = await postAction("unban_all");
|
||||
state.selectedIPs.clear();
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "All bans cleared", "success");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
try {
|
||||
const command = button.dataset.command;
|
||||
if (command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-ip") {
|
||||
await banIP();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-until") {
|
||||
await banUntil();
|
||||
return;
|
||||
}
|
||||
if (command === "unban-ip") {
|
||||
await unbanIP();
|
||||
return;
|
||||
}
|
||||
if (command === "refresh") return window.location.reload();
|
||||
if (command === "ban-ip") return await banIP();
|
||||
if (command === "ban-until") return await banUntil();
|
||||
if (command === "unban-ip") return await unbanIP();
|
||||
if (command === "bulk-unban") return await bulkUnban();
|
||||
if (command === "unban-all") return await unbanAll();
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
} finally {
|
||||
@@ -253,6 +276,30 @@
|
||||
});
|
||||
|
||||
ipInput.addEventListener("input", () => renderIPDetails());
|
||||
filterInput?.addEventListener("input", () => renderBans());
|
||||
sortSelect?.addEventListener("change", () => renderBans());
|
||||
selectAll?.addEventListener("change", () => {
|
||||
if (selectAll.checked) state.bans.forEach((ban) => state.selectedIPs.add(ban.ip));
|
||||
else state.selectedIPs.clear();
|
||||
renderBans();
|
||||
});
|
||||
|
||||
copyIPButton?.addEventListener("click", async () => {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) return showToast("No IP selected", "warning");
|
||||
await navigator.clipboard.writeText(ip);
|
||||
showToast("IP copied", "success");
|
||||
});
|
||||
openActivityButton?.addEventListener("click", () => {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) return showToast("No IP selected", "warning");
|
||||
window.location.href = `/admin/activity?q=${encodeURIComponent(ip)}`;
|
||||
});
|
||||
openAlertsButton?.addEventListener("click", () => {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) return showToast("No IP selected", "warning");
|
||||
window.location.href = `/admin/alerts?q=${encodeURIComponent(ip)}`;
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
@@ -262,11 +309,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (state.bans.length > 0) {
|
||||
setSelectedIP(state.bans[0].ip);
|
||||
} else {
|
||||
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
|
||||
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
|
||||
}
|
||||
if (state.bans.length > 0) setSelectedIP(state.bans[0].ip);
|
||||
render();
|
||||
})();
|
||||
|
||||
@@ -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-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="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +47,7 @@
|
||||
<div class="admin-workspace-body security-page-body">
|
||||
<section class="security-grid">
|
||||
<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">
|
||||
<label class="security-field">IP address
|
||||
<input class="security-input" id="security-ip-input" type="text" placeholder="203.0.113.12">
|
||||
@@ -56,7 +58,9 @@
|
||||
</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="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>
|
||||
</section>
|
||||
|
||||
@@ -69,12 +73,23 @@
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<div class="security-table-toolbar">
|
||||
<input id="security-ban-filter" class="security-input" type="text" placeholder="Filter by IP">
|
||||
<select id="security-ban-sort" class="security-input">
|
||||
<option value="expiry_asc">Expiry ↑</option>
|
||||
<option value="expiry_desc">Expiry ↓</option>
|
||||
<option value="ip_asc">IP A-Z</option>
|
||||
<option value="ip_desc">IP Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
@@ -83,14 +98,19 @@
|
||||
<tbody id="security-bans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="security-ip-detail">
|
||||
<h3 id="security-detail-ip">No IP selected</h3>
|
||||
<ul>
|
||||
<li><strong>Risk:</strong> <span id="security-detail-risk">-</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>ASN:</strong> <span id="security-detail-asn">Placeholder</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">GeoIP not enabled yet</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +136,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<footer class="status-bar admin-dashboard-statusbar">
|
||||
|
||||
Reference in New Issue
Block a user