Legacy sign-in is refused while the switch is off (LP-6)
The second lock. While legacy mail protocols are off, a sign-in over IMAP,
POP3, ManageSieve or SMTP AUTH is refused for every account, so a listener
that exists by mistake -- or submission, which the SMTP lock keeps open --
still lets nobody in.
The check sits at the top of each protocol's sign-in, before the
credentials are looked at. So the answer is the same for a right password,
a wrong one and an account that doesn't exist; it isn't auth.failed, so it
counts nothing against the account and never feeds the auto-ban; and the
session stays open, since the mail app is being told, not thrown off.
Mail apps read the spec's words (LP-12, at server scope):
IMAP NO [ALERT] This server allows only INBUXA webmail and JMAP
apps. This mail app can't sign in.
POP3 -ERR [AUTH] ...the same...
ManageSieve NO "This server allows only INBUXA webmail and JMAP apps."
SMTP 535 5.7.0 This server allows only INBUXA webmail and JMAP
apps. This mail app can't send.
SMTP AUTH is refused on every SMTP listener, port 25 included: only mail
apps authenticate, so inbound delivery is untouched. LMTP is left alone.
The policy is read from the store on each sign-in rather than cached, so
every node of a cluster answers the same the moment the switch turns.
Each refusal raises a new event, auth.legacy-protocol-refused (id 642, info
level, also in the packaged schema), with the protocol as source, the
policy's scope and the domain -- never the account. The session adds the
listener and remote IP.
tests/e2e/legacy_protocols.py now also proves, on a running server: a
normal IMAP and submission sign-in works with the switch on, before and
after; while off, submission refuses the right password and six wrong ones
with the same words and without hanging up; and an IMAP listener created by
mistake while off refuses the right password, a wrong one and an account
that doesn't exist. All 33 checks pass. SMTP sign-ins in the script wait
out a second first: every connection arrives from Docker's gateway, and the
stock inbound throttle takes five a second from one IP.
This commit is contained in:
@@ -17,11 +17,16 @@
|
||||
//! back on the next restart. Opening puts the object back first and then
|
||||
//! spawns, for the same reason in reverse.
|
||||
//!
|
||||
//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over
|
||||
//! a legacy protocol is refused before any password is looked at, so a
|
||||
//! listener that exists by mistake still lets nobody in.
|
||||
//!
|
||||
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
|
||||
//! (LP-20). The server stops answering; what still routes the port is the
|
||||
//! operator's to reconcile.
|
||||
|
||||
use crate::{Server, config::server::Listeners, network::TcpAcceptor};
|
||||
use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
@@ -209,3 +214,150 @@ impl Server {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegacyProtocol {
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
/// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so
|
||||
/// inbound delivery is untouched (LP-3).
|
||||
Submission,
|
||||
}
|
||||
|
||||
impl LegacyProtocol {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LegacyProtocol::Imap => "imap",
|
||||
LegacyProtocol::Pop3 => "pop3",
|
||||
LegacyProtocol::ManageSieve => "manageSieve",
|
||||
LegacyProtocol::Submission => "submission",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the mail app is told, at server scope (LP-12, LP-6). Each
|
||||
/// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting —
|
||||
/// is added by its session; POP3 carries `[AUTH]` in the text, since its
|
||||
/// errors have no separate code, and SMTP is the whole reply line.
|
||||
pub fn refusal(&self) -> &'static str {
|
||||
match self {
|
||||
LegacyProtocol::Imap => {
|
||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
LegacyProtocol::Pop3 => {
|
||||
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.",
|
||||
LegacyProtocol::Submission => {
|
||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The refusal as an error: `auth.legacy-protocol-refused`, not
|
||||
/// `auth.failed`, so it never counts against the account or feeds the
|
||||
/// auto-ban (LP-11). It names the protocol and the domain, never the
|
||||
/// account; the session it is raised in adds the remote IP.
|
||||
pub fn refused(&self, credentials: &Credentials) -> trc::Error {
|
||||
trc::AuthEvent::LegacyProtocolRefused
|
||||
.into_err()
|
||||
.details(self.refusal())
|
||||
.ctx(trc::Key::Source, self.as_str())
|
||||
.ctx(trc::Key::Policy, "server")
|
||||
.ctx_opt(trc::Key::Domain, domain_of(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
/// The domain a sign-in is for, from the name it gives, if it gives one.
|
||||
fn domain_of(credentials: &Credentials) -> Option<String> {
|
||||
let username = match credentials {
|
||||
Credentials::Basic { username, .. } => Some(username.as_str()),
|
||||
Credentials::Bearer { username, .. } => username.as_deref(),
|
||||
}?;
|
||||
username
|
||||
.rsplit_once('@')
|
||||
.map(|(_, domain)| domain.trim().to_lowercase())
|
||||
.filter(|domain| !domain.is_empty())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Refuses a sign-in over a legacy protocol while the server-wide switch
|
||||
/// is off (LP-6). Called before the credentials are checked, so the
|
||||
/// answer is the same for a right password, a wrong one and an account
|
||||
/// that doesn't exist (LP-11).
|
||||
///
|
||||
/// Read from the store on each sign-in rather than cached, so every node
|
||||
/// of a cluster answers the same the moment the switch turns.
|
||||
pub async fn refuse_legacy_sign_in(
|
||||
&self,
|
||||
protocol: LegacyProtocol,
|
||||
credentials: &Credentials,
|
||||
) -> trc::Result<()> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
Err(protocol.refused(credentials))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn basic(username: &str) -> Credentials {
|
||||
Credentials::Basic {
|
||||
username: username.to_string(),
|
||||
secret: "wrong or right, it is never read".to_string(),
|
||||
mfa_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_read_as_the_spec_writes_them() {
|
||||
// LP-12, with "Your organization" read as "This server" (LP-6).
|
||||
assert_eq!(
|
||||
LegacyProtocol::Imap.refusal(),
|
||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
);
|
||||
assert!(
|
||||
LegacyProtocol::Pop3
|
||||
.refusal()
|
||||
.starts_with("[AUTH] This server allows")
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::ManageSieve.refusal(),
|
||||
"This server allows only INBUXA webmail and JMAP apps."
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::Submission.refusal(),
|
||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_not_a_failed_sign_in() {
|
||||
let err = LegacyProtocol::Imap.refused(&basic("[email protected]"));
|
||||
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
||||
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
|
||||
// The session stays open: the mail app is told, not thrown off.
|
||||
assert!(!err.must_disconnect());
|
||||
assert!(err.should_write_err());
|
||||
assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org"));
|
||||
assert_eq!(err.value_as_str(trc::Key::Source), Some("imap"));
|
||||
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_domain_comes_from_the_name_given() {
|
||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||
assert_eq!(domain_of(&basic("no-domain")), None);
|
||||
assert_eq!(domain_of(&basic("trailing@")), None);
|
||||
let bearer = Credentials::Bearer {
|
||||
username: None,
|
||||
token: "t".to_string(),
|
||||
};
|
||||
assert_eq!(domain_of(&bearer), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::{Session, SessionData, State};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use imap_proto::{
|
||||
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> {
|
||||
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||
self.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::Imap, &credentials)
|
||||
.await
|
||||
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::{Command, Session, State, StatusResponse};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use imap_proto::{
|
||||
@@ -65,6 +67,11 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||
self.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::ManageSieve, &credentials)
|
||||
.await?;
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -10,7 +12,7 @@ use crate::{
|
||||
};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
@@ -61,6 +63,11 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> {
|
||||
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||
self.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
|
||||
.await?;
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use common::{auth::AuthRequest, network::SessionStream};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, legacy::LegacyProtocol},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use registry::schema::enums::Permission;
|
||||
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
|
||||
// inbuxa: legacy-protocols LP-6. Refused before the password is looked
|
||||
// at, and not counted as an authentication error (LP-11). Only mail
|
||||
// apps authenticate, so this never touches inbound delivery (LP-3).
|
||||
if let Err(err) = self
|
||||
.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
|
||||
.await
|
||||
{
|
||||
let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused));
|
||||
trc::error!(err.span_id(self.data.session_id));
|
||||
if refused {
|
||||
self.write(LegacyProtocol::Submission.refusal().as_bytes())
|
||||
.await?;
|
||||
} else {
|
||||
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
||||
.await?;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
let result = self
|
||||
.server
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 642;
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
|
||||
// auth.legacy-protocol-refused (legacy-protocols LP-6)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 643;
|
||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -116,6 +117,8 @@ pub enum AuthEvent {
|
||||
Error = 34,
|
||||
Warning = 595,
|
||||
CredentialExpired = 276,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
LegacyProtocolRefused = 642,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -56,6 +56,8 @@ impl EventType {
|
||||
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
|
||||
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
b"auth.legacy-protocol-refused" => EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||
b"auth.error" => EventType::Auth(AuthEvent::Error),
|
||||
b"auth.warning" => EventType::Auth(AuthEvent::Warning),
|
||||
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
|
||||
@@ -705,6 +707,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration",
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "auth.legacy-protocol-refused",
|
||||
EventType::Auth(AuthEvent::Error) => "auth.error",
|
||||
EventType::Auth(AuthEvent::Warning) => "auth.warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
|
||||
@@ -1489,6 +1493,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => 36,
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => 38,
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => 555,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
|
||||
EventType::Auth(AuthEvent::Error) => 34,
|
||||
EventType::Auth(AuthEvent::Warning) => 595,
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => 276,
|
||||
@@ -2137,6 +2143,8 @@ impl EventType {
|
||||
36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
|
||||
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
|
||||
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
|
||||
34 => Some(EventType::Auth(AuthEvent::Error)),
|
||||
595 => Some(EventType::Auth(AuthEvent::Warning)),
|
||||
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
|
||||
@@ -2848,6 +2856,8 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
|
||||
EventType::Auth(AuthEvent::Success) => Level::Info,
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => Level::Info,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => Level::Info,
|
||||
EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info,
|
||||
EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info,
|
||||
EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info,
|
||||
@@ -3187,6 +3197,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication",
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration",
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused",
|
||||
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
||||
EventType::Auth(AuthEvent::Warning) => "Authentication warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||
@@ -3951,6 +3963,8 @@ impl EventType {
|
||||
}
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps",
|
||||
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
|
||||
@@ -4259,6 +4273,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired),
|
||||
EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
EventType::Auth(AuthEvent::ClientRegistration),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||
EventType::Auth(AuthEvent::Error),
|
||||
EventType::Auth(AuthEvent::Warning),
|
||||
EventType::Auth(AuthEvent::CredentialExpired),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
||||
C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho
|
||||
@@ -12,6 +12,12 @@ This is the part unit tests cannot reach: whether a socket actually closes on
|
||||
a running server (LP-2), and whether a listener put back actually binds again
|
||||
(LP-5). Acceptance tests 15, 17 and 18.
|
||||
|
||||
It also checks the second lock (LP-6): while the switch is off, sign-in over
|
||||
submission -- locked open -- and over an IMAP listener that exists by mistake
|
||||
is refused with the spec's words, with the right password and with a wrong
|
||||
one, and refusals never add up to a disconnect (LP-11). And that a normal
|
||||
IMAP sign-in works with the switch on, before and after.
|
||||
|
||||
Passwords are generated into files under target/e2e and never printed.
|
||||
Everything is removed afterwards unless KEEP=1.
|
||||
"""
|
||||
@@ -25,8 +31,12 @@ HTTP = "http://127.0.0.1:18080"
|
||||
# makes the host side accept connections whether or not anything is listening
|
||||
# inside the container, so a bare connect proves nothing: each port has to be
|
||||
# made to speak.
|
||||
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
||||
TLS_PORTS = {18993, 18995, 18465}
|
||||
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025, "mistake": 18994}
|
||||
TLS_PORTS = {18993, 18995, 18465, 18994}
|
||||
IMAP_REFUSAL = ("NO [ALERT] This server allows only INBUXA webmail and JMAP apps. "
|
||||
"This mail app can't sign in.")
|
||||
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. "
|
||||
"This mail app can't send.")
|
||||
INBUXA = "urn:inbuxa:jmap"
|
||||
failures = []
|
||||
|
||||
@@ -60,7 +70,8 @@ def start(env_file=None):
|
||||
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
||||
"-p", f"127.0.0.1:{PORTS['imap']}:993",
|
||||
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
||||
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
|
||||
"-p", f"127.0.0.1:{PORTS['smtp']}:25",
|
||||
"-p", f"127.0.0.1:{PORTS['mistake']}:1993"]
|
||||
if env_file:
|
||||
args += ["--env-file", env_file]
|
||||
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||
@@ -122,6 +133,64 @@ def accepts(port, timeout=5):
|
||||
return False
|
||||
|
||||
|
||||
def tls(port, timeout=10):
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=timeout))
|
||||
|
||||
|
||||
def lines(sock):
|
||||
"""Yields reply lines, CRLF stripped."""
|
||||
buf = b""
|
||||
while True:
|
||||
while b"\r\n" not in buf:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
return
|
||||
buf += chunk
|
||||
line, buf = buf.split(b"\r\n", 1)
|
||||
yield line.decode(errors="replace")
|
||||
|
||||
|
||||
def imap_login(port, user, password):
|
||||
"""The tagged reply to LOGIN, over implicit TLS."""
|
||||
with tls(port) as sock:
|
||||
read = lines(sock)
|
||||
next(read) # greeting
|
||||
quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
sock.sendall(f"a1 LOGIN {quote(user)} {quote(password)}\r\n".encode())
|
||||
for line in read:
|
||||
if line.startswith("a1 "):
|
||||
return line[3:]
|
||||
return ""
|
||||
|
||||
|
||||
def smtp_auths(port, user, passwords):
|
||||
"""The reply to AUTH PLAIN for each password in turn, on one connection.
|
||||
A reply of "" means the server hung up."""
|
||||
# Every connection reaches the server from Docker's gateway, one IP, and
|
||||
# the stock inbound throttle takes five a second from it. The port checks
|
||||
# just before can use those up, so wait the second out.
|
||||
time.sleep(1.1)
|
||||
replies = []
|
||||
with tls(port) as sock:
|
||||
read = lines(sock)
|
||||
next(read) # greeting
|
||||
sock.sendall(b"EHLO e2e.test\r\n")
|
||||
for line in read:
|
||||
if line[3:4] == " ":
|
||||
break
|
||||
for password in passwords:
|
||||
token = base64.b64encode(f"\0{user}\0{password}".encode()).decode()
|
||||
try:
|
||||
sock.sendall(f"AUTH PLAIN {token}\r\n".encode())
|
||||
replies.append(next(read, ""))
|
||||
except OSError:
|
||||
replies.append("")
|
||||
return replies
|
||||
|
||||
|
||||
def settle(port, want, tries=30):
|
||||
"""Wait for a port to reach the wanted state, so the check is not a race."""
|
||||
for _ in range(tries):
|
||||
@@ -176,6 +245,12 @@ def main():
|
||||
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
|
||||
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
|
||||
|
||||
# A normal sign-in works with the switch on -- the control for LP-6.
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"IMAP sign-in works with the switch on")
|
||||
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||
"submission sign-in works with the switch on")
|
||||
|
||||
# What the screen reads: the locked set and what would close (LP-16, LP-21).
|
||||
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||
if got[0] != "inbuxa:ProtocolPolicy/get":
|
||||
@@ -212,12 +287,40 @@ def main():
|
||||
print(" savedListeners:", sorted(saved))
|
||||
check(saved, "the closed listeners were saved (LP-1)")
|
||||
|
||||
# The second lock (LP-6). Submission stays open, being locked, so sign-in
|
||||
# over it is refused instead -- right password or wrong, the same words,
|
||||
# and never enough of them to be thrown off (LP-11, test 2, test 18).
|
||||
replies = smtp_auths(PORTS["submissions"], admin, [admin_pw] + ["wrong"] * 6)
|
||||
check(replies[0] == SMTP_REFUSAL, "submission refuses the right password (LP-6)")
|
||||
check(all(r == SMTP_REFUSAL for r in replies[1:]),
|
||||
"submission refuses wrong passwords the same way, and doesn't hang up (LP-11)")
|
||||
if not all(r == SMTP_REFUSAL for r in replies):
|
||||
print(" replies:", replies)
|
||||
|
||||
# An IMAP listener that exists by mistake: created while the switch is off
|
||||
# (LP-4 will refuse this later), and live after the restart below.
|
||||
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
|
||||
"name": "imap-mistake", "protocol": "imap", "bind": {"0.0.0.0:1993": True},
|
||||
"tlsImplicit": True}}})
|
||||
mistake = (res[1].get("created") or {}).get("m", {}).get("id")
|
||||
check(mistake is not None, "an IMAP listener can still be created by mistake")
|
||||
|
||||
# A restart must not reopen them: the objects are gone, not just the sockets.
|
||||
stop()
|
||||
start()
|
||||
check(settle(PORTS["imap"], False), "IMAP still closed after a restart")
|
||||
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart")
|
||||
|
||||
if mistake:
|
||||
check(settle(PORTS["mistake"], True), "the mistaken IMAP listener is up")
|
||||
check(imap_login(PORTS["mistake"], admin, admin_pw) == IMAP_REFUSAL,
|
||||
"the mistaken listener refuses the right password (LP-6)")
|
||||
check(imap_login(PORTS["mistake"], admin, "wrong") == IMAP_REFUSAL,
|
||||
"and a wrong one, the same way (LP-11)")
|
||||
check(imap_login(PORTS["mistake"], "[email protected]", "x") == IMAP_REFUSAL,
|
||||
"and an account that doesn't exist (LP-11)")
|
||||
one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [mistake]})
|
||||
|
||||
# Turn it back on: the listeners come back and bind again (LP-5).
|
||||
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
policy_set({"legacyProtocols": "enabled"}))
|
||||
@@ -231,6 +334,12 @@ def main():
|
||||
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
|
||||
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
|
||||
|
||||
# And sign-in works again, with no restart.
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"IMAP sign-in works again once the switch is back on")
|
||||
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||
"submission sign-in works again once the switch is back on")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} FAILED:")
|
||||
|
||||
Reference in New Issue
Block a user