Skip to content

Commit eda7ea0

Browse files
authored
fix: Handle SshError(Disconnect) during auth for password fallback (issue #113) (#114)
When SSH key authentication fails, russh may disconnect the connection before returning the authentication failure result, resulting in SshError(Disconnect) instead of KeyAuthFailed. This prevented the password fallback mechanism from triggering. Changes: - Update is_auth_error_for_password_fallback() to recognize SshError(Disconnect) and SshError(RecvError) as auth failures - Refactor establish_connection() to use the helper function for consistent error classification - Add comprehensive tests for the new error handling - Add integration tests for issue #113 scenarios
1 parent 00bc7f9 commit eda7ea0

2 files changed

Lines changed: 162 additions & 15 deletions

File tree

src/commands/interactive/connection.rs

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,16 @@ impl InteractiveCommand {
7272

7373
// Check if authentication failed and password fallback is allowed
7474
// This matches SSH key failures as well as SSH agent authentication failures
75+
// Also handles the case where russh disconnects during authentication failure
76+
// (which returns SshError(Disconnect) instead of KeyAuthFailed)
7577
let result = match result {
76-
Err(
77-
SshError::KeyAuthFailed
78-
| SshError::AgentAuthenticationFailed
79-
| SshError::AgentNoIdentities
80-
| SshError::AgentConnectionFailed
81-
| SshError::AgentRequestIdentitiesFailed,
82-
) if allow_password_fallback && atty::is(atty::Stream::Stdin) => {
78+
Err(ref err)
79+
if allow_password_fallback
80+
&& atty::is(atty::Stream::Stdin)
81+
&& is_auth_error_for_password_fallback(err) =>
82+
{
8383
tracing::debug!(
84-
"SSH authentication failed for {username}@{host}:{port}, attempting password fallback"
84+
"SSH authentication failed for {username}@{host}:{port} ({err}), attempting password fallback"
8585
);
8686

8787
// Prompt for password (matching OpenSSH behavior)
@@ -473,18 +473,56 @@ impl InteractiveCommand {
473473
/// - SSH agent has no identities loaded
474474
/// - SSH agent connection fails
475475
/// - SSH agent identity request fails
476+
/// - SSH server disconnects during authentication (russh::Error::Disconnect)
477+
/// This is a common behavior when the server rejects key authentication
478+
/// and the russh library drops the connection before returning the auth result.
476479
///
477480
/// These are all cases where falling back to password authentication makes sense,
478481
/// matching OpenSSH's behavior.
482+
///
483+
/// # Important
484+
/// The SshError(Disconnect) case is particularly important because russh may
485+
/// disconnect the connection before returning the authentication failure result.
486+
/// The log flow in this case is:
487+
/// ```text
488+
/// userauth_failure -> drop handle -> disconnected SshError(Disconnect)
489+
/// ```
490+
/// Without handling this case, password fallback would never be triggered when
491+
/// key authentication fails on servers that disconnect after auth failure.
479492
pub fn is_auth_error_for_password_fallback(error: &SshError) -> bool {
480-
matches!(
481-
error,
493+
match error {
494+
// Explicit authentication failures
482495
SshError::KeyAuthFailed
483-
| SshError::AgentAuthenticationFailed
484-
| SshError::AgentNoIdentities
485-
| SshError::AgentConnectionFailed
486-
| SshError::AgentRequestIdentitiesFailed
487-
)
496+
| SshError::AgentAuthenticationFailed
497+
| SshError::AgentNoIdentities
498+
| SshError::AgentConnectionFailed
499+
| SshError::AgentRequestIdentitiesFailed => true,
500+
501+
// russh may disconnect after auth failure, which manifests as these errors
502+
// This is a key fix for GitHub issue #113: the server may disconnect
503+
// during authentication, and we should treat this as an auth failure
504+
// that can be retried with password.
505+
SshError::SshError(russh::Error::Disconnect) => {
506+
tracing::debug!(
507+
"Treating SshError(Disconnect) as auth failure - server likely \
508+
disconnected after key authentication rejection"
509+
);
510+
true
511+
}
512+
513+
// RecvError can occur when the server closes the channel during auth
514+
SshError::SshError(russh::Error::RecvError) => {
515+
tracing::debug!(
516+
"Treating SshError(RecvError) as auth failure - server likely \
517+
closed connection during authentication"
518+
);
519+
true
520+
}
521+
522+
// All other errors should not trigger password fallback
523+
// This includes: PasswordWrong, ServerCheckFailed, IoError, etc.
524+
_ => false,
525+
}
488526
}
489527

490528
#[cfg(test)]
@@ -574,4 +612,57 @@ mod tests {
574612
"KeyboardInteractiveAuthFailed should NOT trigger password fallback"
575613
);
576614
}
615+
616+
// Tests for issue #113: Handle SshError(Disconnect) during authentication
617+
#[test]
618+
fn test_ssh_disconnect_triggers_password_fallback() {
619+
let error = SshError::SshError(russh::Error::Disconnect);
620+
assert!(
621+
is_auth_error_for_password_fallback(&error),
622+
"SshError(Disconnect) should trigger password fallback - \
623+
server may disconnect after key auth rejection"
624+
);
625+
}
626+
627+
#[test]
628+
fn test_ssh_recv_error_triggers_password_fallback() {
629+
let error = SshError::SshError(russh::Error::RecvError);
630+
assert!(
631+
is_auth_error_for_password_fallback(&error),
632+
"SshError(RecvError) should trigger password fallback - \
633+
server may close connection during authentication"
634+
);
635+
}
636+
637+
#[test]
638+
fn test_ssh_hup_does_not_trigger_fallback() {
639+
// HUP is a different type of disconnect that happens during normal operation
640+
let error = SshError::SshError(russh::Error::HUP);
641+
assert!(
642+
!is_auth_error_for_password_fallback(&error),
643+
"SshError(HUP) should NOT trigger password fallback - \
644+
this indicates remote closed connection, not auth failure"
645+
);
646+
}
647+
648+
#[test]
649+
fn test_ssh_connection_timeout_does_not_trigger_fallback() {
650+
let error = SshError::SshError(russh::Error::ConnectionTimeout);
651+
assert!(
652+
!is_auth_error_for_password_fallback(&error),
653+
"SshError(ConnectionTimeout) should NOT trigger password fallback - \
654+
this is a network issue, not auth failure"
655+
);
656+
}
657+
658+
#[test]
659+
fn test_ssh_not_authenticated_does_not_trigger_fallback() {
660+
// NotAuthenticated means we haven't tried auth yet, not that auth failed
661+
let error = SshError::SshError(russh::Error::NotAuthenticated);
662+
assert!(
663+
!is_auth_error_for_password_fallback(&error),
664+
"SshError(NotAuthenticated) should NOT trigger password fallback - \
665+
this means auth hasn't been attempted yet"
666+
);
667+
}
577668
}

tests/password_fallback_test.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,59 @@ fn test_host_key_verification_not_bypassed() {
105105
"ServerCheckFailed must NOT trigger password fallback - host key verification is a security feature"
106106
);
107107
}
108+
109+
// Tests for issue #113: Handle SshError(Disconnect) during authentication
110+
// The russh library may disconnect the connection before returning authentication failure,
111+
// which manifests as SshError(Disconnect) instead of KeyAuthFailed.
112+
113+
/// Test that SshError(Disconnect) triggers password fallback.
114+
/// This is the key fix for GitHub issue #113.
115+
#[test]
116+
fn test_ssh_disconnect_during_auth_triggers_password_fallback() {
117+
let error = SshError::SshError(russh::Error::Disconnect);
118+
assert!(
119+
is_auth_error_for_password_fallback(&error),
120+
"SshError(Disconnect) should trigger password fallback - \
121+
server may disconnect after key authentication rejection (issue #113)"
122+
);
123+
}
124+
125+
/// Test that SshError(RecvError) triggers password fallback.
126+
/// The server may close the channel during authentication, resulting in RecvError.
127+
#[test]
128+
fn test_ssh_recv_error_during_auth_triggers_password_fallback() {
129+
let error = SshError::SshError(russh::Error::RecvError);
130+
assert!(
131+
is_auth_error_for_password_fallback(&error),
132+
"SshError(RecvError) should trigger password fallback - \
133+
server may close connection during authentication"
134+
);
135+
}
136+
137+
/// Test that other SSH errors (HUP, ConnectionTimeout) do NOT trigger password fallback.
138+
/// These indicate network issues, not authentication failures.
139+
#[test]
140+
fn test_other_ssh_errors_do_not_trigger_fallback() {
141+
let non_auth_ssh_errors: Vec<(SshError, &str)> = vec![
142+
(
143+
SshError::SshError(russh::Error::HUP),
144+
"HUP - remote closed connection",
145+
),
146+
(
147+
SshError::SshError(russh::Error::ConnectionTimeout),
148+
"ConnectionTimeout - network issue",
149+
),
150+
(
151+
SshError::SshError(russh::Error::NotAuthenticated),
152+
"NotAuthenticated - auth not attempted yet",
153+
),
154+
];
155+
156+
for (error, desc) in non_auth_ssh_errors {
157+
assert!(
158+
!is_auth_error_for_password_fallback(&error),
159+
"{} should NOT trigger password fallback",
160+
desc
161+
);
162+
}
163+
}

0 commit comments

Comments
 (0)