Fix IMAP: LOGIN and AUTHENTICATE with a wrong, expired or unknown app password or API key are answered with an untagged NO, so clients keep waiting for the command to complete until the connection times out

This commit is contained in:
Stalwart Official committed 2026-10-01 11:22:27 +02:00
1 parent 69038eaea8
commit 6f6942ae57
3 files changed
+57 -3

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
## Fixed
- JMAP: Creating a `MaskedEmail` with `emailDomain` fails with `forbidden` for every domain when the account has addresses on more than one domain.
- Autodiscover: Requests for a response schema other than Outlook's, such as ActiveSync (`mobilesync`), are answered with the Outlook settings instead of error 601.
- IMAP:
- `LOGIN` and `AUTHENTICATE` with a wrong, expired or unknown app password or API key are answered with an untagged `NO`, so clients keep waiting for the command to complete until the connection times out.
- The failed login that exceeds the maximum number of authentication failures is answered with an untagged `NO` before the connection is closed.
## [0.16.24] - 2026-09-27
+50 -2
View File
@@ -677,7 +677,13 @@ pub trait SerializeResponse {
impl SerializeResponse for trc::Error {
fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(128);
if let Some(tag) = self.value_as_str(trc::Key::Id) {
if let Some(tag) = self
.keys()
.iter()
.rev()
.find_map(|(key, value)| (*key == trc::Key::Id).then_some(value))
.and_then(|value| value.as_str())
{
buf.extend_from_slice(tag.as_bytes());
} else {
buf.push(b'*');
@@ -813,9 +819,51 @@ impl Display for Command {
#[cfg(test)]
mod tests {
use crate::parser::parse_sequence_set;
use crate::protocol::ObjectId;
use crate::protocol::{ObjectId, SerializeResponse};
use types::id::Id;
#[test]
fn serialize_error_uses_command_tag() {
for (error, expected) in [
(
trc::AuthEvent::Failed.into_err().id("a1"),
"a1 NO [AUTHENTICATIONFAILED] ",
),
(
trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::Id, 7u32)
.id("a1"),
"a1 NO [AUTHENTICATIONFAILED] ",
),
(
trc::AuthEvent::Error
.into_err()
.ctx(trc::Key::Id, "12")
.id("a2"),
"a2 NO [AUTHENTICATIONFAILED] ",
),
(
trc::AuthEvent::TooManyAttempts
.into_err()
.caused_by(trc::AuthEvent::Failed.into_err().ctx(trc::Key::Id, 7u32))
.id("a3"),
"a3 NO [AUTHENTICATIONFAILED] ",
),
(
trc::AuthEvent::Failed.into_err().ctx(trc::Key::Id, 7u32),
"* NO [AUTHENTICATIONFAILED] ",
),
] {
let response = error.serialize();
assert!(
response.starts_with(expected.as_bytes()),
"{:?} does not start with {expected:?}",
String::from_utf8_lossy(&response)
);
}
}
#[test]
fn serialize_objectid_compound() {
// Empty compound
+4 -1
View File
@@ -84,7 +84,10 @@ impl<T: SessionStream> Session<T> {
auth_failures: auth_failures + 1,
};
} else {
return trc::AuthEvent::TooManyAttempts.into_err().caused_by(err);
return trc::AuthEvent::TooManyAttempts
.into_err()
.caused_by(err)
.id(tag.clone());
}
}