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
}
417 lines
9.7 KiB
Go
417 lines
9.7 KiB
Go
package sipguardian
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// SIPMessage represents a parsed SIP message (request or response)
|
|
type SIPMessage struct {
|
|
// Common fields
|
|
IsRequest bool
|
|
SIPVersion string
|
|
Headers []SIPHeader
|
|
Body []byte
|
|
|
|
// Request fields
|
|
Method string
|
|
RequestURI string
|
|
|
|
// Response fields
|
|
StatusCode int
|
|
ReasonPhrase string
|
|
}
|
|
|
|
// SIPHeader represents a single SIP header
|
|
type SIPHeader struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
// Common errors
|
|
var (
|
|
ErrInvalidSIPMessage = errors.New("invalid SIP message format")
|
|
ErrEmptyMessage = errors.New("empty message")
|
|
ErrNoStartLine = errors.New("missing start line")
|
|
)
|
|
|
|
// ParseSIPMessage parses raw bytes into a SIPMessage structure
|
|
func ParseSIPMessage(data []byte) (*SIPMessage, error) {
|
|
if len(data) == 0 {
|
|
return nil, ErrEmptyMessage
|
|
}
|
|
|
|
msg := &SIPMessage{
|
|
Headers: make([]SIPHeader, 0),
|
|
}
|
|
|
|
// Split into headers and body at double CRLF
|
|
parts := bytes.SplitN(data, []byte("\r\n\r\n"), 2)
|
|
headerSection := parts[0]
|
|
if len(parts) > 1 {
|
|
msg.Body = parts[1]
|
|
}
|
|
|
|
// Split header section into lines
|
|
lines := bytes.Split(headerSection, []byte("\r\n"))
|
|
if len(lines) == 0 {
|
|
return nil, ErrNoStartLine
|
|
}
|
|
|
|
// Parse start line (first line)
|
|
if err := msg.parseStartLine(string(lines[0])); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse headers (remaining lines)
|
|
for i := 1; i < len(lines); i++ {
|
|
line := string(lines[i])
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Handle header continuation (line starting with whitespace)
|
|
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
|
|
// Append to previous header
|
|
if len(msg.Headers) > 0 {
|
|
msg.Headers[len(msg.Headers)-1].Value += " " + strings.TrimSpace(line)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Parse header name: value
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx > 0 {
|
|
name := strings.TrimSpace(line[:colonIdx])
|
|
value := strings.TrimSpace(line[colonIdx+1:])
|
|
msg.Headers = append(msg.Headers, SIPHeader{
|
|
Name: name,
|
|
Value: value,
|
|
})
|
|
}
|
|
}
|
|
|
|
return msg, nil
|
|
}
|
|
|
|
// parseStartLine parses the first line of a SIP message
|
|
func (m *SIPMessage) parseStartLine(line string) error {
|
|
parts := strings.Fields(line)
|
|
if len(parts) < 2 {
|
|
return ErrInvalidSIPMessage
|
|
}
|
|
|
|
// Check if it's a response (starts with SIP/2.0)
|
|
if strings.HasPrefix(parts[0], "SIP/") {
|
|
m.IsRequest = false
|
|
m.SIPVersion = parts[0]
|
|
|
|
// Parse status code
|
|
if len(parts) < 2 {
|
|
return ErrInvalidSIPMessage
|
|
}
|
|
code, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return fmt.Errorf("invalid status code: %v", err)
|
|
}
|
|
m.StatusCode = code
|
|
|
|
// Reason phrase is the rest
|
|
if len(parts) >= 3 {
|
|
m.ReasonPhrase = strings.Join(parts[2:], " ")
|
|
}
|
|
} else {
|
|
// It's a request
|
|
m.IsRequest = true
|
|
m.Method = parts[0]
|
|
|
|
if len(parts) < 3 {
|
|
return ErrInvalidSIPMessage
|
|
}
|
|
m.RequestURI = parts[1]
|
|
m.SIPVersion = parts[2]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Serialize converts the SIPMessage back to wire format
|
|
func (m *SIPMessage) Serialize() []byte {
|
|
var buf bytes.Buffer
|
|
|
|
// Write start line
|
|
if m.IsRequest {
|
|
buf.WriteString(m.Method)
|
|
buf.WriteByte(' ')
|
|
buf.WriteString(m.RequestURI)
|
|
buf.WriteByte(' ')
|
|
buf.WriteString(m.SIPVersion)
|
|
} else {
|
|
buf.WriteString(m.SIPVersion)
|
|
buf.WriteByte(' ')
|
|
buf.WriteString(strconv.Itoa(m.StatusCode))
|
|
buf.WriteByte(' ')
|
|
buf.WriteString(m.ReasonPhrase)
|
|
}
|
|
buf.WriteString("\r\n")
|
|
|
|
// Write headers
|
|
for _, h := range m.Headers {
|
|
buf.WriteString(h.Name)
|
|
buf.WriteString(": ")
|
|
buf.WriteString(h.Value)
|
|
buf.WriteString("\r\n")
|
|
}
|
|
|
|
// End of headers
|
|
buf.WriteString("\r\n")
|
|
|
|
// Write body if present
|
|
if len(m.Body) > 0 {
|
|
buf.Write(m.Body)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// GetHeader returns the first header with the given name (case-insensitive)
|
|
func (m *SIPMessage) GetHeader(name string) *SIPHeader {
|
|
lowerName := strings.ToLower(name)
|
|
// Also check compact form
|
|
compactName := getCompactForm(lowerName)
|
|
|
|
for i := range m.Headers {
|
|
headerLower := strings.ToLower(m.Headers[i].Name)
|
|
if headerLower == lowerName || headerLower == compactName {
|
|
return &m.Headers[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetHeaders returns all headers with the given name (case-insensitive)
|
|
func (m *SIPMessage) GetHeaders(name string) []*SIPHeader {
|
|
lowerName := strings.ToLower(name)
|
|
compactName := getCompactForm(lowerName)
|
|
|
|
var result []*SIPHeader
|
|
for i := range m.Headers {
|
|
headerLower := strings.ToLower(m.Headers[i].Name)
|
|
if headerLower == lowerName || headerLower == compactName {
|
|
result = append(result, &m.Headers[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetHeader sets a header value, replacing existing or adding new
|
|
func (m *SIPMessage) SetHeader(name, value string) {
|
|
lowerName := strings.ToLower(name)
|
|
compactName := getCompactForm(lowerName)
|
|
|
|
for i := range m.Headers {
|
|
headerLower := strings.ToLower(m.Headers[i].Name)
|
|
if headerLower == lowerName || headerLower == compactName {
|
|
m.Headers[i].Value = value
|
|
return
|
|
}
|
|
}
|
|
// Not found, add new
|
|
m.Headers = append(m.Headers, SIPHeader{Name: name, Value: value})
|
|
}
|
|
|
|
// PrependHeader adds a header at the beginning of the header list
|
|
func (m *SIPMessage) PrependHeader(name, value string) {
|
|
newHeader := SIPHeader{Name: name, Value: value}
|
|
m.Headers = append([]SIPHeader{newHeader}, m.Headers...)
|
|
}
|
|
|
|
// AppendHeader adds a header at the end of the header list
|
|
func (m *SIPMessage) AppendHeader(name, value string) {
|
|
m.Headers = append(m.Headers, SIPHeader{Name: name, Value: value})
|
|
}
|
|
|
|
// RemoveHeader removes all headers with the given name (case-insensitive)
|
|
func (m *SIPMessage) RemoveHeader(name string) {
|
|
lowerName := strings.ToLower(name)
|
|
compactName := getCompactForm(lowerName)
|
|
|
|
newHeaders := make([]SIPHeader, 0, len(m.Headers))
|
|
for _, h := range m.Headers {
|
|
headerLower := strings.ToLower(h.Name)
|
|
if headerLower != lowerName && headerLower != compactName {
|
|
newHeaders = append(newHeaders, h)
|
|
}
|
|
}
|
|
m.Headers = newHeaders
|
|
}
|
|
|
|
// RemoveFirstHeader removes only the first header with the given name
|
|
func (m *SIPMessage) RemoveFirstHeader(name string) bool {
|
|
lowerName := strings.ToLower(name)
|
|
compactName := getCompactForm(lowerName)
|
|
|
|
for i, h := range m.Headers {
|
|
headerLower := strings.ToLower(h.Name)
|
|
if headerLower == lowerName || headerLower == compactName {
|
|
m.Headers = append(m.Headers[:i], m.Headers[i+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetCallID returns the Call-ID header value
|
|
func (m *SIPMessage) GetCallID() string {
|
|
if h := m.GetHeader("Call-ID"); h != nil {
|
|
return h.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetFromTag extracts the tag parameter from the From header
|
|
func (m *SIPMessage) GetFromTag() string {
|
|
if h := m.GetHeader("From"); h != nil {
|
|
return extractTagParam(h.Value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetToTag extracts the tag parameter from the To header
|
|
func (m *SIPMessage) GetToTag() string {
|
|
if h := m.GetHeader("To"); h != nil {
|
|
return extractTagParam(h.Value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetCSeq returns the CSeq number and method
|
|
func (m *SIPMessage) GetCSeq() (int, string) {
|
|
if h := m.GetHeader("CSeq"); h != nil {
|
|
parts := strings.Fields(h.Value)
|
|
if len(parts) >= 2 {
|
|
seq, _ := strconv.Atoi(parts[0])
|
|
return seq, parts[1]
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
// GetViaBranch extracts the branch parameter from the top Via header
|
|
func (m *SIPMessage) GetViaBranch() string {
|
|
if h := m.GetHeader("Via"); h != nil {
|
|
return extractViaParam(h.Value, "branch")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Clone creates a deep copy of the message
|
|
func (m *SIPMessage) Clone() *SIPMessage {
|
|
clone := &SIPMessage{
|
|
IsRequest: m.IsRequest,
|
|
SIPVersion: m.SIPVersion,
|
|
Method: m.Method,
|
|
RequestURI: m.RequestURI,
|
|
StatusCode: m.StatusCode,
|
|
ReasonPhrase: m.ReasonPhrase,
|
|
Headers: make([]SIPHeader, len(m.Headers)),
|
|
}
|
|
|
|
copy(clone.Headers, m.Headers)
|
|
|
|
if len(m.Body) > 0 {
|
|
clone.Body = make([]byte, len(m.Body))
|
|
copy(clone.Body, m.Body)
|
|
}
|
|
|
|
return clone
|
|
}
|
|
|
|
// getCompactForm returns the compact form of a header name
|
|
func getCompactForm(name string) string {
|
|
compactForms := map[string]string{
|
|
"call-id": "i",
|
|
"contact": "m",
|
|
"content-encoding": "e",
|
|
"content-length": "l",
|
|
"content-type": "c",
|
|
"from": "f",
|
|
"subject": "s",
|
|
"supported": "k",
|
|
"to": "t",
|
|
"via": "v",
|
|
}
|
|
if compact, ok := compactForms[name]; ok {
|
|
return compact
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractTagParam extracts the tag parameter from a From/To header value
|
|
func extractTagParam(headerValue string) string {
|
|
// Look for ;tag= parameter
|
|
idx := strings.Index(strings.ToLower(headerValue), ";tag=")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
|
|
tagStart := idx + 5 // len(";tag=")
|
|
rest := headerValue[tagStart:]
|
|
|
|
// Find end of tag value (semicolon or end of string)
|
|
endIdx := strings.IndexAny(rest, ";,>")
|
|
if endIdx < 0 {
|
|
return rest
|
|
}
|
|
return rest[:endIdx]
|
|
}
|
|
|
|
// extractViaParam extracts a parameter from a Via header value
|
|
func extractViaParam(headerValue, param string) string {
|
|
lower := strings.ToLower(headerValue)
|
|
search := ";" + param + "="
|
|
|
|
idx := strings.Index(lower, search)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
|
|
valueStart := idx + len(search)
|
|
rest := headerValue[valueStart:]
|
|
|
|
// Find end of value
|
|
endIdx := strings.IndexAny(rest, ";,")
|
|
if endIdx < 0 {
|
|
return rest
|
|
}
|
|
return rest[:endIdx]
|
|
}
|
|
|
|
// IsDialogCreating returns true if this is a dialog-creating request
|
|
func (m *SIPMessage) IsDialogCreating() bool {
|
|
if !m.IsRequest {
|
|
return false
|
|
}
|
|
switch m.Method {
|
|
case "INVITE", "SUBSCRIBE", "REFER", "NOTIFY":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsDialogTerminating returns true if this ends a dialog
|
|
func (m *SIPMessage) IsDialogTerminating() bool {
|
|
if !m.IsRequest {
|
|
return false
|
|
}
|
|
return m.Method == "BYE"
|
|
}
|
|
|
|
// UpdateContentLength updates the Content-Length header to match body size
|
|
func (m *SIPMessage) UpdateContentLength() {
|
|
m.SetHeader("Content-Length", strconv.Itoa(len(m.Body)))
|
|
}
|