307 lines
8.0 KiB
Go
307 lines
8.0 KiB
Go
package edgeguard
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type RateLimit struct {
|
|
RatePerSecond float64 `json:"rate_per_second"`
|
|
Burst int `json:"burst"`
|
|
}
|
|
|
|
type RateRule struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host,omitempty"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
RatePerSecond float64 `json:"rate_per_second"`
|
|
Burst int `json:"burst"`
|
|
}
|
|
|
|
type AutoBanConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Threshold int `json:"threshold"`
|
|
WindowSeconds int `json:"window_seconds"`
|
|
BanSeconds int `json:"ban_seconds"`
|
|
ScannerWeight int `json:"scanner_weight"`
|
|
MethodWeight int `json:"method_weight"`
|
|
RateLimitWeight int `json:"rate_limit_weight"`
|
|
InvalidURIWeight int `json:"invalid_uri_weight"`
|
|
}
|
|
|
|
type Config struct {
|
|
Listen string `json:"listen"`
|
|
AllowedHosts []string `json:"allowed_hosts"`
|
|
BlacklistFile string `json:"blacklist_file"`
|
|
RateExemptFile string `json:"rate_exempt_file"`
|
|
StateFile string `json:"state_file"`
|
|
ReloadSeconds int `json:"reload_seconds"`
|
|
MaxURILength int `json:"max_uri_length"`
|
|
MaxTrackedIPs int `json:"max_tracked_ips"`
|
|
GlobalLimit RateLimit `json:"global_limit"`
|
|
PerIPLimit RateLimit `json:"per_ip_limit"`
|
|
Rules []RateRule `json:"rules"`
|
|
BlockedMethods []string `json:"blocked_methods"`
|
|
ScannerPathPrefixes []string `json:"scanner_path_prefixes"`
|
|
AutoBan AutoBanConfig `json:"auto_ban"`
|
|
}
|
|
|
|
type RuntimeConfig struct {
|
|
Config
|
|
allowedHosts map[string]struct{}
|
|
blockedMethods map[string]struct{}
|
|
blacklist []netip.Prefix
|
|
rateExempt []netip.Prefix
|
|
fingerprint string
|
|
}
|
|
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Listen: "127.0.0.1:9081",
|
|
ReloadSeconds: 15,
|
|
MaxURILength: 8192,
|
|
MaxTrackedIPs: 100000,
|
|
GlobalLimit: RateLimit{
|
|
RatePerSecond: 2500,
|
|
Burst: 5000,
|
|
},
|
|
PerIPLimit: RateLimit{
|
|
RatePerSecond: 200,
|
|
Burst: 500,
|
|
},
|
|
Rules: []RateRule{
|
|
{Name: "access-login", PathPrefix: "/_sessionguard/auth/login", RatePerSecond: 5, Burst: 100},
|
|
{Name: "access-callback", PathPrefix: "/_sessionguard/auth/oidc/callback", RatePerSecond: 10, Burst: 100},
|
|
{Name: "admin-oidc", PathPrefix: "/oidc/", RatePerSecond: 5, Burst: 50},
|
|
},
|
|
BlockedMethods: []string{"CONNECT", "TRACE", "TRACK"},
|
|
ScannerPathPrefixes: []string{
|
|
"/.env", "/.git", "/.svn", "/.hg", "/wp-admin", "/wp-login.php",
|
|
"/phpmyadmin", "/pma", "/cgi-bin", "/server-status", "/actuator",
|
|
"/vendor/phpunit", "/boaform", "/HNAP1", "/solr/", "/jenkins/",
|
|
},
|
|
AutoBan: AutoBanConfig{
|
|
Enabled: true,
|
|
Threshold: 10,
|
|
WindowSeconds: 120,
|
|
BanSeconds: 900,
|
|
ScannerWeight: 5,
|
|
MethodWeight: 3,
|
|
RateLimitWeight: 0,
|
|
InvalidURIWeight: 3,
|
|
},
|
|
}
|
|
}
|
|
|
|
func LoadRuntimeConfig(path string) (*RuntimeConfig, error) {
|
|
cfg := DefaultConfig()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode config: %w", err)
|
|
}
|
|
if err := validateConfig(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rc := &RuntimeConfig{Config: cfg}
|
|
rc.allowedHosts = make(map[string]struct{}, len(cfg.AllowedHosts))
|
|
for _, h := range cfg.AllowedHosts {
|
|
h = normalizeHost(h)
|
|
if h != "" {
|
|
rc.allowedHosts[h] = struct{}{}
|
|
}
|
|
}
|
|
rc.blockedMethods = make(map[string]struct{}, len(cfg.BlockedMethods))
|
|
for _, m := range cfg.BlockedMethods {
|
|
m = strings.ToUpper(strings.TrimSpace(m))
|
|
if m != "" {
|
|
rc.blockedMethods[m] = struct{}{}
|
|
}
|
|
}
|
|
|
|
rc.blacklist, err = loadPrefixFile(cfg.BlacklistFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load blacklist: %w", err)
|
|
}
|
|
rc.rateExempt, err = loadPrefixFile(cfg.RateExemptFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load rate exempt list: %w", err)
|
|
}
|
|
|
|
h := sha256.New()
|
|
h.Write(b)
|
|
for _, p := range rc.blacklist {
|
|
h.Write([]byte("blacklist:" + p.String() + "\n"))
|
|
}
|
|
for _, p := range rc.rateExempt {
|
|
h.Write([]byte("rate-exempt:" + p.String() + "\n"))
|
|
}
|
|
rc.fingerprint = hex.EncodeToString(h.Sum(nil))
|
|
return rc, nil
|
|
}
|
|
|
|
func validateConfig(cfg *Config) error {
|
|
if strings.TrimSpace(cfg.Listen) == "" {
|
|
return fmt.Errorf("listen must not be empty")
|
|
}
|
|
if cfg.ReloadSeconds <= 0 {
|
|
cfg.ReloadSeconds = 15
|
|
}
|
|
if cfg.MaxURILength <= 0 {
|
|
cfg.MaxURILength = 8192
|
|
}
|
|
if cfg.MaxTrackedIPs <= 0 {
|
|
cfg.MaxTrackedIPs = 100000
|
|
}
|
|
if err := validateRate("global_limit", cfg.GlobalLimit); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRate("per_ip_limit", cfg.PerIPLimit); err != nil {
|
|
return err
|
|
}
|
|
for i := range cfg.Rules {
|
|
r := &cfg.Rules[i]
|
|
r.Host = normalizeHost(r.Host)
|
|
r.PathPrefix = strings.ToLower(strings.TrimSpace(r.PathPrefix))
|
|
if r.PathPrefix == "" {
|
|
return fmt.Errorf("rules[%d].path_prefix must not be empty", i)
|
|
}
|
|
if r.RatePerSecond <= 0 || r.Burst <= 0 {
|
|
return fmt.Errorf("rules[%d] must have positive rate_per_second and burst", i)
|
|
}
|
|
}
|
|
if cfg.AutoBan.Enabled {
|
|
if cfg.AutoBan.Threshold <= 0 || cfg.AutoBan.WindowSeconds <= 0 || cfg.AutoBan.BanSeconds <= 0 {
|
|
return fmt.Errorf("auto_ban threshold/window_seconds/ban_seconds must be positive")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRate(name string, r RateLimit) error {
|
|
if r.RatePerSecond <= 0 || r.Burst <= 0 {
|
|
return fmt.Errorf("%s must have positive rate_per_second and burst", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadPrefixFile(path string) ([]netip.Prefix, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return nil, nil
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var out []netip.Prefix
|
|
s := bufio.NewScanner(f)
|
|
lineNo := 0
|
|
for s.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(s.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if i := strings.IndexByte(line, '#'); i >= 0 {
|
|
line = strings.TrimSpace(line[:i])
|
|
}
|
|
p, err := parsePrefix(line)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %w", path, lineNo, err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if err := s.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() })
|
|
return out, nil
|
|
}
|
|
|
|
func parsePrefix(s string) (netip.Prefix, error) {
|
|
if p, err := netip.ParsePrefix(s); err == nil {
|
|
return p.Masked(), nil
|
|
}
|
|
a, err := netip.ParseAddr(s)
|
|
if err != nil {
|
|
return netip.Prefix{}, fmt.Errorf("invalid IP/CIDR %q", s)
|
|
}
|
|
bits := 128
|
|
if a.Is4() {
|
|
bits = 32
|
|
}
|
|
return netip.PrefixFrom(a, bits), nil
|
|
}
|
|
|
|
func normalizeHost(s string) string {
|
|
s = strings.TrimSpace(strings.ToLower(s))
|
|
if i := strings.IndexByte(s, ':'); i > 0 && strings.Count(s, ":") == 1 {
|
|
s = s[:i]
|
|
}
|
|
return strings.TrimSuffix(s, ".")
|
|
}
|
|
|
|
func (r *RuntimeConfig) IsBlacklisted(ip netip.Addr) bool {
|
|
return containsPrefix(r.blacklist, ip)
|
|
}
|
|
|
|
func (r *RuntimeConfig) IsRateExempt(ip netip.Addr) bool {
|
|
return containsPrefix(r.rateExempt, ip)
|
|
}
|
|
|
|
func containsPrefix(prefixes []netip.Prefix, ip netip.Addr) bool {
|
|
for _, p := range prefixes {
|
|
if p.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *RuntimeConfig) HostAllowed(host string) bool {
|
|
if len(r.allowedHosts) == 0 {
|
|
return true
|
|
}
|
|
_, ok := r.allowedHosts[normalizeHost(host)]
|
|
return ok
|
|
}
|
|
|
|
func (r *RuntimeConfig) MethodBlocked(method string) bool {
|
|
_, ok := r.blockedMethods[strings.ToUpper(strings.TrimSpace(method))]
|
|
return ok
|
|
}
|
|
|
|
func (r *RuntimeConfig) MatchingRule(host, path string) *RateRule {
|
|
host = normalizeHost(host)
|
|
for i := range r.Rules {
|
|
rule := &r.Rules[i]
|
|
if rule.Host != "" && rule.Host != host {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(path, rule.PathPrefix) {
|
|
return rule
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RuntimeConfig) ReloadInterval() time.Duration {
|
|
return time.Duration(r.ReloadSeconds) * time.Second
|
|
}
|
|
|
|
func (r *RuntimeConfig) Fingerprint() string { return r.fingerprint }
|