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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=1234\r\n" + // f = From "t: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + // Null byte + missing Via "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;tag=abc\r\n" + "To: \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: ;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: ;tag=abc\r\n" + "To: ;tag=xyz\r\n" + "Call-ID: 123456@192.168.1.1\r\n" + "CSeq: 1 REGISTER\r\n" + "Contact: \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") } }