Implements RFC 3261 compliant topology hiding to protect internal
infrastructure from external attackers:
New files:
- sipmsg.go: SIP message parsing/serialization with full header support
- sipheaders.go: Via, Contact, From/To header parsing with compact forms
- dialog_state.go: Dialog and transaction state management for response correlation
- topology.go: TopologyHider handler for caddy-l4 integration
- topology_test.go: Comprehensive unit tests (26 new tests, 60 total)
Features:
- Via header insertion (proxy adds own Via, pops on response)
- Contact header rewriting (hide internal IPs behind proxy address)
- Sensitive header stripping (P-Asserted-Identity, Server, etc.)
- Call-ID anonymization (optional)
- Private IP masking in all headers
- Dialog state tracking for stateful response routing
- Transaction state for stateless operation
Caddyfile configuration:
sip_topology_hider {
proxy_host 203.0.113.1
proxy_port 5060
upstream udp/192.168.1.100:5060
rewrite_via
rewrite_contact
strip_headers P-Preferred-Identity Server
}
467 lines
10 KiB
Go
467 lines
10 KiB
Go
package sipguardian
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ViaHeader represents a parsed Via header
|
|
type ViaHeader struct {
|
|
Protocol string // SIP/2.0
|
|
Transport string // UDP, TCP, TLS, WS, WSS
|
|
Host string
|
|
Port int
|
|
Branch string
|
|
Received string
|
|
RPort int
|
|
Params map[string]string // Other parameters
|
|
}
|
|
|
|
// ContactHeader represents a parsed Contact header
|
|
type ContactHeader struct {
|
|
DisplayName string
|
|
URI string
|
|
Params map[string]string
|
|
}
|
|
|
|
// FromToHeader represents a parsed From or To header
|
|
type FromToHeader struct {
|
|
DisplayName string
|
|
URI string
|
|
Tag string
|
|
Params map[string]string
|
|
}
|
|
|
|
// SensitiveHeaders lists headers that may leak internal topology
|
|
var SensitiveHeaders = []string{
|
|
"P-Preferred-Identity",
|
|
"P-Asserted-Identity",
|
|
"Remote-Party-ID",
|
|
"P-Charging-Vector",
|
|
"P-Charging-Function-Addresses",
|
|
"Server",
|
|
"User-Agent",
|
|
"X-Asterisk-HangupCause",
|
|
"X-Asterisk-HangupCauseCode",
|
|
}
|
|
|
|
// ParseViaHeader parses a Via header value into structured form
|
|
// Example: "SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK123;received=10.0.0.1"
|
|
func ParseViaHeader(value string) (*ViaHeader, error) {
|
|
via := &ViaHeader{
|
|
Params: make(map[string]string),
|
|
}
|
|
|
|
// Split by semicolons to get parameters
|
|
parts := strings.Split(value, ";")
|
|
if len(parts) == 0 {
|
|
return nil, fmt.Errorf("empty Via header")
|
|
}
|
|
|
|
// First part: SIP/2.0/UDP host:port
|
|
mainPart := strings.TrimSpace(parts[0])
|
|
fields := strings.Fields(mainPart)
|
|
if len(fields) < 2 {
|
|
return nil, fmt.Errorf("invalid Via header format")
|
|
}
|
|
|
|
// Parse protocol/transport
|
|
protoTransport := strings.Split(fields[0], "/")
|
|
if len(protoTransport) >= 2 {
|
|
via.Protocol = protoTransport[0] + "/" + protoTransport[1]
|
|
}
|
|
if len(protoTransport) >= 3 {
|
|
via.Transport = protoTransport[2]
|
|
}
|
|
|
|
// Parse host:port
|
|
hostPort := fields[1]
|
|
if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx > 0 {
|
|
via.Host = hostPort[:colonIdx]
|
|
port, _ := strconv.Atoi(hostPort[colonIdx+1:])
|
|
via.Port = port
|
|
} else {
|
|
via.Host = hostPort
|
|
via.Port = 5060 // Default SIP port
|
|
}
|
|
|
|
// Parse parameters
|
|
for i := 1; i < len(parts); i++ {
|
|
param := strings.TrimSpace(parts[i])
|
|
if eqIdx := strings.Index(param, "="); eqIdx > 0 {
|
|
key := strings.ToLower(param[:eqIdx])
|
|
val := param[eqIdx+1:]
|
|
|
|
switch key {
|
|
case "branch":
|
|
via.Branch = val
|
|
case "received":
|
|
via.Received = val
|
|
case "rport":
|
|
via.RPort, _ = strconv.Atoi(val)
|
|
default:
|
|
via.Params[key] = val
|
|
}
|
|
} else {
|
|
// Flag parameter (no value)
|
|
via.Params[strings.ToLower(param)] = ""
|
|
}
|
|
}
|
|
|
|
return via, nil
|
|
}
|
|
|
|
// Serialize converts ViaHeader back to string format
|
|
func (v *ViaHeader) Serialize() string {
|
|
var buf strings.Builder
|
|
|
|
// Protocol and transport
|
|
buf.WriteString(v.Protocol)
|
|
buf.WriteByte('/')
|
|
buf.WriteString(v.Transport)
|
|
buf.WriteByte(' ')
|
|
|
|
// Host and port
|
|
buf.WriteString(v.Host)
|
|
if v.Port > 0 && v.Port != 5060 {
|
|
buf.WriteByte(':')
|
|
buf.WriteString(strconv.Itoa(v.Port))
|
|
}
|
|
|
|
// Branch (required for RFC 3261 compliance)
|
|
if v.Branch != "" {
|
|
buf.WriteString(";branch=")
|
|
buf.WriteString(v.Branch)
|
|
}
|
|
|
|
// Received parameter
|
|
if v.Received != "" {
|
|
buf.WriteString(";received=")
|
|
buf.WriteString(v.Received)
|
|
}
|
|
|
|
// RPort parameter
|
|
if v.RPort > 0 {
|
|
buf.WriteString(";rport=")
|
|
buf.WriteString(strconv.Itoa(v.RPort))
|
|
}
|
|
|
|
// Other parameters
|
|
for key, val := range v.Params {
|
|
buf.WriteByte(';')
|
|
buf.WriteString(key)
|
|
if val != "" {
|
|
buf.WriteByte('=')
|
|
buf.WriteString(val)
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// ParseContactHeader parses a Contact header value
|
|
// Example: "\"Alice\" <sip:alice@example.com>;expires=3600"
|
|
func ParseContactHeader(value string) (*ContactHeader, error) {
|
|
contact := &ContactHeader{
|
|
Params: make(map[string]string),
|
|
}
|
|
|
|
// Handle * (special Contact for REGISTER)
|
|
if strings.TrimSpace(value) == "*" {
|
|
contact.URI = "*"
|
|
return contact, nil
|
|
}
|
|
|
|
// Extract display name if present (quoted)
|
|
if idx := strings.Index(value, "\""); idx >= 0 {
|
|
endIdx := strings.Index(value[idx+1:], "\"")
|
|
if endIdx > 0 {
|
|
contact.DisplayName = value[idx+1 : idx+1+endIdx]
|
|
value = value[idx+1+endIdx+1:]
|
|
}
|
|
}
|
|
|
|
// Extract URI (within angle brackets or until semicolon)
|
|
value = strings.TrimSpace(value)
|
|
if strings.HasPrefix(value, "<") {
|
|
endIdx := strings.Index(value, ">")
|
|
if endIdx > 0 {
|
|
contact.URI = value[1:endIdx]
|
|
value = value[endIdx+1:]
|
|
}
|
|
} else {
|
|
// No angle brackets - URI goes until semicolon or end
|
|
semiIdx := strings.Index(value, ";")
|
|
if semiIdx > 0 {
|
|
contact.URI = strings.TrimSpace(value[:semiIdx])
|
|
value = value[semiIdx:]
|
|
} else {
|
|
contact.URI = strings.TrimSpace(value)
|
|
value = ""
|
|
}
|
|
}
|
|
|
|
// Parse parameters
|
|
for _, part := range strings.Split(value, ";") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if eqIdx := strings.Index(part, "="); eqIdx > 0 {
|
|
contact.Params[strings.ToLower(part[:eqIdx])] = part[eqIdx+1:]
|
|
} else if part != "" {
|
|
contact.Params[strings.ToLower(part)] = ""
|
|
}
|
|
}
|
|
|
|
return contact, nil
|
|
}
|
|
|
|
// Serialize converts ContactHeader back to string format
|
|
func (c *ContactHeader) Serialize() string {
|
|
var buf strings.Builder
|
|
|
|
if c.URI == "*" {
|
|
return "*"
|
|
}
|
|
|
|
if c.DisplayName != "" {
|
|
buf.WriteByte('"')
|
|
buf.WriteString(c.DisplayName)
|
|
buf.WriteString("\" ")
|
|
}
|
|
|
|
buf.WriteByte('<')
|
|
buf.WriteString(c.URI)
|
|
buf.WriteByte('>')
|
|
|
|
for key, val := range c.Params {
|
|
buf.WriteByte(';')
|
|
buf.WriteString(key)
|
|
if val != "" {
|
|
buf.WriteByte('=')
|
|
buf.WriteString(val)
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// ParseFromToHeader parses a From or To header value
|
|
// Example: "\"Bob\" <sip:bob@example.com>;tag=abc123"
|
|
func ParseFromToHeader(value string) (*FromToHeader, error) {
|
|
header := &FromToHeader{
|
|
Params: make(map[string]string),
|
|
}
|
|
|
|
// Extract display name if present (quoted)
|
|
if idx := strings.Index(value, "\""); idx >= 0 {
|
|
endIdx := strings.Index(value[idx+1:], "\"")
|
|
if endIdx > 0 {
|
|
header.DisplayName = value[idx+1 : idx+1+endIdx]
|
|
value = value[idx+1+endIdx+1:]
|
|
}
|
|
}
|
|
|
|
// Extract URI (within angle brackets)
|
|
value = strings.TrimSpace(value)
|
|
if strings.HasPrefix(value, "<") {
|
|
endIdx := strings.Index(value, ">")
|
|
if endIdx > 0 {
|
|
header.URI = value[1:endIdx]
|
|
value = value[endIdx+1:]
|
|
}
|
|
} else {
|
|
// No angle brackets
|
|
semiIdx := strings.Index(value, ";")
|
|
if semiIdx > 0 {
|
|
header.URI = strings.TrimSpace(value[:semiIdx])
|
|
value = value[semiIdx:]
|
|
} else {
|
|
header.URI = strings.TrimSpace(value)
|
|
value = ""
|
|
}
|
|
}
|
|
|
|
// Parse parameters
|
|
for _, part := range strings.Split(value, ";") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if eqIdx := strings.Index(part, "="); eqIdx > 0 {
|
|
key := strings.ToLower(part[:eqIdx])
|
|
val := part[eqIdx+1:]
|
|
if key == "tag" {
|
|
header.Tag = val
|
|
} else {
|
|
header.Params[key] = val
|
|
}
|
|
}
|
|
}
|
|
|
|
return header, nil
|
|
}
|
|
|
|
// Serialize converts FromToHeader back to string format
|
|
func (f *FromToHeader) Serialize() string {
|
|
var buf strings.Builder
|
|
|
|
if f.DisplayName != "" {
|
|
buf.WriteByte('"')
|
|
buf.WriteString(f.DisplayName)
|
|
buf.WriteString("\" ")
|
|
}
|
|
|
|
buf.WriteByte('<')
|
|
buf.WriteString(f.URI)
|
|
buf.WriteByte('>')
|
|
|
|
if f.Tag != "" {
|
|
buf.WriteString(";tag=")
|
|
buf.WriteString(f.Tag)
|
|
}
|
|
|
|
for key, val := range f.Params {
|
|
buf.WriteByte(';')
|
|
buf.WriteString(key)
|
|
if val != "" {
|
|
buf.WriteByte('=')
|
|
buf.WriteString(val)
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// ExtractHostFromURI extracts the host part from a SIP URI
|
|
// Example: "sip:user@host:5060;transport=udp" -> "host"
|
|
func ExtractHostFromURI(uri string) string {
|
|
// Remove scheme
|
|
if idx := strings.Index(uri, ":"); idx >= 0 {
|
|
uri = uri[idx+1:]
|
|
}
|
|
|
|
// Remove user part
|
|
if idx := strings.Index(uri, "@"); idx >= 0 {
|
|
uri = uri[idx+1:]
|
|
}
|
|
|
|
// Remove port and parameters
|
|
if idx := strings.Index(uri, ":"); idx >= 0 {
|
|
uri = uri[:idx]
|
|
}
|
|
if idx := strings.Index(uri, ";"); idx >= 0 {
|
|
uri = uri[:idx]
|
|
}
|
|
|
|
return uri
|
|
}
|
|
|
|
// ExtractPortFromURI extracts the port from a SIP URI (default 5060)
|
|
func ExtractPortFromURI(uri string) int {
|
|
// Remove scheme and user part
|
|
if idx := strings.Index(uri, "@"); idx >= 0 {
|
|
uri = uri[idx+1:]
|
|
} else if idx := strings.Index(uri, ":"); idx >= 0 {
|
|
uri = uri[idx+1:]
|
|
}
|
|
|
|
// Extract port
|
|
if colonIdx := strings.Index(uri, ":"); colonIdx >= 0 {
|
|
portStr := uri[colonIdx+1:]
|
|
if semiIdx := strings.Index(portStr, ";"); semiIdx >= 0 {
|
|
portStr = portStr[:semiIdx]
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err == nil {
|
|
return port
|
|
}
|
|
}
|
|
|
|
return 5060 // Default SIP port
|
|
}
|
|
|
|
// RewriteURIHost replaces the host in a SIP URI
|
|
func RewriteURIHost(uri, newHost string, newPort int) string {
|
|
// Pattern to match SIP URI
|
|
re := regexp.MustCompile(`^(sips?:)([^@]+@)?([^:;>]+)(:\d+)?(.*)$`)
|
|
matches := re.FindStringSubmatch(uri)
|
|
if matches == nil {
|
|
return uri
|
|
}
|
|
|
|
// Rebuild URI with new host
|
|
result := matches[1] + matches[2] + newHost
|
|
if newPort > 0 && newPort != 5060 {
|
|
result += ":" + strconv.Itoa(newPort)
|
|
}
|
|
result += matches[5]
|
|
|
|
return result
|
|
}
|
|
|
|
// GenerateBranch generates an RFC 3261 compliant branch parameter
|
|
func GenerateBranch() string {
|
|
// Must start with z9hG4bK for RFC 3261 compliance
|
|
return "z9hG4bK" + generateRandomHex(16)
|
|
}
|
|
|
|
// generateRandomHex generates a random hex string of given length
|
|
func generateRandomHex(length int) string {
|
|
const chars = "0123456789abcdef"
|
|
result := make([]byte, length)
|
|
for i := range result {
|
|
// Use uint64 modulo to avoid negative index from int conversion
|
|
result[i] = chars[pseudoRand()%uint64(len(chars))]
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
// Simple pseudo-random number generator (not cryptographically secure)
|
|
var prngState uint64 = 0xdeadbeef
|
|
|
|
func pseudoRand() uint64 {
|
|
prngState ^= prngState << 13
|
|
prngState ^= prngState >> 7
|
|
prngState ^= prngState << 17
|
|
return prngState
|
|
}
|
|
|
|
// InitPRNG seeds the pseudo-random number generator
|
|
func InitPRNG(seed uint64) {
|
|
prngState = seed
|
|
if prngState == 0 {
|
|
prngState = 0xdeadbeef
|
|
}
|
|
}
|
|
|
|
// Note: IsPrivateIP is defined in geoip.go with proper CIDR parsing
|
|
|
|
// NormalizeHeaderName converts header name to canonical form
|
|
func NormalizeHeaderName(name string) string {
|
|
// Map compact forms to full names
|
|
compactToFull := map[string]string{
|
|
"i": "Call-ID",
|
|
"m": "Contact",
|
|
"e": "Content-Encoding",
|
|
"l": "Content-Length",
|
|
"c": "Content-Type",
|
|
"f": "From",
|
|
"s": "Subject",
|
|
"k": "Supported",
|
|
"t": "To",
|
|
"v": "Via",
|
|
}
|
|
|
|
lower := strings.ToLower(name)
|
|
if full, ok := compactToFull[lower]; ok {
|
|
return full
|
|
}
|
|
|
|
// Title case for standard headers
|
|
return strings.Title(lower)
|
|
}
|