diff --git a/pkg/sip/crlf_normalizer.go b/pkg/sip/crlf_normalizer.go index bc54a8f..1b6d8ba 100644 --- a/pkg/sip/crlf_normalizer.go +++ b/pkg/sip/crlf_normalizer.go @@ -5,11 +5,31 @@ import ( "net" ) -// normalizeCRLF replaces bare \n (not preceded by \r) with \r\n in SIP messages. -// This handles non-compliant devices that send LF-only line endings instead of -// the CRLF required by RFC 3261 Section 7. -func normalizeCRLF(data []byte) []byte { - // Fast path: if no bare \n exists, return as-is +// findHeaderBodySplit returns the byte offset where the SIP body begins +// (immediately after the blank-line separator). Returns -1 if no separator +// is found, meaning the data is likely all headers. +// +// Handles all line-ending variants: \r\n\r\n, \n\n, \r\n\n, \n\r\n. +func findHeaderBodySplit(data []byte) int { + for i := 0; i < len(data)-1; i++ { + if data[i] != '\n' { + continue + } + // We're at a \n. The next "line" starts at i+1. + // A blank line means the next char is also a line ending. + if data[i+1] == '\n' { + return i + 2 // \n\n + } + if data[i+1] == '\r' && i+2 < len(data) && data[i+2] == '\n' { + return i + 3 // \n\r\n + } + } + return -1 +} + +// normalizeCRLFBytes replaces bare \n (not preceded by \r) with \r\n. +func normalizeCRLFBytes(data []byte) []byte { + // Fast path: if no \n exists, return as-is if !bytes.Contains(data, []byte("\n")) { return data } @@ -28,7 +48,7 @@ func normalizeCRLF(data []byte) []byte { // Replace bare \n with \r\n var buf bytes.Buffer - buf.Grow(len(data) + 64) // pre-allocate with some extra room + buf.Grow(len(data) + 64) for i := 0; i < len(data); i++ { if data[i] == '\n' && (i == 0 || data[i-1] != '\r') { buf.WriteByte('\r') @@ -38,6 +58,33 @@ func normalizeCRLF(data []byte) []byte { return buf.Bytes() } +// normalizeCRLF replaces bare \n with \r\n only in the SIP header section, +// leaving the message body untouched. This prevents Content-Length / body-size +// mismatch when an SBC (e.g. AudioCodes) sends bare LFs in the body: expanding +// those LFs would make the body longer than Content-Length declares, causing +// sipgo to truncate the body and break multipart SIPREC parsing. +func normalizeCRLF(data []byte) []byte { + split := findHeaderBodySplit(data) + if split < 0 || split >= len(data) { + // No separator or no body — normalize everything (all headers) + return normalizeCRLFBytes(data) + } + + headerSection := data[:split] + normalized := normalizeCRLFBytes(headerSection) + if len(normalized) == split { + // Headers unchanged — return original slice as-is + return data + } + + // Headers expanded — rebuild: normalized headers + original body + body := data[split:] + result := make([]byte, len(normalized)+len(body)) + copy(result, normalized) + copy(result[len(normalized):], body) + return result +} + // crlfPacketConn wraps a net.PacketConn to normalize bare \n to \r\n // in incoming UDP packets before sipgo's parser processes them. type crlfPacketConn struct { @@ -50,20 +97,23 @@ func (c *crlfPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { return n, addr, err } + // normalizeCRLF only touches headers (before the blank-line separator), + // leaving the body intact so Content-Length stays accurate. normalized := normalizeCRLF(p[:n]) if len(normalized) == n { - // No change or same length — data is already in p + // No change — data is already in p (or same-length slice) return n, addr, nil } - // Normalized data is longer; copy back if it fits + // Normalized data is longer (header expansion); copy back if it fits. + // normalizeCRLF returns a fresh allocation when it changes data, so + // copying into p is safe even though body bytes originally lived there. if len(normalized) <= len(p) { copy(p, normalized) return len(normalized), addr, nil } // Extremely unlikely: normalized data exceeds buffer. - // Copy what fits — sipgo will handle the truncation. copy(p, normalized) return len(p), addr, nil } @@ -101,6 +151,7 @@ func (c *crlfConn) Read(p []byte) (int, error) { return n, err } + // normalizeCRLF only touches headers, leaving the body intact. normalized := normalizeCRLF(p[:n]) if len(normalized) == n { return n, err diff --git a/pkg/sip/crlf_normalizer_test.go b/pkg/sip/crlf_normalizer_test.go index c99fbc9..021fd21 100644 --- a/pkg/sip/crlf_normalizer_test.go +++ b/pkg/sip/crlf_normalizer_test.go @@ -2,7 +2,11 @@ package sip import ( "bytes" + "fmt" + "mime" + "mime/multipart" "net" + "strings" "testing" "time" ) @@ -60,6 +64,315 @@ func TestNormalizeCRLF(t *testing.T) { } } +// TestFindHeaderBodySplit exercises every separator variant and edge case. +func TestFindHeaderBodySplit(t *testing.T) { + tests := []struct { + name string + input string + want int // expected split index, -1 if none + }{ + {"CRLF separator", "H1: v\r\nH2: v\r\n\r\nbody", 16}, + {"bare LF separator", "H1: v\nH2: v\n\nbody", 13}, + {"mixed CRLF header + LF separator", "H1: v\r\nH2: v\n\nbody", 14}, + {"LF header + CRLF separator", "H1: v\nH2: v\n\r\nbody", 14}, + {"no separator", "H1: v\r\nH2: v\r\n", -1}, + {"empty", "", -1}, + {"just separator LF", "\n\n", 2}, + {"just separator CRLF", "\r\n\r\n", 4}, + {"body contains newlines", "H: v\n\nbody\nwith\nnewlines", 6}, + {"single LF", "\n", -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findHeaderBodySplit([]byte(tt.input)) + if got != tt.want { + t.Errorf("findHeaderBodySplit(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +// TestNormalizeCRLF_BodyPreserved is the key regression test for issue #44: +// bare LFs in headers must be normalized, but body bytes must be untouched +// so that Content-Length still matches. +func TestNormalizeCRLF_BodyPreserved(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "bare LF headers + bare LF body stays intact", + input: "INVITE sip:x SIP/2.0\nContent-Length: 20\n\n" + + "body\nwith\nbare\nLFs\n", + expected: "INVITE sip:x SIP/2.0\r\nContent-Length: 20\r\n\r\n" + + "body\nwith\nbare\nLFs\n", + }, + { + name: "CRLF headers + body with bare LFs untouched", + input: "INVITE sip:x SIP/2.0\r\nContent-Length: 10\r\n\r\n" + + "body\ndata\n", + expected: "INVITE sip:x SIP/2.0\r\nContent-Length: 10\r\n\r\n" + + "body\ndata\n", + }, + { + name: "headers only (no body) still normalized", + input: "INVITE sip:x SIP/2.0\nContent-Length: 0\n\n", + expected: "INVITE sip:x SIP/2.0\r\nContent-Length: 0\r\n\r\n", + }, + { + name: "no separator — all gets normalized (header-only packet)", + input: "INVITE sip:x SIP/2.0\nVia: SIP/2.0/UDP host", + expected: "INVITE sip:x SIP/2.0\r\nVia: SIP/2.0/UDP host", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeCRLF([]byte(tt.input)) + if !bytes.Equal(result, []byte(tt.expected)) { + t.Errorf("got:\n %q\nwant:\n %q", result, tt.expected) + } + }) + } +} + +// TestNormalizeCRLF_AudioCodesMultipartBody reproduces issue #44 end-to-end: +// an AudioCodes SBC sends a SIPREC INVITE with bare LFs in both headers and +// the multipart body. Content-Length is computed on the raw (bare-LF) body. +// After normalization, Content-Length must still match the body size, and the +// multipart parser must find both the SDP and rs-metadata parts. +func TestNormalizeCRLF_AudioCodesMultipartBody(t *testing.T) { + boundary := "boundary_ac14d5" + + // Build a multipart body with bare LFs (as AudioCodes might send) + body := "--" + boundary + "\n" + + "Content-Type: application/sdp\n" + + "\n" + + "v=0\n" + + "o=AudiocodesGW 1644952274 165864691 IN IP4 192.168.100.21\n" + + "s=SBC-Call\n" + + "c=IN IP4 192.168.100.21\n" + + "t=0 0\n" + + "m=audio 7716 RTP/AVP 8 101\n" + + "a=rtpmap:8 PCMA/8000\n" + + "\n" + + "--" + boundary + "\n" + + "Content-Type: application/rs-metadata+xml\n" + + "Content-Disposition: recording-session\n" + + "\n" + + `` + "\n" + + `` + "\n" + + " complete\n" + + " \n" + + " group1\n" + + " \n" + + "\n" + + "\n" + + "--" + boundary + "--\n" + + contentLength := len(body) // calculated on bare-LF body, like the SBC does + + // Build the full SIP packet with bare-LF headers + packet := fmt.Sprintf("INVITE sip:recorder@10.0.0.1 SIP/2.0\n"+ + "Via: SIP/2.0/UDP 192.168.100.21;branch=z9hG4bKac311994078\n"+ + "From: ;tag=1c1818087822\n"+ + "To: \n"+ + "Call-ID: 1330070767992026211126@192.168.100.21\n"+ + "CSeq: 1 INVITE\n"+ + "Content-Type: multipart/mixed;boundary=%s\n"+ + "Content-Length: %d\n"+ + "\n"+ + "%s", boundary, contentLength, body) + + // Normalize (simulating what crlfPacketConn does) + normalized := normalizeCRLF([]byte(packet)) + + // Parse out the Content-Length and body from the normalized packet + parts := bytes.SplitN(normalized, []byte("\r\n\r\n"), 2) + if len(parts) != 2 { + t.Fatal("normalized packet missing header/body separator") + } + headerSection := string(parts[0]) + bodySection := parts[1] + + // Extract Content-Length from normalized headers + var clValue int + for _, line := range strings.Split(headerSection, "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "content-length:") { + fmt.Sscanf(strings.TrimSpace(line[len("Content-Length:"):]), "%d", &clValue) + } + } + + // THE KEY ASSERTION: body length must equal Content-Length + if len(bodySection) != clValue { + t.Fatalf("Content-Length %d != actual body length %d (body truncation bug!)", + clValue, len(bodySection)) + } + + // Verify the multipart body is still parseable + ct := "multipart/mixed;boundary=" + boundary + _, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatalf("parse media type: %v", err) + } + + mr := multipart.NewReader(bytes.NewReader(bodySection), params["boundary"]) + var foundSDP, foundMetadata bool + for { + part, err := mr.NextPart() + if err != nil { + break + } + ct := part.Header.Get("Content-Type") + switch { + case strings.Contains(ct, "sdp"): + foundSDP = true + case strings.Contains(ct, "rs-metadata"): + foundMetadata = true + } + } + + if !foundSDP { + t.Error("multipart parser did not find SDP part") + } + if !foundMetadata { + t.Error("multipart parser did not find rs-metadata part — body was likely truncated") + } +} + +// TestNormalizeCRLF_BodyBinaryData ensures binary body content (e.g. RTP +// payload or base64 with 0x0a bytes) isn't corrupted by normalization. +func TestNormalizeCRLF_BodyBinaryData(t *testing.T) { + // Body contains raw 0x0a (LF) bytes that are NOT line endings + binaryBody := []byte{0x01, 0x0a, 0x0d, 0x0a, 0xff, 0x0a, 0x0a, 0x00} + + header := "INVITE sip:x SIP/2.0\nContent-Length: 8\n\n" + packet := append([]byte(header), binaryBody...) + + normalized := normalizeCRLF(packet) + + // Find body in normalized output + idx := bytes.Index(normalized, []byte("\r\n\r\n")) + if idx < 0 { + t.Fatal("no header/body separator after normalization") + } + resultBody := normalized[idx+4:] + + if !bytes.Equal(resultBody, binaryBody) { + t.Errorf("body corrupted:\n got: %x\n want: %x", resultBody, binaryBody) + } +} + +// TestNormalizeCRLF_LargeMultipartBody ensures a large SIPREC body with many +// bare-LF lines doesn't get truncated (the original bug scenario: 60+ lines +// of XML metadata with bare LFs causing ~60 extra bytes of expansion). +func TestNormalizeCRLF_LargeMultipartBody(t *testing.T) { + // Build a body with 100 lines of XML using bare LFs + var bodyBuilder strings.Builder + bodyBuilder.WriteString("--boundary123\n") + bodyBuilder.WriteString("Content-Type: application/sdp\n\nv=0\n\n") + bodyBuilder.WriteString("--boundary123\n") + bodyBuilder.WriteString("Content-Type: application/rs-metadata+xml\n\n") + for i := 0; i < 100; i++ { + fmt.Fprintf(&bodyBuilder, " data\n", i) + } + bodyBuilder.WriteString("--boundary123--\n") + body := bodyBuilder.String() + + contentLength := len(body) + + packet := fmt.Sprintf("INVITE sip:x SIP/2.0\n"+ + "Content-Type: multipart/mixed;boundary=boundary123\n"+ + "Content-Length: %d\n\n%s", contentLength, body) + + normalized := normalizeCRLF([]byte(packet)) + + // Extract body after the normalized separator + idx := bytes.Index(normalized, []byte("\r\n\r\n")) + if idx < 0 { + t.Fatal("missing separator") + } + resultBody := normalized[idx+4:] + + if len(resultBody) != contentLength { + t.Fatalf("body length %d != Content-Length %d (truncated by %d bytes)", + len(resultBody), contentLength, contentLength-len(resultBody)) + } + + // Body must be byte-identical to original (bare LFs preserved) + if !bytes.Equal(resultBody, []byte(body)) { + t.Error("body content was modified by normalization") + } +} + +// TestNormalizeCRLF_ProperCRLFPassthrough ensures that a fully compliant +// message (all CRLF) passes through unchanged with zero allocations. +func TestNormalizeCRLF_ProperCRLFPassthrough(t *testing.T) { + msg := "INVITE sip:x SIP/2.0\r\nVia: SIP/2.0/UDP host\r\n\r\nbody data\r\n" + input := []byte(msg) + result := normalizeCRLF(input) + + // Must return the exact same slice (no copy) + if &result[0] != &input[0] { + t.Error("proper CRLF message was copied instead of returned as-is") + } +} + +// TestNormalizeCRLF_SeparatorVariants exercises all four blank-line separator +// forms to ensure headers are normalized and body is preserved in each case. +// The separator string encodes "last header line ending + blank line ending": +// +// \r\n\r\n = CRLF header + CRLF blank +// \n\n = LF header + LF blank +// \r\n\n = CRLF header + LF blank +// \n\r\n = LF header + CRLF blank +func TestNormalizeCRLF_SeparatorVariants(t *testing.T) { + tests := []struct { + name string + firstEnd string // line ending for the first header line + sep string // last-header-ending + blank-line (the full separator) + }{ + {"CRLF+CRLF", "\r\n", "\r\n\r\n"}, + {"LF+LF", "\n", "\n\n"}, + {"CRLF+LF", "\r\n", "\r\n\n"}, + {"LF+CRLF", "\n", "\n\r\n"}, + } + + bodyContent := "body\nwith\nbare\nLFs" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build: start-line + firstEnd + Via header + sep + body + // The sep already includes the Via line's ending + the blank line. + raw := "INVITE sip:x SIP/2.0" + tt.firstEnd + + "Via: SIP/2.0/UDP host" + tt.sep + + bodyContent + + result := normalizeCRLF([]byte(raw)) + + // Headers must end with \r\n\r\n after normalization + idx := bytes.Index(result, []byte("\r\n\r\n")) + if idx < 0 { + t.Fatalf("no CRLF separator in result: %q", result) + } + + // No bare LFs in the header section + headerBytes := result[:idx] + for i, b := range headerBytes { + if b == '\n' && (i == 0 || headerBytes[i-1] != '\r') { + t.Errorf("bare LF at header byte %d: %q", i, headerBytes) + break + } + } + + // Body must be untouched + body := string(result[idx+4:]) + if body != bodyContent { + t.Errorf("body was modified: got %q, want %q", body, bodyContent) + } + }) + } +} + func TestCRLFPacketConn(t *testing.T) { // Create a real UDP connection pair serverAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") @@ -102,6 +415,68 @@ func TestCRLFPacketConn(t *testing.T) { } } +// TestCRLFPacketConn_BodyPreserved sends a SIP message with bare-LF headers +// and a bare-LF body through a real UDP crlfPacketConn and verifies the body +// is not modified while headers are normalized. +func TestCRLFPacketConn_BodyPreserved(t *testing.T) { + serverAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + serverConn, err := net.ListenUDP("udp", serverAddr) + if err != nil { + t.Fatal(err) + } + defer serverConn.Close() + + clientConn, err := net.DialUDP("udp", nil, serverConn.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer clientConn.Close() + + wrapped := &crlfPacketConn{PacketConn: serverConn} + + body := "--boundary\nContent-Type: application/sdp\n\nv=0\n\n--boundary--\n" + packet := fmt.Sprintf("INVITE sip:x SIP/2.0\nContent-Type: multipart/mixed;boundary=boundary\nContent-Length: %d\n\n%s", + len(body), body) + + _, err = clientConn.Write([]byte(packet)) + if err != nil { + t.Fatal(err) + } + + buf := make([]byte, 65535) + serverConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, _, err := wrapped.ReadFrom(buf) + if err != nil { + t.Fatal(err) + } + + result := buf[:n] + idx := bytes.Index(result, []byte("\r\n\r\n")) + if idx < 0 { + t.Fatal("no CRLF separator in normalized packet") + } + resultBody := string(result[idx+4:]) + + if resultBody != body { + t.Errorf("body modified by normalizer:\n got: %q\n want: %q", resultBody, body) + } + + // Verify Content-Length matches + headers := string(result[:idx]) + var cl int + for _, line := range strings.Split(headers, "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "content-length:") { + fmt.Sscanf(strings.TrimSpace(line[len("Content-Length:"):]), "%d", &cl) + } + } + if cl != len(body) { + t.Errorf("Content-Length %d != body length %d", cl, len(body)) + } +} + func TestCRLFConn(t *testing.T) { // Create a TCP listener listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -159,3 +534,13 @@ func BenchmarkNormalizeCRLF_NeedsFixing(b *testing.B) { normalizeCRLF(data) } } + +func BenchmarkNormalizeCRLF_WithBody(b *testing.B) { + body := strings.Repeat("line of body content\n", 50) + data := []byte(fmt.Sprintf("INVITE sip:x SIP/2.0\nVia: SIP/2.0/UDP host\nContent-Length: %d\n\n%s", + len(body), body)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + normalizeCRLF(data) + } +}