Add extension enumeration detection and comprehensive SIP protection
Major features: - Extension enumeration detection with 3 detection algorithms: - Max unique extensions threshold (default: 20 in 5 min) - Sequential pattern detection (e.g., 100,101,102...) - Rapid-fire detection (many extensions in short window) - Prometheus metrics for all SIP Guardian operations - SQLite persistent storage for bans and attack history - Webhook notifications for ban/unban/suspicious events - GeoIP-based country blocking with continent shortcuts - Per-method rate limiting with token bucket algorithm Bug fixes: - Fix whitelist count always reporting zero in stats - Fix whitelisted connections metric never incrementing - Fix Caddyfile config not being applied to shared guardian New files: - enumeration.go: Extension enumeration detector - enumeration_test.go: 14 comprehensive unit tests - metrics.go: Prometheus metrics handler - storage.go: SQLite persistence layer - webhooks.go: Webhook notification system - geoip.go: MaxMind GeoIP integration - ratelimit.go: Per-method rate limiting Testing: - sandbox/ contains complete Docker Compose test environment - All 14 enumeration tests pass
This commit is contained in:
parent
0b0fb53c9c
commit
c73fa9d3d1
19 changed files with 4630 additions and 544 deletions
417
l4handler.go
417
l4handler.go
|
|
@ -2,6 +2,7 @@ package sipguardian
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
|
|
@ -48,17 +49,21 @@ func (m *SIPMatcher) Provision(ctx caddy.Context) error {
|
|||
|
||||
// Match returns true if the connection appears to be SIP traffic
|
||||
func (m *SIPMatcher) Match(cx *layer4.Connection) (bool, error) {
|
||||
// Peek at first 64 bytes to check for SIP signature
|
||||
// Read enough bytes to identify SIP traffic
|
||||
// We need at least 8 bytes to identify SIP methods (e.g., "REGISTER " or "SIP/2.0 ")
|
||||
buf := make([]byte, 64)
|
||||
n, err := io.ReadAtLeast(cx, buf, 8)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return false, nil
|
||||
n, err := io.ReadFull(cx, buf)
|
||||
if err == io.ErrUnexpectedEOF && n >= 8 {
|
||||
// Got less than 64 bytes but enough to match - that's fine
|
||||
buf = buf[:n]
|
||||
} else if err != nil {
|
||||
// Return the error so caddy-l4 knows we need more data
|
||||
// This includes ErrConsumedAllPrefetchedBytes which triggers prefetch
|
||||
return false, err
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
// Check if it matches a SIP method
|
||||
// Check if it matches a SIP method (REGISTER, INVITE, OPTIONS, etc.)
|
||||
if m.methodRegex.Match(buf) {
|
||||
// Rewind the buffer for the handler
|
||||
cx.SetVar("sip_peek", buf)
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -80,6 +85,10 @@ type SIPHandler struct {
|
|||
// Upstream address to proxy to
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
|
||||
// Embedded guardian config parsed from Caddyfile
|
||||
// This gets applied to the shared guardian during Provision
|
||||
SIPGuardian
|
||||
|
||||
logger *zap.Logger
|
||||
guardian *SIPGuardian
|
||||
}
|
||||
|
|
@ -94,13 +103,13 @@ func (SIPHandler) CaddyModule() caddy.ModuleInfo {
|
|||
func (h *SIPHandler) Provision(ctx caddy.Context) error {
|
||||
h.logger = ctx.Logger()
|
||||
|
||||
// Get or create the guardian instance
|
||||
// In a real implementation, this would use Caddy's module loading
|
||||
// For now, we'll create a default instance
|
||||
h.guardian = &SIPGuardian{}
|
||||
if err := h.guardian.Provision(ctx); err != nil {
|
||||
// Get or create a shared guardian instance from the global registry
|
||||
// Pass our parsed config so the guardian can be configured
|
||||
guardian, err := GetOrCreateGuardianWithConfig(ctx, "default", &h.SIPGuardian)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.guardian = guardian
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -116,59 +125,185 @@ func (h *SIPHandler) Handle(cx *layer4.Connection, next layer4.Handler) error {
|
|||
// Check if IP is banned
|
||||
if h.guardian.IsBanned(host) {
|
||||
h.logger.Debug("Blocked banned IP", zap.String("ip", host))
|
||||
if enableMetrics {
|
||||
RecordConnection("blocked")
|
||||
}
|
||||
return cx.Close()
|
||||
}
|
||||
|
||||
// Check if IP is whitelisted - skip further checks
|
||||
if h.guardian.IsWhitelisted(host) {
|
||||
if enableMetrics {
|
||||
RecordConnection("allowed")
|
||||
}
|
||||
return next.Handle(cx)
|
||||
}
|
||||
|
||||
// Get the peeked SIP data if available
|
||||
if peekData := cx.GetVar("sip_peek"); peekData != nil {
|
||||
buf := peekData.([]byte)
|
||||
// Check GeoIP blocking (if configured)
|
||||
if blocked, country := h.guardian.IsCountryBlocked(host); blocked {
|
||||
h.logger.Info("Blocked connection from blocked country",
|
||||
zap.String("ip", host),
|
||||
zap.String("country", country),
|
||||
)
|
||||
if enableMetrics {
|
||||
RecordConnection("geo_blocked")
|
||||
}
|
||||
return cx.Close()
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if isSuspiciousSIP(buf) {
|
||||
h.logger.Warn("Suspicious SIP traffic detected",
|
||||
zap.String("ip", host),
|
||||
zap.ByteString("sample", buf[:min(32, len(buf))]),
|
||||
)
|
||||
banned := h.guardian.RecordFailure(host, "suspicious_sip_pattern")
|
||||
if banned {
|
||||
// Read data from the connection for suspicious pattern detection
|
||||
// caddy-l4 replays prefetched data on read, so we can read the full message here
|
||||
buf := make([]byte, 1024)
|
||||
n, err := cx.Read(buf)
|
||||
if n > 0 {
|
||||
buf = buf[:n]
|
||||
h.logger.Debug("Read SIP data for inspection",
|
||||
zap.String("ip", host),
|
||||
zap.Int("bytes", n),
|
||||
)
|
||||
|
||||
// Extract SIP method for rate limiting
|
||||
method := ExtractSIPMethod(buf)
|
||||
if method != "" {
|
||||
// Check rate limit
|
||||
rl := GetRateLimiter(h.logger)
|
||||
if allowed, reason := rl.Allow(host, method); !allowed {
|
||||
h.logger.Warn("Rate limit exceeded",
|
||||
zap.String("ip", host),
|
||||
zap.String("method", string(method)),
|
||||
)
|
||||
if enableMetrics {
|
||||
RecordConnection("rate_limited")
|
||||
}
|
||||
// Record as failure (may trigger ban)
|
||||
h.guardian.RecordFailure(host, reason)
|
||||
return cx.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extension enumeration attacks
|
||||
extension := ExtractTargetExtension(buf)
|
||||
if extension != "" {
|
||||
detector := GetEnumerationDetector(h.logger)
|
||||
result := detector.RecordAttempt(host, extension)
|
||||
if result.Detected {
|
||||
h.logger.Warn("Enumeration attack detected",
|
||||
zap.String("ip", host),
|
||||
zap.String("reason", result.Reason),
|
||||
zap.Int("unique_extensions", result.UniqueCount),
|
||||
zap.Strings("extensions", result.Extensions),
|
||||
)
|
||||
|
||||
if enableMetrics {
|
||||
RecordEnumerationDetection(result.Reason)
|
||||
RecordEnumerationExtensions(result.UniqueCount)
|
||||
RecordConnection("enumeration_blocked")
|
||||
}
|
||||
|
||||
// Store in persistent storage if enabled
|
||||
if h.guardian.storage != nil {
|
||||
go h.guardian.storage.RecordEnumerationAttempt(host, result.Reason, result.UniqueCount, result.Extensions)
|
||||
}
|
||||
|
||||
// Emit webhook event
|
||||
if enableWebhooks {
|
||||
go EmitEnumerationEvent(h.logger, host, result)
|
||||
}
|
||||
|
||||
// Ban the IP (use enumeration-specific ban time if configured)
|
||||
h.guardian.RecordFailure(host, "enumeration_"+result.Reason)
|
||||
return cx.Close()
|
||||
}
|
||||
|
||||
// Update metrics for tracked IPs
|
||||
if enableMetrics {
|
||||
stats := detector.GetStats()
|
||||
if trackedIPs, ok := stats["tracked_ips"].(int); ok {
|
||||
UpdateEnumerationTrackedIPs(trackedIPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns in the SIP message
|
||||
suspiciousPattern := detectSuspiciousPattern(buf)
|
||||
if suspiciousPattern != "" {
|
||||
h.logger.Warn("Suspicious SIP traffic detected",
|
||||
zap.String("ip", host),
|
||||
zap.String("pattern", suspiciousPattern),
|
||||
zap.ByteString("sample", buf[:min(64, len(buf))]),
|
||||
)
|
||||
if enableMetrics {
|
||||
RecordSuspiciousPattern(suspiciousPattern)
|
||||
RecordConnection("suspicious")
|
||||
}
|
||||
|
||||
// Store in persistent storage if enabled
|
||||
if h.guardian.storage != nil {
|
||||
go h.guardian.storage.RecordSuspiciousPattern(host, suspiciousPattern, string(buf[:min(200, len(buf))]))
|
||||
}
|
||||
|
||||
banned := h.guardian.RecordFailure(host, "suspicious_sip_pattern")
|
||||
if banned {
|
||||
h.logger.Warn("IP banned due to suspicious activity",
|
||||
zap.String("ip", host),
|
||||
)
|
||||
return cx.Close()
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
h.logger.Debug("Failed to read SIP data for inspection",
|
||||
zap.String("ip", host),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful connection
|
||||
if enableMetrics {
|
||||
RecordConnection("allowed")
|
||||
}
|
||||
|
||||
// Continue to next handler
|
||||
return next.Handle(cx)
|
||||
}
|
||||
|
||||
// isSuspiciousSIP checks for common attack patterns in SIP traffic
|
||||
func isSuspiciousSIP(data []byte) bool {
|
||||
s := string(data)
|
||||
// suspiciousPatternDefs defines patterns and their names for detection
|
||||
var suspiciousPatternDefs = []struct {
|
||||
name string
|
||||
pattern string
|
||||
}{
|
||||
{"sipvicious", "sipvicious"},
|
||||
{"friendly-scanner", "friendly-scanner"},
|
||||
{"sipcli", "sipcli"},
|
||||
{"sip-scan", "sip-scan"},
|
||||
{"voipbuster", "voipbuster"},
|
||||
{"asterisk-pbx-scanner", "asterisk pbx"},
|
||||
{"sipsak", "sipsak"},
|
||||
{"sundayddr", "sundayddr"},
|
||||
{"iwar", "iwar"},
|
||||
{"cseq-flood", "cseq: 1 options"}, // Repeated OPTIONS flood
|
||||
{"zoiper-spoof", "user-agent: zoiper"},
|
||||
{"test-extension-100", "sip:100@"},
|
||||
{"test-extension-1000", "sip:1000@"},
|
||||
{"null-user", "sip:@"},
|
||||
{"anonymous", "anonymous@"},
|
||||
}
|
||||
|
||||
// Common scanner/attack patterns
|
||||
suspiciousPatterns := []string{
|
||||
"sipvicious",
|
||||
"friendly-scanner",
|
||||
"sipcli",
|
||||
"sip-scan",
|
||||
"User-Agent: Zoiper", // Often spoofed
|
||||
"From: <sip:100@", // Common test extension
|
||||
"From: <sip:1000@",
|
||||
"To: <sip:100@",
|
||||
}
|
||||
// detectSuspiciousPattern checks for common attack patterns and returns the pattern name
|
||||
func detectSuspiciousPattern(data []byte) string {
|
||||
lower := strings.ToLower(string(data))
|
||||
|
||||
lower := strings.ToLower(s)
|
||||
for _, pattern := range suspiciousPatterns {
|
||||
if strings.Contains(lower, strings.ToLower(pattern)) {
|
||||
return true
|
||||
for _, def := range suspiciousPatternDefs {
|
||||
if strings.Contains(lower, def.pattern) {
|
||||
return def.name
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSuspiciousSIP checks for common attack patterns in SIP traffic (legacy wrapper)
|
||||
func isSuspiciousSIP(data []byte) bool {
|
||||
return detectSuspiciousPattern(data) != ""
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
|
|
@ -214,6 +349,20 @@ func (m *SIPMatcher) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|||
// find_time 10m
|
||||
// ban_time 1h
|
||||
// whitelist 10.0.0.0/8 172.16.0.0/12
|
||||
// storage /data/sip-guardian.db
|
||||
// geoip_db /data/GeoLite2-Country.mmdb
|
||||
// block_countries CN RU
|
||||
// allow_countries US CA GB
|
||||
// enumeration {
|
||||
// max_extensions 20
|
||||
// extension_window 5m
|
||||
// sequential_threshold 5
|
||||
// rapid_fire_count 10
|
||||
// rapid_fire_window 30s
|
||||
// ban_time 2h
|
||||
// exempt_extensions 100 200 9999
|
||||
// }
|
||||
// webhook http://example.com/hook { ... }
|
||||
// }
|
||||
//
|
||||
// Or simply: sip_guardian (uses defaults)
|
||||
|
|
@ -221,18 +370,184 @@ func (h *SIPHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|||
// Move past "sip_guardian" token
|
||||
d.Next()
|
||||
|
||||
// The handler doesn't have its own configuration - it uses the shared SIPGuardian
|
||||
// But we need to parse any configuration blocks that might be present
|
||||
// Parse configuration into the embedded SIPGuardian struct
|
||||
// This config will be applied to the shared guardian during Provision
|
||||
|
||||
// Check for inline args or block
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
// For now, SIPHandler delegates to SIPGuardian
|
||||
// In future, handler-specific config could go here
|
||||
switch d.Val() {
|
||||
case "max_failures", "find_time", "ban_time", "whitelist":
|
||||
// These are handled by the embedded SIPGuardian
|
||||
// Skip to allow flexibility in config placement
|
||||
d.RemainingArgs()
|
||||
case "max_failures":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(d.Val(), "%d", &val); err != nil {
|
||||
return d.Errf("invalid max_failures: %v", err)
|
||||
}
|
||||
h.MaxFailures = val
|
||||
|
||||
case "find_time":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid find_time: %v", err)
|
||||
}
|
||||
h.FindTime = caddy.Duration(dur)
|
||||
|
||||
case "ban_time":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid ban_time: %v", err)
|
||||
}
|
||||
h.BanTime = caddy.Duration(dur)
|
||||
|
||||
case "whitelist":
|
||||
for d.NextArg() {
|
||||
h.WhitelistCIDR = append(h.WhitelistCIDR, d.Val())
|
||||
}
|
||||
|
||||
case "storage":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
h.StoragePath = d.Val()
|
||||
|
||||
case "geoip_db":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
h.GeoIPPath = d.Val()
|
||||
|
||||
case "block_countries":
|
||||
for d.NextArg() {
|
||||
country := d.Val()
|
||||
if expanded := ExpandContinentCode(country); expanded != nil {
|
||||
h.BlockedCountries = append(h.BlockedCountries, expanded...)
|
||||
} else {
|
||||
h.BlockedCountries = append(h.BlockedCountries, country)
|
||||
}
|
||||
}
|
||||
|
||||
case "allow_countries":
|
||||
for d.NextArg() {
|
||||
country := d.Val()
|
||||
if expanded := ExpandContinentCode(country); expanded != nil {
|
||||
h.AllowedCountries = append(h.AllowedCountries, expanded...)
|
||||
} else {
|
||||
h.AllowedCountries = append(h.AllowedCountries, country)
|
||||
}
|
||||
}
|
||||
|
||||
case "enumeration":
|
||||
h.Enumeration = &EnumerationConfig{}
|
||||
for innerNesting := d.Nesting(); d.NextBlock(innerNesting); {
|
||||
switch d.Val() {
|
||||
case "max_extensions":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(d.Val(), "%d", &val); err != nil {
|
||||
return d.Errf("invalid max_extensions: %v", err)
|
||||
}
|
||||
h.Enumeration.MaxExtensions = val
|
||||
case "extension_window":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid extension_window: %v", err)
|
||||
}
|
||||
h.Enumeration.ExtensionWindow = dur
|
||||
case "sequential_threshold":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(d.Val(), "%d", &val); err != nil {
|
||||
return d.Errf("invalid sequential_threshold: %v", err)
|
||||
}
|
||||
h.Enumeration.SequentialThreshold = val
|
||||
case "rapid_fire_count":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(d.Val(), "%d", &val); err != nil {
|
||||
return d.Errf("invalid rapid_fire_count: %v", err)
|
||||
}
|
||||
h.Enumeration.RapidFireCount = val
|
||||
case "rapid_fire_window":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid rapid_fire_window: %v", err)
|
||||
}
|
||||
h.Enumeration.RapidFireWindow = dur
|
||||
case "ban_time":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid enumeration ban_time: %v", err)
|
||||
}
|
||||
h.Enumeration.EnumBanTime = dur
|
||||
case "exempt_extensions":
|
||||
h.Enumeration.ExemptExtensions = d.RemainingArgs()
|
||||
default:
|
||||
return d.Errf("unknown enumeration directive: %s", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "webhook":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
webhook := WebhookConfig{
|
||||
URL: d.Val(),
|
||||
}
|
||||
// Parse webhook block if present
|
||||
for innerNesting := d.Nesting(); d.NextBlock(innerNesting); {
|
||||
switch d.Val() {
|
||||
case "events":
|
||||
webhook.Events = d.RemainingArgs()
|
||||
case "secret":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
webhook.Secret = d.Val()
|
||||
case "timeout":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return d.Errf("invalid webhook timeout: %v", err)
|
||||
}
|
||||
webhook.Timeout = dur
|
||||
case "header":
|
||||
args := d.RemainingArgs()
|
||||
if len(args) != 2 {
|
||||
return d.Errf("header requires name and value")
|
||||
}
|
||||
if webhook.Headers == nil {
|
||||
webhook.Headers = make(map[string]string)
|
||||
}
|
||||
webhook.Headers[args[0]] = args[1]
|
||||
default:
|
||||
return d.Errf("unknown webhook directive: %s", d.Val())
|
||||
}
|
||||
}
|
||||
h.Webhooks = append(h.Webhooks, webhook)
|
||||
|
||||
default:
|
||||
return d.Errf("unknown sip_guardian directive: %s", d.Val())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue