218 lines
6.4 KiB
Go
218 lines
6.4 KiB
Go
package edgeguard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testConfig(t *testing.T, mutate func(*Config)) *RuntimeConfig {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cfg := DefaultConfig()
|
|
cfg.Listen = "127.0.0.1:0"
|
|
cfg.BlacklistFile = filepath.Join(dir, "blacklist.txt")
|
|
cfg.RateExemptFile = filepath.Join(dir, "rate-exempt.txt")
|
|
if err := os.WriteFile(cfg.BlacklistFile, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cfg.RateExemptFile, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.StateFile = filepath.Join(dir, "state.json")
|
|
cfg.AllowedHosts = []string{"ts.example.test", "sessionguard.example.test"}
|
|
if mutate != nil {
|
|
mutate(&cfg)
|
|
}
|
|
b, err := jsonMarshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, "edgeguard.json")
|
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rc, err := LoadRuntimeConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rc
|
|
}
|
|
|
|
func jsonMarshal(v any) ([]byte, error) {
|
|
return json.MarshalIndent(v, "", " ")
|
|
}
|
|
|
|
func newTestGuard(cfg *RuntimeConfig) *Guard {
|
|
return NewGuard(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
|
}
|
|
|
|
func TestAllowsNormalRequest(t *testing.T) {
|
|
g := newTestGuard(testConfig(t, nil))
|
|
d := g.Check(netip.MustParseAddr("203.0.113.10"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
|
|
if !d.Allowed || d.StatusCode != 204 {
|
|
t.Fatalf("unexpected decision: %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestBlacklistCIDR(t *testing.T) {
|
|
dir := t.TempDir()
|
|
blacklist := filepath.Join(dir, "blacklist.txt")
|
|
if err := os.WriteFile(blacklist, []byte("203.0.113.0/24\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.AllowedHosts = []string{"ts.example.test"}
|
|
cfg.BlacklistFile = blacklist
|
|
cfg.RateExemptFile = ""
|
|
cfg.StateFile = filepath.Join(dir, "state.json")
|
|
b, _ := json.Marshal(cfg)
|
|
configPath := filepath.Join(dir, "edgeguard.json")
|
|
_ = os.WriteFile(configPath, b, 0o600)
|
|
rc, err := LoadRuntimeConfig(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g := newTestGuard(rc)
|
|
d := g.Check(netip.MustParseAddr("203.0.113.99"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
|
|
if d.StatusCode != 403 || d.Reason != "blacklisted" {
|
|
t.Fatalf("unexpected decision: %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestScannerTriggersAutoBan(t *testing.T) {
|
|
cfg := testConfig(t, func(c *Config) {
|
|
c.AutoBan.Threshold = 10
|
|
c.AutoBan.ScannerWeight = 5
|
|
})
|
|
g := newTestGuard(cfg)
|
|
ip := netip.MustParseAddr("198.51.100.7")
|
|
now := time.Unix(1000, 0)
|
|
for i := 0; i < 2; i++ {
|
|
d := g.Check(ip, "ts.example.test", "GET", "/.env", now.Add(time.Duration(i)*time.Second))
|
|
if d.StatusCode != 404 {
|
|
t.Fatalf("scanner request %d: %+v", i, d)
|
|
}
|
|
}
|
|
d := g.Check(ip, "ts.example.test", "GET", "/", now.Add(3*time.Second))
|
|
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
|
|
t.Fatalf("expected temp ban, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestPerIPRateLimit(t *testing.T) {
|
|
cfg := testConfig(t, func(c *Config) {
|
|
c.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 2}
|
|
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
|
c.AutoBan.Enabled = false
|
|
c.Rules = nil
|
|
})
|
|
g := newTestGuard(cfg)
|
|
ip := netip.MustParseAddr("198.51.100.8")
|
|
now := time.Unix(1000, 0)
|
|
for i := 0; i < 2; i++ {
|
|
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
|
|
t.Fatalf("request %d should be allowed: %+v", i, d)
|
|
}
|
|
}
|
|
d := g.Check(ip, "ts.example.test", "GET", "/", now)
|
|
if d.StatusCode != 429 {
|
|
t.Fatalf("expected 429, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestRateExemptCIDR(t *testing.T) {
|
|
dir := t.TempDir()
|
|
exempt := filepath.Join(dir, "exempt.txt")
|
|
if err := os.WriteFile(exempt, []byte("198.51.100.0/24\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.AllowedHosts = []string{"ts.example.test"}
|
|
cfg.BlacklistFile = ""
|
|
cfg.RateExemptFile = exempt
|
|
cfg.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 1}
|
|
cfg.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
|
cfg.Rules = nil
|
|
cfg.StateFile = filepath.Join(dir, "state.json")
|
|
b, _ := json.Marshal(cfg)
|
|
configPath := filepath.Join(dir, "edgeguard.json")
|
|
_ = os.WriteFile(configPath, b, 0o600)
|
|
rc, err := LoadRuntimeConfig(configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g := newTestGuard(rc)
|
|
ip := netip.MustParseAddr("198.51.100.42")
|
|
now := time.Unix(1000, 0)
|
|
for i := 0; i < 20; i++ {
|
|
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
|
|
t.Fatalf("request %d unexpectedly denied: %+v", i, d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBlockedMethodAndHost(t *testing.T) {
|
|
g := newTestGuard(testConfig(t, nil))
|
|
ip := netip.MustParseAddr("192.0.2.10")
|
|
now := time.Unix(1000, 0)
|
|
if d := g.Check(ip, "unknown.example.test", "GET", "/", now); d.StatusCode != 421 {
|
|
t.Fatalf("expected 421, got %+v", d)
|
|
}
|
|
if d := g.Check(ip, "ts.example.test", "TRACE", "/", now); d.StatusCode != 405 {
|
|
t.Fatalf("expected 405, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestEncodedScannerPath(t *testing.T) {
|
|
g := newTestGuard(testConfig(t, nil))
|
|
d := g.Check(netip.MustParseAddr("192.0.2.20"), "ts.example.test", "GET", "/.%65nv", time.Unix(1000, 0))
|
|
if d.StatusCode != 404 {
|
|
t.Fatalf("expected encoded /.env to be denied, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestTemporaryBanPersistsAcrossRestart(t *testing.T) {
|
|
cfg := testConfig(t, func(c *Config) {
|
|
c.AutoBan.Threshold = 5
|
|
c.AutoBan.ScannerWeight = 5
|
|
})
|
|
ip := netip.MustParseAddr("203.0.113.77")
|
|
now := time.Now()
|
|
g1 := newTestGuard(cfg)
|
|
if d := g1.Check(ip, "ts.example.test", "GET", "/.env", now); d.StatusCode != 404 {
|
|
t.Fatalf("expected scanner deny, got %+v", d)
|
|
}
|
|
g1.FlushState()
|
|
g2 := newTestGuard(cfg)
|
|
d := g2.Check(ip, "ts.example.test", "GET", "/", now.Add(time.Second))
|
|
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
|
|
t.Fatalf("expected persisted temporary ban, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestTrackedIPCapacityIsBounded(t *testing.T) {
|
|
cfg := testConfig(t, func(c *Config) {
|
|
c.MaxTrackedIPs = 2
|
|
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
|
c.PerIPLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
|
c.Rules = nil
|
|
c.AutoBan.Enabled = false
|
|
})
|
|
g := newTestGuard(cfg)
|
|
now := time.Unix(1000, 0)
|
|
for _, raw := range []string{"192.0.2.1", "192.0.2.2"} {
|
|
if d := g.Check(netip.MustParseAddr(raw), "ts.example.test", "GET", "/", now); !d.Allowed {
|
|
t.Fatalf("first two IPs should be tracked: %s %+v", raw, d)
|
|
}
|
|
}
|
|
d := g.Check(netip.MustParseAddr("192.0.2.3"), "ts.example.test", "GET", "/", now)
|
|
if d.StatusCode != 429 || d.Reason != "edge state capacity" {
|
|
t.Fatalf("expected bounded-state 429, got %+v", d)
|
|
}
|
|
}
|