Skip to content
Merged
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
85 changes: 84 additions & 1 deletion crates/asterisk-sip/src/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,14 +345,25 @@ impl OutboundAuthenticator {
// Clone the original request and add the Authorization header.
let mut new_request = original_request.clone();

// Increment CSeq.
// Increment CSeq and mint a FRESH top Via branch. The credentialed retry
// is a NEW client transaction (RFC 3261 §8.1.3.5 / §17.1.1.1): reusing
// the challenged request's branch collides with the transaction the 401/
// 407 just terminated, so the retry must carry both an incremented CSeq
// and a new branch. Reusing the branch was the M-f defect.
let new_branch = generate_branch();
let mut via_rewritten = false;
for h in &mut new_request.headers {
if h.name.eq_ignore_ascii_case(header_names::CSEQ) {
if let Some((num_str, method_str)) = h.value.split_once(' ') {
if let Ok(num) = num_str.trim().parse::<u32>() {
h.value = format!("{} {}", num + 1, method_str);
}
}
} else if !via_rewritten && h.name.eq_ignore_ascii_case(header_names::VIA) {
// Only the topmost (our own) Via carries this transaction's
// branch; a downstream proxy's Via must be left untouched.
h.value = replace_via_branch(&h.value, &new_branch);
via_rewritten = true;
}
}

Expand Down Expand Up @@ -493,6 +504,45 @@ fn generate_nonce() -> String {
hex::encode(bytes)
}

/// Generate a fresh RFC 3261 magic-cookie Via branch for a new client
/// transaction.
fn generate_branch() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let bytes: [u8; 8] = rng.gen();
format!("z9hG4bK{}", hex::encode(bytes))
}

/// Replace the `branch=` parameter of a Via header value with `new_branch`,
/// preserving every other Via parameter (`rport`, `received`, transport, host).
/// If the Via carries no `branch=` param, one is appended.
fn replace_via_branch(via_value: &str, new_branch: &str) -> String {
let mut out = Vec::new();
let mut replaced = false;
for (idx, part) in via_value.split(';').enumerate() {
if idx == 0 {
// The sent-protocol + sent-by (e.g. "SIP/2.0/UDP host:port").
out.push(part.trim_end().to_string());
continue;
}
let trimmed = part.trim();
if trimmed.eq_ignore_ascii_case("branch")
|| trimmed
.split_once('=')
.is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("branch"))
{
out.push(format!("branch={}", new_branch));
replaced = true;
} else {
out.push(trimmed.to_string());
}
}
if !replaced {
out.push(format!("branch={}", new_branch));
}
out.join(";")
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -827,5 +877,38 @@ mod tests {
// CSeq should be incremented.
let cseq = authed_request.cseq().unwrap();
assert!(cseq.starts_with("2 "));

// The credentialed retry MUST be a new client transaction: its top Via
// branch differs from the challenged request's branch (M-f: reusing the
// branch collides with the just-completed 401 transaction). Before the
// fix this branch was `z9hG4bK123`, identical to the original.
let orig_branch = extract_branch(&request.get_header(header_names::VIA).unwrap());
let retry_branch = extract_branch(&authed_request.get_header(header_names::VIA).unwrap());
assert_eq!(orig_branch.as_deref(), Some("z9hG4bK123"));
assert!(
retry_branch.is_some() && retry_branch.as_deref() != Some("z9hG4bK123"),
"retry Via must carry a FRESH branch, got {:?}",
retry_branch
);
}

fn extract_branch(via: &str) -> Option<String> {
via.split(';')
.find_map(|p| p.trim().strip_prefix("branch="))
.map(|b| b.trim().to_string())
}

