From 7635a538af19749e1ab8fa89e2612c088b3e4b84 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:02:28 +0400 Subject: [PATCH] fix(voice): harden agent-label parsing and prevent started_at drift - Conference::Manager.extract_user_id now requires the account id in the participant label to match call.account_id, so a spoofed label can't attach a user from another account to the call. - CallStatus::Manager keeps the earliest started_at across multiple in-progress webhooks (Twilio can emit answered + in-progress, plus retries) so duration_seconds isn't undercounted. --- .../app/services/voice/call_status/manager.rb | 5 ++++- .../app/services/voice/conference/manager.rb | 16 +++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb index e7cc95f38..c45fdfd94 100644 --- a/enterprise/app/services/voice/call_status/manager.rb +++ b/enterprise/app/services/voice/call_status/manager.rb @@ -17,7 +17,10 @@ class Voice::CallStatus::Manager ts = timestamp || now_seconds if status == 'in_progress' - attrs[:started_at] = Time.zone.at(ts) + # Twilio can emit multiple in-progress updates (answered + in-progress, retries). + # Keep the earliest timestamp so duration_seconds doesn't shift forward. + started_at = Time.zone.at(ts) + attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at elsif Call::TERMINAL_STATUSES.include?(status) call.ended_at = ts attrs[:meta] = call.meta diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb index c6115137b..9f34eec75 100644 --- a/enterprise/app/services/voice/conference/manager.rb +++ b/enterprise/app/services/voice/conference/manager.rb @@ -35,6 +35,17 @@ class Voice::Conference::Manager status_manager.process_status_update('in_progress', timestamp: now) end + # Parses agent user_id from participant_label. Only returns an id when the + # label's embedded account id matches the call's account — protects against + # a spoofed/cross-account label attaching a foreign user to the call. + def extract_user_id + match = participant_label.to_s.match(AGENT_LABEL_PATTERN) + return unless match + return unless match[2].to_i == call.account_id + + match[1].to_i + end + def handle_leave! case call.status when 'ringing' @@ -54,11 +65,6 @@ class Voice::Conference::Manager participant_label.to_s.start_with?('agent-') end - def extract_user_id - match = participant_label.to_s.match(AGENT_LABEL_PATTERN) - match && match[1].to_i - end - def now Time.zone.now.to_i end