Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions pkg/sip/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
pendingTransfers map[LocalTag]*PendingTransfer
}

var errUnknownCall = psrpc.NewErrorf(psrpc.NotFound, "unknown call")

// GetStateHandler returns the per-call StateHandler that CallState forwards
// outgoing changes to. cloud builds typically return a handler that forks the
// stream to both the upstream RPC and local observability; the default
Expand Down Expand Up @@ -344,7 +346,7 @@
s.log.Infow("transferring SIP call", "callID", req.SipCallId, "transferTo", req.TransferTo)

// Check if provider is internal and config is set before allowing transfer
if err := s.checkInternalProviderRequest(ctx, req.SipCallId); err != nil {
if err := s.validateTransfer(ctx, req); err != nil {
return &emptypb.Empty{}, err
}

Expand Down Expand Up @@ -450,18 +452,21 @@
return nil
}

err := psrpc.NewErrorf(psrpc.NotFound, "unknown call")
s.mon.TransferFailed(stats.Inbound, "unknown_call", false)
return err
return errUnknownCall
}

func (s *Service) checkInternalProviderRequest(ctx context.Context, callID string) error {
func (s *Service) validateTransfer(ctx context.Context, req *rpc.InternalTransferSIPParticipantRequest) error {
// Look for call both in client (outbound) and server (inbound)
callID := req.SipCallId
s.cli.cmu.Lock()
out := s.cli.activeCalls[LocalTag(callID)]
s.cli.cmu.Unlock()

if out != nil {
if err := s.ensureTransferAuthorized(out.state, req); err != nil {
return err
}
return s.validateCallProvider(out.state)
}

Expand All @@ -470,6 +475,9 @@
s.srv.cmu.Unlock()

if in != nil {
if err := s.ensureTransferAuthorized(in.state, req); err != nil {
return err
}
return s.validateCallProvider(in.state)
}

Expand All @@ -490,6 +498,31 @@
return nil
}

func (s *Service) ensureTransferAuthorized(state *CallState, req *rpc.InternalTransferSIPParticipantRequest) error {
info := state.Info()
if info == nil {
return errUnknownCall
}

if req.GetRoomName() == "" && req.GetParticipantIdentity() == "" {

Check failure on line 507 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetParticipantIdentity undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetParticipantIdentity)

Check failure on line 507 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetRoomName undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetRoomName)
// Skip performing this authorization check against older clients.
// TODO: Remove this branch after clients have been updated to set these fields.
return nil
}
Comment on lines +507 to +511

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Transfer authorization is skipped entirely when the request omits room and participant

The new authorization check in pkg/sip/service.go:507-511 bypasses validation whenever both RoomName and ParticipantIdentity are empty in the transfer request, so any caller able to reach the internal transfer RPC can still transfer an arbitrary call by simply omitting those two fields. This is an intentional backwards-compatibility branch (documented with a TODO), but it means the control provides no protection until it is enforced.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if info.RoomName != req.GetRoomName() || info.ParticipantIdentity != req.GetParticipantIdentity() {

Check failure on line 513 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetParticipantIdentity undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetParticipantIdentity)

Check failure on line 513 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetRoomName undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetRoomName)
s.log.Warnw("rejecting unauthorized SIP transfer request", nil,
"callID", req.SipCallId,
"authorizedRoom", req.GetRoomName(),

Check failure on line 516 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetRoomName undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetRoomName)
"actualRoom", state.callInfo.RoomName,
"authorizedParticipant", req.GetParticipantIdentity(),

Check failure on line 518 in pkg/sip/service.go

View workflow job for this annotation

GitHub Actions / test

req.GetParticipantIdentity undefined (type *rpc.InternalTransferSIPParticipantRequest has no field or method GetParticipantIdentity)) (typecheck)
"actualParticipant", state.callInfo.ParticipantIdentity,
)
return errUnknownCall
}
return nil
}

// extractTransferErrorReason extracts a user-friendly reason string from an error
func extractTransferErrorReason(err error) string {
if err == nil {
Expand Down
187 changes: 187 additions & 0 deletions pkg/sip/signaling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1293,3 +1293,190 @@ func TestRouteSet(t *testing.T) {
})
})
}

// TestTransferAuthorization verifies that the TransferSIPParticipant handler
// rejects requests whose room/participant don't match the call being transferred,
// before any REFER is sent to the peer.
func TestTransferAuthorization(t *testing.T) {
const referTo = "tel:+15551234567"

directions := map[string]func(t *testing.T, st *serviceTest) (*sipUADialogTest, *CallState){
"inbound": func(t *testing.T, st *serviceTest) (*sipUADialogTest, *CallState) {
call, ic := st.CreateInboundCall(t)
return call, ic.state
},
"outbound": func(t *testing.T, st *serviceTest) (*sipUADialogTest, *CallState) {
call, oc, _ := st.CreateOutboundCall(t)
return call, oc.state
},
}

transferReq := func(call *sipUADialogTest, room, identity string) *rpc.InternalTransferSIPParticipantRequest {
return &rpc.InternalTransferSIPParticipantRequest{
SipCallId: string(call.remoteTag),
TransferTo: referTo,
RoomName: room,
ParticipantIdentity: identity,
RingingTimeout: durationpb.New(time.Second),
}
}

transferAccepted := func(t *testing.T, st *serviceTest, call *sipUADialogTest, room, identity string) {
t.Helper()

reqChan := call.RegisterRequestChannel("")
defer call.UnregisterRequestChannel("")

ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()

transferRes := make(chan error, 1)
go func() {
defer close(transferRes)
_, err := st.Service.TransferSIPParticipant(ctx, transferReq(call, room, identity))
transferRes <- err
}()

select {
case msg := <-reqChan:
require.Equal(t, sip.REFER, msg.req.Method)
require.NoError(t, msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 202, "Accepted", nil)))
case err := <-transferRes:
require.Fail(t, "transfer returned before sending REFER", "%v", err)
case <-ctx.Done():
require.Fail(t, "timeout waiting for REFER")
}

