From 7aeb455f86cc96248c84d8db620a44d3d67d9dc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 06:43:30 +0000 Subject: [PATCH] fix: reject codec-changing re-INVITE with 488 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an in-dialog re-INVITE offers SDP that no longer includes the currently negotiated audio codec/payload type, answer 488 Not Acceptable Here and leave the RTP destination unchanged. Previously LiveKit returned 200 OK with the cached local SDP, which could advertise a codec that was not in the offer (RFC 3264 §6.1) and leave the call with no usable media. Address-only re-INVITEs that keep the negotiated codec continue to update the RTP destination as before. Adds inbound/outbound regression coverage for codec mismatch. Fixes livekit/sip#766 Co-authored-by: li xuanqun <793005378@qq.com> --- pkg/sip/inbound.go | 76 +++++++++++++++++++++++++++++++++++---- pkg/sip/outbound.go | 4 +-- pkg/sip/signaling_test.go | 58 ++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 7da5fe54..b4e03e7c 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -320,16 +320,61 @@ func sdpBodyFromRequest(req *sip.Request) []byte { return req.Body() } -func updateRemoteFromSDP(media *MediaPort, log logger.Logger, codecs *msdk.CodecSet, body []byte) { +// applyReinviteSDP updates the RTP destination from a re-INVITE SDP body when +// the currently negotiated audio codec and payload type remain offered. +// +// An empty body is treated as a session refresh (ok=true, no media change). +// Parse failures leave the destination unchanged and still return ok=true so +// the dialog can be refreshed with the existing local SDP (historical behavior). +// +// When the offer removes the negotiated audio codec/payload, ok=false and the +// caller must reject with 488 without changing the media path. Answering 200 +// with the cached local SDP would advertise a codec that was not in the offer +// (see livekit/sip#766 / RFC 3264 §6.1). +func applyReinviteSDP(media *MediaPort, log logger.Logger, codecs *msdk.CodecSet, body []byte) (ok bool) { if len(body) == 0 || media == nil { - return + return true } desc, err := sdp.ParseWith(codecs, body) if err != nil { log.Warnw("failed to parse re-INVITE SDP, RTP destination not updated", err) - return + return true + } + if !reinviteOffersCurrentAudio(media, desc) { + cur := media.Config() + var curType byte + var curName string + if cur != nil && cur.Audio.Codec != nil { + curType = cur.Audio.Type + curName = cur.Audio.Codec.Info().SDPName + } + log.Infow("rejecting re-INVITE that removes negotiated audio codec", + "negotiatedType", curType, + "negotiatedCodec", curName, + "remote", desc.Addr, + ) + return false } media.UpdateRemote(desc.Addr) + return true +} + +func reinviteOffersCurrentAudio(media *MediaPort, desc *sdp.Description) bool { + conf := media.Config() + if conf == nil || conf.Audio.Codec == nil { + return true + } + curType := conf.Audio.Type + curName := conf.Audio.Codec.Info().SDPName + for _, c := range desc.MediaDesc.Codecs { + if c.Type != curType { + continue + } + if c.Codec == nil || c.Codec.Info().SDPName == curName { + return true + } + } + return false } func (s *Server) onInvite(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { @@ -401,7 +446,11 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE s.cmu.RUnlock() if existing != nil && existing.cc.InviteCSeq() < cc.InviteCSeq() { existing.log().Infow("reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) - existing.updateRemoteFromSDP(sdpBodyFromRequest(req)) + body := sdpBodyFromRequest(req) + if !existing.applyReinviteSDP(body) { + cc.RejectAsKeepAlive(sip.StatusNotAcceptableHere, "Not Acceptable Here") + return nil + } cc.AcceptAsKeepAlive(existing.cc.OwnSDP()) return nil } @@ -411,8 +460,13 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE if oc != nil && oc.cc != nil && oc.cc.InviteCSeq() < newCSeq { localSDP := oc.cc.LocalSDP() if len(localSDP) != 0 { + body := sdpBodyFromRequest(req) + if !oc.applyReinviteSDP(body) { + oc.log.Infow("rejecting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) + cc.RejectAsKeepAlive(sip.StatusNotAcceptableHere, "Not Acceptable Here") + return nil + } oc.log.Infow("accepting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) - oc.updateRemoteFromSDP(sdpBodyFromRequest(req)) oc.cc.RecordInvite(newCSeq) cc.AcceptAsKeepAlive(localSDP) return nil @@ -1483,10 +1537,10 @@ func (c *inboundCall) Shutdown(ctx context.Context) { c.closeWithTerm(ctx, stats.ServerError("shutdown")) } -func (c *inboundCall) updateRemoteFromSDP(body []byte) { +func (c *inboundCall) applyReinviteSDP(body []byte) bool { c.mmu.Lock() defer c.mmu.Unlock() - updateRemoteFromSDP(c.media, c.log(), c.mediaCodecs, body) + return applyReinviteSDP(c.media, c.log(), c.mediaCodecs, body) } func (c *inboundCall) closeMedia() { @@ -1983,6 +2037,14 @@ func (c *sipInbound) AcceptAsKeepAlive(sdp []byte) { c.respondWithData(sip.StatusOK, "OK", "application/sdp", sdp) } +// RejectAsKeepAlive rejects an in-dialog re-INVITE without tearing down the +// established dialog. Unlike RespondAndDrop it does not cache the response in +// rejectedInvites (which is keyed by Call-ID + From-tag and would poison later +// in-dialog requests) and does not clear dialog state on the established call. +func (c *sipInbound) RejectAsKeepAlive(status sip.StatusCode, reason string) { + c.respond(status, reason) +} + func (c *sipInbound) OwnSDP() []byte { c.mu.RLock() defer c.mu.RUnlock() diff --git a/pkg/sip/outbound.go b/pkg/sip/outbound.go index 89d5252c..dd6309c4 100644 --- a/pkg/sip/outbound.go +++ b/pkg/sip/outbound.go @@ -522,8 +522,8 @@ func (c *outboundCall) dialSIP(ctx context.Context, tid traceid.ID) error { return nil } -func (c *outboundCall) updateRemoteFromSDP(body []byte) { - updateRemoteFromSDP(c.media, c.log, c.sipConf.mediaConfig.Codecs, body) +func (c *outboundCall) applyReinviteSDP(body []byte) bool { + return applyReinviteSDP(c.media, c.log, c.sipConf.mediaConfig.Codecs, body) } func (c *outboundCall) connectMedia() { diff --git a/pkg/sip/signaling_test.go b/pkg/sip/signaling_test.go index 8744f46f..0758a785 100644 --- a/pkg/sip/signaling_test.go +++ b/pkg/sip/signaling_test.go @@ -18,6 +18,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" "github.com/livekit/media-sdk/sdp" "github.com/livekit/mediatransportutil/pkg/rtcconfig" "github.com/livekit/protocol/livekit" @@ -700,6 +704,24 @@ func TestReinvite(t *testing.T) { require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP") require.Equal(t, initialRemote, ic.media.RemoteAddr(), "body-less re-INVITE must not change RTP destination") }) + + t.Run("codec_mismatch", func(t *testing.T) { + st := NewServiceTest(t, nil) + call, ic := st.CreateInboundCall(t) + initialRemote := ic.media.RemoteAddr() + conf := ic.media.Config() + require.NotNil(t, conf) + require.NotNil(t, conf.Audio.Codec) + + offerBytes := mustIncompatibleAudioOffer(t, conf.Audio.Codec.Info().SDPName, netip.MustParseAddr("9.8.7.6"), 12345) + req, _, err := call.Invite(offerBytes) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusNotAcceptableHere, resp.StatusCode, + "re-INVITE that removes negotiated codec must be rejected with 488") + require.Equal(t, initialRemote, ic.media.RemoteAddr(), + "rejected codec-changing re-INVITE must not change RTP destination") + }) }) t.Run("outbound", func(t *testing.T) { t.Run("normal", func(t *testing.T) { @@ -744,6 +766,24 @@ func TestReinvite(t *testing.T) { require.Equal(t, initialRemote, oc.media.RemoteAddr(), "body-less re-INVITE must not change RTP destination") }) + t.Run("codec_mismatch", func(t *testing.T) { + st := NewServiceTest(t, nil) + call, oc, _ := st.CreateOutboundCall(t) + initialRemote := oc.media.RemoteAddr() + conf := oc.media.Config() + require.NotNil(t, conf) + require.NotNil(t, conf.Audio.Codec) + + offerBytes := mustIncompatibleAudioOffer(t, conf.Audio.Codec.Info().SDPName, netip.MustParseAddr("9.8.7.6"), 12345) + req, _, err := call.Invite(offerBytes) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusNotAcceptableHere, resp.StatusCode, + "re-INVITE that removes negotiated codec must be rejected with 488") + require.Equal(t, initialRemote, oc.media.RemoteAddr(), + "rejected codec-changing re-INVITE must not change RTP destination") + }) + t.Run("miss", func(t *testing.T) { st := NewServiceTest(t, nil) call, oc, _ := st.CreateOutboundCall(t) @@ -767,6 +807,24 @@ func TestReinvite(t *testing.T) { }) } +// mustIncompatibleAudioOffer builds an SDP offer that intentionally excludes +// negotiatedCodec so a re-INVITE cannot keep the established media session. +func mustIncompatibleAudioOffer(t *testing.T, negotiatedCodec string, addr netip.Addr, port int) []byte { + t.Helper() + incompatible := msdk.NewCodecSet() + for _, name := range []string{g711.ULawSDPNameAndRate, g711.ALawSDPNameAndRate, g722.SDPNameAndRate} { + if name != negotiatedCodec { + incompatible.SetEnabled(name, true) + } + } + incompatible.SetEnabled(dtmf.SDPNameAndRate, true) + offer, err := sdp.NewOfferWith(incompatible, addr, port, sdp.EncryptionNone) + require.NoError(t, err) + offerBytes, err := offer.SDP.Marshal() + require.NoError(t, err) + return offerBytes +} + func TestTransfer(t *testing.T) { const referTo = "tel:+15551234567"