/// `replace_via_branch` swaps only the branch and preserves the sent-by and
/// every other Via parameter (rport, transport).
#[test]
fn test_replace_via_branch_preserves_other_params() {
let via = "SIP/2.0/UDP 10.0.0.9:5060;rport;branch=z9hG4bKold";
let out = replace_via_branch(via, "z9hG4bKnew");
assert!(out.starts_with("SIP/2.0/UDP 10.0.0.9:5060"));
assert!(out.contains(";rport"));
assert!(out.contains(";branch=z9hG4bKnew"));
assert!(!out.contains("z9hG4bKold"));
// Exactly one branch param.
assert_eq!(out.matches("branch=").count(), 1);
}
}
20 changes: 20 additions & 0 deletions crates/asterisk-sip/src/channel_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,24 @@ impl ChannelDriver for SipChannelDriver {
// Create SIP session
let mut sip_session = SipSession::new_outbound(self.local_addr, remote_addr);

// Resolve the endpoint's OUTBOUND digest credential so a carrier's
// 401/407 on this origination INVITE can be answered (M-f). Absent
// `outbound_auth`, a challenge is a hard failure (no credentials to
// present). `dest` is the endpoint name here (a raw URI dial has no
// endpoint and therefore no outbound auth).
sip_session.outbound_auth = endpoint_config
.as_ref()
.and_then(|config| {
let ep = config.find_endpoint(dest)?;
let auth_name = ep.outbound_auth.as_deref()?;
let auth = config.find_auth(auth_name)?;
Some(crate::authenticator::AuthCredentials::new(
&auth.username,
&auth.password,
auth.realm.as_deref().unwrap_or(""),
))
});

// Create SDP offer with a concrete, routable connection address
// (external_media_address / routed interface — never 0.0.0.0,
// issue #56). Fail closed (CP3): if a configured external_media_address
Expand Down Expand Up @@ -710,6 +728,8 @@ impl ChannelDriver for SipChannelDriver {
local_tag: session.local_tag.clone(),
early_media: session.early_media.clone(),
early_media_config: session.early_media_config.clone(),
outbound_auth: session.outbound_auth.clone(),
auth_attempts: session.auth_attempts,
};
handler.register_outbound_session(
&session.call_id,
Expand Down
83 changes: 81 additions & 2 deletions crates/asterisk-sip/src/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ struct CallState {
_rtp_reservation: Option<RtpSession>,
}

/// Maximum credentialed INVITE retries in response to a 401/407 on an
/// origination leg. RFC 3261 gives a UAC one retry per challenge; a well-behaved
/// carrier challenges once (nonce), so a single retry suffices. Bounding it
/// stops a misbehaving or malicious carrier that keeps re-challenging (or issues
/// `stale`) from driving an unbounded INVITE loop (M-f "bounded retries").
const MAX_OUTBOUND_AUTH_ATTEMPTS: u32 = 2;

#[cfg(not(test))]
const ABANDONED_SIGNALING_GRACE: std::time::Duration =
crate::transaction::timers::TIMER_B;
Expand Down Expand Up @@ -280,6 +287,21 @@ impl SipEventHandler {
}
}

/// Send an INVITE (e.g. a credentialed challenge retry) as a NEW INVITE
/// client transaction when the stack is attached, so it gets its own
/// retransmission/timeout timers. Handler-only tests fall back to the raw
/// transport.
async fn send_client_invite(
&self,
request: SipMessage,
remote_addr: SocketAddr,
) -> Result<(), crate::transport::TransportError> {
match self.stack.get() {
Some(stack) => stack.send_invite(request, remote_addr).await.map(|_| ()),
None => self.transport.send(&request, remote_addr).await,
}
}

async fn validate_in_dialog_request(
&self,
request: &SipMessage,
Expand Down Expand Up @@ -1378,6 +1400,56 @@ impl SipEventHandler {
if !cs.session.is_outbound {
return;
}

// Wire-correct digest challenge handling on origination (M-f). The
// transaction layer has ALREADY sent the RFC 3261 §17.1.1.3 non-2xx ACK
// for this 401/407 (stack::process_response, reusing the INVITE branch +
// real CSeq) before this event was delivered. What is missing — and is
// added here — is answering the challenge with a NEW INVITE transaction
// (fresh branch, incremented CSeq, Authorization). Do it BEFORE
// on_response so the challenge is not treated as a generic failure.
if matches!(status_code, 401 | 407) {
// Bounded credentialed retry.
let may_retry = cs.session.outbound_auth.is_some()
&& cs.session.auth_attempts < MAX_OUTBOUND_AUTH_ATTEMPTS;
if may_retry {
if let Some(retry) = cs.session.build_auth_retry_invite(response) {
if let Err(error) = self.send_client_invite(retry, remote_addr).await {
warn!(call_id = %call_id, %error,
"Failed to send credentialed INVITE retry");
} else {
info!(call_id = %call_id, status_code,
attempt = cs.session.auth_attempts,
"Sent credentialed INVITE retry for auth challenge");
return;
}
} else {
warn!(call_id = %call_id,
"Auth challenge unanswerable (no/invalid outbound credentials)");
}
} else if cs.session.outbound_auth.is_some() {
warn!(call_id = %call_id,
"Auth challenge retry bound reached; failing origination");
} else {
debug!(call_id = %call_id,
"Auth challenge received but endpoint has no outbound_auth; failing");
}

// No (further) retry: fail the leg like any other final failure.
let abandoned = cs.abandoned;
drop(cs);
if abandoned {
self.release_outbound_leg(&channel_name);
return;
}
if let Some(channel) = store::find_by_name(&channel_name) {
let mut ch = channel.lock();
ch.hangup_cause = HangupCause::NormalClearing;
ch.softhangup(softhangup::AST_SOFTHANGUP_DEV);
}
return;
}

cs.session.on_response(response);

if let Some(to_tag) = response.get_header("To")
Expand All @@ -1387,8 +1459,15 @@ impl SipEventHandler {
}

if (200..300).contains(&status_code) {
// ACK — and every subsequent in-dialog request (BYE) — is routed
// through the dialog route set (Record-Route) to the refreshed
// Contact / first route hop, NOT the original request-URI (M-f).
// Refresh the stored next hop so the abandoned-crossing BYE and the
// reject-answer BYE below use the same proxy path.
let ack_hop = cs.session.in_dialog_next_hop().unwrap_or(remote_addr);
cs.next_hop = ack_hop;
if let Some(ack) = cs.session.build_ack() {
if let Err(error) = self.transport.send(&ack, remote_addr).await {
if let Err(error) = self.transport.send(&ack, ack_hop).await {
warn!(call_id = %call_id, %error, "Failed to send ACK");
} else {
debug!(call_id = %call_id, "Sent ACK for 200 OK");
Expand Down Expand Up @@ -1429,7 +1508,7 @@ impl SipEventHandler {
warn!(call_id = %call_id, channel = %channel_name, %error,
"Rejecting unusable outbound SDP answer");
if let Some(bye) = cs.session.build_bye() {
if let Err(send_error) = self.send_client_request(bye, remote_addr).await {
if let Err(send_error) = self.send_client_request(bye, cs.next_hop).await {
warn!(call_id = %call_id,
"Failed to send BYE after rejecting answer: {}", send_error);
}
Expand Down
11 changes: 10 additions & 1 deletion crates/asterisk-sip/src/pjsip_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,13 @@ pub struct EndpointConfig {
pub disallow: Vec<String>,
/// Codecs to allow.
pub allow: Vec<String>,
/// Reference to an auth section name.
/// Reference to an auth section name (INBOUND: the credential this endpoint
/// is challenged against).
pub auth: Option<String>,
/// Reference to an auth section used for OUTBOUND digest — the credential
/// rustisk presents when this endpoint (a carrier/trunk) challenges our
/// origination INVITE with 401/407 (M-f). Distinct from `auth`.
pub outbound_auth: Option<String>,
/// Reference to an AOR section name.
pub aors: Option<String>,
/// Whether to allow direct media (RTP bypass).
Expand Down Expand Up @@ -119,6 +124,7 @@ impl Default for EndpointConfig {
disallow: Vec::new(),
allow: Vec::new(),
auth: None,
outbound_auth: None,
aors: None,
direct_media: true,
rtp_symmetric: false,
Expand Down Expand Up @@ -576,6 +582,9 @@ fn parse_endpoint(cat: &asterisk_config::Category) -> EndpointConfig {
if let Some(v) = get_last_variable(cat,"auth") {
ep.auth = Some(v.to_string());
}
if let Some(v) = get_last_variable(cat,"outbound_auth") {
ep.outbound_auth = Some(v.to_string());
}
if let Some(v) = get_last_variable(cat,"aors") {
ep.aors = Some(v.to_string());
}
Expand Down
Loading
Loading