notifyReq := call.NewRequest(sip.NOTIFY)
notifyReq.AppendHeader(sip.NewHeader("Event", "refer"))
notifyReq.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag"))
notifyReq.SetBody([]byte(sip.NewResponse(200, sipStatus(200)).String()))
require.Equal(t, sip.StatusCode(200), call.TransactionRequest(t, notifyReq).StatusCode)

// The service hangs up after a successful transfer.
select {
case msg := <-reqChan:
require.Equal(t, sip.BYE, msg.req.Method)
require.NoError(t, msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, sipStatus(200), nil)))
case <-ctx.Done():
require.Fail(t, "timeout waiting for BYE")
}

select {
case err := <-transferRes:
require.NoError(t, err, "authorized transfer should succeed")
case <-ctx.Done():
require.Fail(t, "timeout waiting for transfer result")
}
}

// transferRejected asserts the request is rejected as an unknown call, and that
// no REFER is sent to the peer. Rejected requests never reach the transfer
// goroutine, so the handler returns synchronously.
transferRejected := func(t *testing.T, st *serviceTest, call *sipUADialogTest, room, identity string) {
t.Helper()

reqChan := call.RegisterRequestChannel(sip.REFER.String())
defer call.UnregisterRequestChannel(sip.REFER.String())

ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()

_, err := st.Service.TransferSIPParticipant(ctx, transferReq(call, room, identity))
require.ErrorIs(t, err, errUnknownCall)

var psErr psrpc.Error
require.ErrorAs(t, err, &psErr)
require.Equal(t, psrpc.NotFound, psErr.Code())

select {
case msg := <-reqChan:
require.Fail(t, "no REFER should be sent for an unauthorized transfer", "%s", msg.req.Method)
case <-time.After(250 * time.Millisecond):
}

resp := call.TransactionRequest(t, call.NewRequest(sip.BYE))
require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting BYE-200 OK")
}

for direction, setupCall := range directions {
t.Run(direction, func(t *testing.T) {
t.Run("authorized", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, state := setupCall(t, st)

info := state.Info()
require.NotEmpty(t, info.RoomName)
require.NotEmpty(t, info.ParticipantIdentity)

transferAccepted(t, st, call, info.RoomName, info.ParticipantIdentity)
})

t.Run("legacy request skips the check", func(t *testing.T) {
// Older clients set neither field. Until they are updated, the
// request is allowed through without an authorization check.
st := NewServiceTest(t, nil)
call, _ := setupCall(t, st)

transferAccepted(t, st, call, "", "")
})

t.Run("wrong room", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, state := setupCall(t, st)

transferRejected(t, st, call, "other-room", state.Info().ParticipantIdentity)
})

t.Run("wrong participant", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, state := setupCall(t, st)

transferRejected(t, st, call, state.Info().RoomName, "other-participant")
})

t.Run("wrong room and participant", func(t *testing.T) {
st := NewServiceTest(t, nil)
call, _ := setupCall(t, st)

transferRejected(t, st, call, "other-room", "other-participant")
})

t.Run("partial request does not skip the check", func(t *testing.T) {
// Only one of the two fields set: the empty one still has to match.
st := NewServiceTest(t, nil)
call, state := setupCall(t, st)

transferRejected(t, st, call, state.Info().RoomName, "")
})
})
}

t.Run("call without state", func(t *testing.T) {
// A call that is registered but has no state yet cannot be authorized.
st := NewServiceTest(t, nil)

const callID = "call-without-state"
st.Client.cmu.Lock()
st.Client.activeCalls[LocalTag(callID)] = &outboundCall{}
st.Client.cmu.Unlock()
t.Cleanup(func() {
// The call is only a stub; drop it before the client tears down.
st.Client.cmu.Lock()
delete(st.Client.activeCalls, LocalTag(callID))
st.Client.cmu.Unlock()
})

ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()

_, err := st.Service.TransferSIPParticipant(ctx, &rpc.InternalTransferSIPParticipantRequest{
SipCallId: callID,
TransferTo: referTo,
RoomName: "test-room",
ParticipantIdentity: "test-participant",
RingingTimeout: durationpb.New(time.Second),
})
require.ErrorIs(t, err, errUnknownCall)
})
}
Loading