Files
sessiongurad/internal/edgeguard/guard_test.go
jbergner 7c69432097
Some checks failed
release-tag / release-image (push) Successful in 2m3s
release-main / release-images (push) Failing after 3m9s
RC-2 0.5.2
2026-08-24 05:46:42 +02:00

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