caddy-sip-guardian/validation_test.go
Ryan Malloy 976fdf53a5 Add SIP message validation feature
Implements RFC 3261 compliance checking and security validation:

- Three validation modes: permissive (default), strict, paranoid
- Critical checks: null bytes, binary injection (immediate ban)
- RFC compliance: required headers (Via, From, To, Call-ID, CSeq, Max-Forwards)
- Format validation: CSeq range, Content-Length, Via branch format
- Paranoid mode: SQL injection patterns, excessive headers, long values
- Compact header form support (v, f, t, i, l, etc.)

Caddyfile configuration:
  validation {
      enabled true
      mode permissive
      max_message_size 65535
      ban_on_null_bytes true
      ban_on_binary_injection true
      disabled_rules via_invalid_branch
  }

New Prometheus metrics:
- sip_guardian_validation_violations_total{rule}
- sip_guardian_validation_results_total{result}
- sip_guardian_message_size_bytes (histogram)

Includes comprehensive unit tests covering all validation scenarios.
2025-12-07 15:57:26 -07:00

609 lines
18 KiB
Go

package sipguardian
import (
"strings"
"testing"
"go.uber.org/zap"
)
func TestDefaultValidationConfig(t *testing.T) {
config := DefaultValidationConfig()
if !config.Enabled {
t.Error("Expected validation to be enabled by default")
}
if config.Mode != ValidationModePermissive {
t.Errorf("Expected permissive mode, got %s", config.Mode)
}
if config.MaxMessageSize != 65535 {
t.Errorf("Expected max message size 65535, got %d", config.MaxMessageSize)
}
if !config.BanOnNullBytes {
t.Error("Expected ban on null bytes by default")
}
if !config.BanOnBinaryInjection {
t.Error("Expected ban on binary injection by default")
}
}
func TestValidationNullBytes(t *testing.T) {
logger := zap.NewNop()
validator := NewSIPValidator(logger, DefaultValidationConfig())
// Valid SIP message
validMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 0\r\n\r\n")
result := validator.Validate(validMsg)
if result.ShouldBan {
t.Error("Valid message should not trigger ban")
}
// Message with null byte at the end (won't trigger binary injection first)
nullMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 5\r\n\r\ntest\x00")
result = validator.Validate(nullMsg)
if !result.ShouldBan {
t.Error("Null byte injection should trigger ban")
}
// Note: null_bytes is checked first, so ban reason should be null_bytes
if result.BanReason != "validation_null_bytes" {
t.Errorf("Expected ban reason 'validation_null_bytes', got '%s'", result.BanReason)
}
// Check violation was recorded
hasNullViolation := false
for _, v := range result.Violations {
if v.Rule == "null_bytes" && v.Severity == SeverityCritical {
hasNullViolation = true
break
}
}
if !hasNullViolation {
t.Error("Expected null_bytes violation with critical severity")
}
}
func TestValidationBinaryInjection(t *testing.T) {
logger := zap.NewNop()
validator := NewSIPValidator(logger, DefaultValidationConfig())
// Message with binary control character (bell)
binaryMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:\x07user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n\r\n")
result := validator.Validate(binaryMsg)
if !result.ShouldBan {
t.Error("Binary injection should trigger ban")
}
if result.BanReason != "validation_binary_injection" {
t.Errorf("Expected ban reason 'validation_binary_injection', got '%s'", result.BanReason)
}
}
func TestValidationMissingHeaders(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModeStrict // Missing headers should ban in strict mode
validator := NewSIPValidator(logger, config)
// Message missing Via header
noViaMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result := validator.Validate(noViaMsg)
if result.Valid {
t.Error("Message missing Via should be invalid")
}
hasViaViolation := false
for _, v := range result.Violations {
if v.Rule == "missing_via" {
hasViaViolation = true
break
}
}
if !hasViaViolation {
t.Error("Expected missing_via violation")
}
// Message missing Call-ID
noCallIDMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"CSeq: 1 REGISTER\r\n\r\n")
result = validator.Validate(noCallIDMsg)
if result.Valid {
t.Error("Message missing Call-ID should be invalid")
}
hasCallIDViolation := false
for _, v := range result.Violations {
if v.Rule == "missing_call_id" {
hasCallIDViolation = true
break
}
}
if !hasCallIDViolation {
t.Error("Expected missing_call_id violation")
}
}
func TestValidationCompactHeaders(t *testing.T) {
logger := zap.NewNop()
validator := NewSIPValidator(logger, DefaultValidationConfig())
// Valid message using compact header forms
compactMsg := []byte("INVITE sip:bob@example.com SIP/2.0\r\n" +
"v: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK776\r\n" + // v = Via
"f: <sip:alice@example.com>;tag=1234\r\n" + // f = From
"t: <sip:bob@example.com>\r\n" + // t = To
"i: a84b4c76e66710@192.168.1.1\r\n" + // i = Call-ID
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n" +
"l: 0\r\n\r\n") // l = Content-Length
result := validator.Validate(compactMsg)
if !result.Valid {
t.Errorf("Valid message with compact headers should pass validation: %+v", result.Violations)
}
}
func TestValidationOversizedMessage(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.MaxMessageSize = 500 // Small limit for testing
validator := NewSIPValidator(logger, config)
// Create message larger than limit
largeBody := strings.Repeat("X", 600)
largeMsg := []byte("INVITE sip:bob@example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:alice@example.com>;tag=abc\r\n" +
"To: <sip:bob@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 600\r\n\r\n" + largeBody)
result := validator.Validate(largeMsg)
if result.Valid {
t.Error("Oversized message should be invalid")
}
hasOversizedViolation := false
for _, v := range result.Violations {
if v.Rule == "oversized_message" {
hasOversizedViolation = true
break
}
}
if !hasOversizedViolation {
t.Error("Expected oversized_message violation")
}
}
func TestValidationCSeqRange(t *testing.T) {
logger := zap.NewNop()
validator := NewSIPValidator(logger, DefaultValidationConfig())
// Valid CSeq (within range)
validCSeqMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 100 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result := validator.Validate(validCSeqMsg)
hasCSeqViolation := false
for _, v := range result.Violations {
if v.Rule == "cseq_out_of_range" {
hasCSeqViolation = true
break
}
}
if hasCSeqViolation {
t.Error("Valid CSeq should not trigger cseq_out_of_range")
}
// Invalid CSeq (out of range - negative not possible as string, but
// we can test with very large number exceeding int32)
// Note: Testing the actual boundary is tricky since ParseInt may handle it
}
func TestValidationContentLengthMismatch(t *testing.T) {
logger := zap.NewNop()
validator := NewSIPValidator(logger, DefaultValidationConfig())
// Correct Content-Length
correctMsg := []byte("INVITE sip:bob@example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:alice@example.com>;tag=abc\r\n" +
"To: <sip:bob@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 4\r\n\r\ntest")
result := validator.Validate(correctMsg)
hasMismatch := false
for _, v := range result.Violations {
if v.Rule == "content_length_mismatch" {
hasMismatch = true
break
}
}
if hasMismatch {
t.Error("Correct Content-Length should not trigger mismatch")
}
// Wrong Content-Length (claims 100 but body is 4)
wrongMsg := []byte("INVITE sip:bob@example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:alice@example.com>;tag=abc\r\n" +
"To: <sip:bob@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 100\r\n\r\ntest")
result = validator.Validate(wrongMsg)
hasMismatch = false
for _, v := range result.Violations {
if v.Rule == "content_length_mismatch" {
hasMismatch = true
break
}
}
if !hasMismatch {
t.Error("Wrong Content-Length should trigger mismatch")
}
}
func TestValidationViaBranch(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModeStrict // Via branch check only in strict mode
validator := NewSIPValidator(logger, config)
// Valid Via branch (RFC 3261 compliant - starts with z9hG4bK)
validBranchMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bKnashds7\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result := validator.Validate(validBranchMsg)
hasViaBranchViolation := false
for _, v := range result.Violations {
if v.Rule == "via_invalid_branch" {
hasViaBranchViolation = true
break
}
}
if hasViaBranchViolation {
t.Error("Valid Via branch should not trigger violation")
}
// Invalid Via branch (doesn't start with z9hG4bK)
invalidBranchMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=oldbranch123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result = validator.Validate(invalidBranchMsg)
hasViaBranchViolation = false
for _, v := range result.Violations {
if v.Rule == "via_invalid_branch" {
hasViaBranchViolation = true
break
}
}
if !hasViaBranchViolation {
t.Error("Invalid Via branch should trigger violation in strict mode")
}
}
func TestValidationDisabledRules(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.DisabledRules = []string{"null_bytes", "missing_via"}
validator := NewSIPValidator(logger, config)
// Message with null byte should NOT trigger ban when rule disabled
nullMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"From: <sip:user\x00@example.com>;tag=abc\r\n" + // Null byte + missing Via
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n\r\n")
result := validator.Validate(nullMsg)
// Should not have null_bytes violation (disabled)
hasNullViolation := false
for _, v := range result.Violations {
if v.Rule == "null_bytes" {
hasNullViolation = true
break
}
}
if hasNullViolation {
t.Error("Disabled null_bytes rule should not trigger violation")
}
// Should not have missing_via violation (disabled)
hasViaViolation := false
for _, v := range result.Violations {
if v.Rule == "missing_via" {
hasViaViolation = true
break
}
}
if hasViaViolation {
t.Error("Disabled missing_via rule should not trigger violation")
}
}
func TestValidationPermissiveMode(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModePermissive
config.BanOnNullBytes = false // Disable ban in permissive
config.BanOnBinaryInjection = false
validator := NewSIPValidator(logger, config)
// Message missing Via - should log but not ban in permissive
noViaMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n\r\n")
result := validator.Validate(noViaMsg)
if result.ShouldBan {
t.Error("Permissive mode should not ban for missing headers")
}
if result.Valid {
t.Error("Message should still be marked invalid")
}
}
func TestValidationParanoidMode(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModeParanoid
validator := NewSIPValidator(logger, config)
// Valid message should pass even in paranoid mode
validMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n" +
"Content-Length: 0\r\n\r\n")
result := validator.Validate(validMsg)
if !result.Valid {
t.Errorf("Valid message should pass paranoid validation: %+v", result.Violations)
}
// Message with suspicious SQL-like pattern
sqlMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:' OR 1=1--@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result = validator.Validate(sqlMsg)
hasSQLViolation := false
for _, v := range result.Violations {
if v.Rule == "suspicious_sql_injection" {
hasSQLViolation = true
break
}
}
if !hasSQLViolation {
t.Error("Paranoid mode should detect SQL injection patterns")
}
}
func TestValidationRequestURI(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModeStrict
validator := NewSIPValidator(logger, config)
// Valid sip: URI
validSipMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
result := validator.Validate(validSipMsg)
hasURIViolation := false
for _, v := range result.Violations {
if v.Rule == "invalid_request_uri" {
hasURIViolation = true
break
}
}
if hasURIViolation {
t.Error("Valid sip: URI should not trigger violation")
}
// Valid tel: URI
validTelMsg := []byte("INVITE tel:+1-555-1234 SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <tel:+1-555-1234>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n\r\n")
result = validator.Validate(validTelMsg)
hasURIViolation = false
for _, v := range result.Violations {
if v.Rule == "invalid_request_uri" {
hasURIViolation = true
break
}
}
if hasURIViolation {
t.Error("Valid tel: URI should not trigger violation")
}
// Invalid URI scheme
invalidURIMsg := []byte("INVITE http://example.com/attack SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 INVITE\r\n" +
"Max-Forwards: 70\r\n\r\n")
result = validator.Validate(invalidURIMsg)
hasURIViolation = false
for _, v := range result.Violations {
if v.Rule == "invalid_request_uri" {
hasURIViolation = true
break
}
}
if !hasURIViolation {
t.Error("Invalid URI scheme should trigger violation")
}
}
func TestValidatorGlobalInstance(t *testing.T) {
logger := zap.NewNop()
// Get global validator
v1 := GetValidator(logger)
v2 := GetValidator(logger)
if v1 != v2 {
t.Error("GetValidator should return the same instance")
}
// Update config
config := DefaultValidationConfig()
config.MaxMessageSize = 12345
SetValidationConfig(config)
// Should reflect updated config
v3 := GetValidator(logger)
stats := v3.GetStats()
if stats["config_enabled"] != true {
t.Error("Config should be enabled")
}
}
func TestValidatorStats(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
validator := NewSIPValidator(logger, config)
// Validate some messages
validMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Max-Forwards: 70\r\n\r\n")
invalidMsg := []byte("REGISTER sip:example.com SIP/2.0\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" + // Missing Via
"CSeq: 1 REGISTER\r\n\r\n")
validator.Validate(validMsg)
validator.Validate(validMsg)
validator.Validate(invalidMsg)
stats := validator.GetStats()
if stats["total_validated"].(int64) != 3 {
t.Errorf("Expected 3 total validated, got %v", stats["total_validated"])
}
if stats["total_valid"].(int64) != 2 {
t.Errorf("Expected 2 total valid, got %v", stats["total_valid"])
}
if stats["total_invalid"].(int64) != 1 {
t.Errorf("Expected 1 total invalid, got %v", stats["total_invalid"])
}
violations := stats["violations_by_rule"].(map[string]int64)
if violations["missing_via"] != 1 {
t.Errorf("Expected 1 missing_via violation, got %d", violations["missing_via"])
}
}
func TestValidationSIPResponse(t *testing.T) {
logger := zap.NewNop()
config := DefaultValidationConfig()
config.Mode = ValidationModeStrict
validator := NewSIPValidator(logger, config)
// SIP response (starts with SIP/2.0)
responseMsg := []byte("SIP/2.0 200 OK\r\n" +
"Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123;received=10.0.0.1\r\n" +
"From: <sip:user@example.com>;tag=abc\r\n" +
"To: <sip:user@example.com>;tag=xyz\r\n" +
"Call-ID: 123456@192.168.1.1\r\n" +
"CSeq: 1 REGISTER\r\n" +
"Contact: <sip:user@10.0.0.1:5060>\r\n" +
"Content-Length: 0\r\n\r\n")
result := validator.Validate(responseMsg)
// Responses should not trigger invalid_request_uri
hasURIViolation := false
for _, v := range result.Violations {
if v.Rule == "invalid_request_uri" {
hasURIViolation = true
break
}
}
if hasURIViolation {
t.Error("SIP responses should not be checked for Request-URI")
}
}