1 Commits
11 changed files with 647 additions and 289 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.3] - 2026-08-30
### Added
### Changed
### Fixed
- IMAP authentication is no longer replayed with an inline initial response to backends that do not advertise `SASL-IR` (#4).
## [1.0.2] - 2026-07-31
### Added
Generated
+311 -245
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "proxy"
version = "1.0.2"
version = "1.0.3"
edition = "2024"
license = "AGPL-3.0-only OR LicenseRef-SEL"
description = "Multi-protocol e-mail proxy"
@@ -49,8 +49,7 @@ rustls-platform-verifier = "0.7"
[dev-dependencies]
tempfile = "3"
mockito = "1.7"
testcontainers = "0.27"
testcontainers-modules = { version = "0.15", features = ["redis", "postgres"] }
testcontainers = "0.28"
pwhash = "1"
jmap-client = { version = "0.4", default-features = false, features = ["websockets", "async", "aws_lc_rs"] }
futures-util = "0.3"
+92 -4
View File
@@ -623,6 +623,55 @@ pub async fn hold_login(ctx: &Ctx, identifier: Option<&str>) {
}
}
pub struct BackendLeg {
pub stream: BoxedStream,
pub residual: Vec<u8>,
pub accepts_initial_response: bool,
}
impl BackendLeg {
pub fn new(stream: BoxedStream, residual: Vec<u8>) -> Self {
Self {
stream,
residual,
accepts_initial_response: true,
}
}
pub fn with_initial_response(mut self, accepted: bool) -> Self {
self.accepts_initial_response = accepted;
self
}
}
pub struct AuthFrame {
inline: Vec<u8>,
deferred: Option<(Vec<u8>, Vec<u8>)>,
}
impl AuthFrame {
pub fn inline(frame: Vec<u8>) -> Self {
Self {
inline: frame,
deferred: None,
}
}
pub fn with_deferred(mut self, command: Vec<u8>, response: Vec<u8>) -> Self {
self.deferred = Some((command, response));
self
}
fn wire(&self, accepts_initial_response: bool) -> (&[u8], Option<&[u8]>) {
match &self.deferred {
Some((command, response)) if !accepts_initial_response => {
(command.as_slice(), Some(response.as_slice()))
}
_ => (self.inline.as_slice(), None),
}
}
}
pub enum AuthWire {
Imap { tag: String },
Pop3,
@@ -715,6 +764,7 @@ pub async fn complete_backend_auth(
backend: &mut BoxedStream,
residual: &mut Vec<u8>,
wire: AuthWire,
mut pending_response: Option<&[u8]>,
hide: bool,
max: usize,
) -> Result<bool> {
@@ -723,7 +773,8 @@ pub async fn complete_backend_auth(
let line = crate::outbound::read_line(backend, residual, max).await?;
match classify_auth_line(&line, &wire) {
AuthLine::Continuation => {
crate::outbound::write_all(backend, SASL_EMPTY_RESPONSE).await?;
let response = pending_response.take().unwrap_or(SASL_EMPTY_RESPONSE);
crate::outbound::write_all(backend, response).await?;
}
AuthLine::Passthrough => {
client_out.extend_from_slice(&line);
@@ -763,14 +814,14 @@ pub async fn bridge_authenticated<F, Fut>(
started: Instant,
label: &'static str,
wire: AuthWire,
auth_frame: &[u8],
auth: &AuthFrame,
unavailable: &[u8],
max: usize,
open: F,
) -> Result<()>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<(BoxedStream, Vec<u8>)>>,
Fut: Future<Output = Result<BackendLeg>>,
{
if let Some(id) = identifier
&& !crate::token::valid_routing_identifier(id)
@@ -784,7 +835,11 @@ where
return Ok(());
}
let (mut backend, mut backend_residual) = match establish(ctx, dest_id, peer, open).await {
let BackendLeg {
stream: mut backend,
residual: mut backend_residual,
accepts_initial_response,
} = match establish(ctx, dest_id, peer, open).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, protocol = label, destination = %dest_id, "backend unavailable");
@@ -793,6 +848,7 @@ where
}
};
let (auth_frame, pending_response) = auth.wire(accepts_initial_response);
crate::outbound::write_all(&mut backend, auth_frame).await?;
let hide = ctx.hide_auth_errors(dest_id);
@@ -803,6 +859,7 @@ where
&mut backend,
&mut backend_residual,
wire,
pending_response,
hide,
max,
),
@@ -910,6 +967,37 @@ pub async fn read_crlf_line(
mod tests {
use super::*;
#[test]
fn auth_frame_defers_initial_response_when_not_accepted() {
let frame = AuthFrame::inline(b"a1 AUTHENTICATE PLAIN AGFAYgBj\r\n".to_vec())
.with_deferred(
b"a1 AUTHENTICATE PLAIN\r\n".to_vec(),
b"AGFAYgBj\r\n".to_vec(),
);
assert_eq!(
frame.wire(true),
(b"a1 AUTHENTICATE PLAIN AGFAYgBj\r\n".as_slice(), None)
);
assert_eq!(
frame.wire(false),
(
b"a1 AUTHENTICATE PLAIN\r\n".as_slice(),
Some(b"AGFAYgBj\r\n".as_slice())
)
);
}
#[test]
fn auth_frame_without_deferred_form_stays_inline() {
let frame = AuthFrame::inline(b"AUTH PLAIN AGFAYgBj\r\n".to_vec());
assert_eq!(
frame.wire(false),
(b"AUTH PLAIN AGFAYgBj\r\n".as_slice(), None)
);
}
#[test]
fn advertises_token_handles_smtp_ehlo_continuation_lines() {
let ehlo = b"250-mail.example\r\n250-PIPELINING\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 CHUNKING\r\n";
+13 -7
View File
@@ -78,10 +78,14 @@ pub async fn handle(
let client_tls = stream.is_tls();
let fwd = forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl);
let auth_frame = format!(
"{tag} AUTHENTICATE {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
let mech_name = mechanism_name(&mech);
let response = BASE64.encode(&raw_challenge);
let auth = common::AuthFrame::inline(
format!("{tag} AUTHENTICATE {mech_name} {response}\r\n").into_bytes(),
)
.with_deferred(
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
format!("{response}\r\n").into_bytes(),
);
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
@@ -95,7 +99,7 @@ pub async fn handle(
started,
"imap",
common::AuthWire::Imap { tag },
auth_frame.as_bytes(),
&auth,
unavailable.as_bytes(),
MAX_PREAUTH,
|| open_backend(ctx, &dest_id, &fwd),
@@ -477,7 +481,7 @@ async fn open_backend(
ctx: &Ctx,
dest_id: &str,
fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> {
) -> Result<common::BackendLeg> {
let common::DialedEndpoint {
dest,
ep,
@@ -490,6 +494,7 @@ async fn open_backend(
let mut residual = Vec::new();
let banner = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?;
let mut sasl_ir = common::advertises_token(&banner, "SASL-IR");
if ep.tls == TlsMode::Starttls {
if banner.contains(&b'[') && !common::advertises_token(&banner, "STARTTLS") {
@@ -500,6 +505,7 @@ async fn open_backend(
if !tagged_ok(&resp, b"P1") {
return Err(ProxyError::backend("backend refused STARTTLS"));
}
sasl_ir = common::advertises_token(&resp, "SASL-IR");
backend = outbound::tls_connect(backend, &tls_cfg, dest).await?;
}
@@ -509,7 +515,7 @@ async fn open_backend(
forward::send_imap_id(&mut backend, &mut residual, &banner, fwd, MAX_PREAUTH).await?;
}
Ok((backend, residual))
Ok(common::BackendLeg::new(backend, residual).with_initial_response(sasl_ir))
}
fn tagged_ok(line: &[u8], tag: &[u8]) -> bool {
+10 -7
View File
@@ -66,10 +66,13 @@ pub async fn handle(
ctx.config.server.proxy_ttl,
);
let auth_frame = format!(
"AUTHENTICATE \"{}\" \"{}\"\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
let auth = common::AuthFrame::inline(
format!(
"AUTHENTICATE \"{}\" \"{}\"\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
common::bridge_authenticated(
@@ -82,7 +85,7 @@ pub async fn handle(
started,
"managesieve",
common::AuthWire::ManageSieve,
auth_frame.as_bytes(),
&auth,
b"NO \"backend unavailable\"\r\n",
MAX_PREAUTH,
|| open_backend(ctx, &dest_id, &fwd),
@@ -468,7 +471,7 @@ async fn open_backend(
ctx: &Ctx,
dest_id: &str,
fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> {
) -> Result<common::BackendLeg> {
let common::DialedEndpoint {
dest,
ep,
@@ -509,7 +512,7 @@ async fn open_backend(
.await?;
}
Ok((backend, residual))
Ok(common::BackendLeg::new(backend, residual))
}
async fn read_capabilities(stream: &mut BoxedStream, residual: &mut Vec<u8>) -> Result<Vec<u8>> {
+10 -7
View File
@@ -65,10 +65,13 @@ pub async fn handle(
ctx.config.server.proxy_ttl,
);
let auth_frame = format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
let auth = common::AuthFrame::inline(
format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
common::bridge_authenticated(
@@ -81,7 +84,7 @@ pub async fn handle(
started,
"pop3",
common::AuthWire::Pop3,
auth_frame.as_bytes(),
&auth,
b"-ERR backend unavailable\r\n",
MAX_PREAUTH,
|| open_backend(ctx, &dest_id, &fwd),
@@ -404,7 +407,7 @@ async fn open_backend(
ctx: &Ctx,
dest_id: &str,
fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> {
) -> Result<common::BackendLeg> {
let common::DialedEndpoint {
dest,
ep,
@@ -447,7 +450,7 @@ async fn open_backend(
.await?;
}
Ok((backend, residual))
Ok(common::BackendLeg::new(backend, residual))
}
async fn backend_advertises_stls(
+10 -7
View File
@@ -74,10 +74,13 @@ pub async fn handle_submission(
ctx.config.server.proxy_ttl,
);
let auth_frame = format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
let auth = common::AuthFrame::inline(
format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
common::bridge_authenticated(
@@ -90,7 +93,7 @@ pub async fn handle_submission(
started,
"submission",
common::AuthWire::Smtp,
auth_frame.as_bytes(),
&auth,
b"421 4.3.0 backend unavailable\r\n",
MAX_PREAUTH,
|| open_backend(ctx, &dest_id, &fwd),
@@ -523,7 +526,7 @@ async fn open_backend(
ctx: &Ctx,
dest_id: &str,
fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> {
) -> Result<common::BackendLeg> {
let common::DialedEndpoint {
dest,
ep,
@@ -568,7 +571,7 @@ async fn open_backend(
}
}
Ok((backend, residual))
Ok(common::BackendLeg::new(backend, residual))
}
fn advertised_xclient_attrs(ehlo_reply: &[u8]) -> Vec<String> {
+150 -1
View File
@@ -57,7 +57,7 @@ async fn backend_expect_mech(
mech: &'static str,
want_user: &'static str,
) {
sock.write_all(b"* OK [CAPABILITY IMAP4rev2] backend ready\r\n")
sock.write_all(b"* OK [CAPABILITY IMAP4rev2 SASL-IR] backend ready\r\n")
.await
.unwrap();
@@ -652,3 +652,152 @@ tls = "starttls"
);
handler.abort();
}
const EXCHANGE_GREETING: &[u8] =
b"* OK [CAPABILITY IMAP4 IMAP4rev1 AUTH=PLAIN AUTH=NTLM AUTH=GSSAPI UIDPLUS MOVE ID \
CHILDREN IDLE NAMESPACE LITERAL+] backend ready\r\n";
async fn backend_without_sasl_ir(mut sock: tokio::net::TcpStream, want_user: &'static str) {
sock.write_all(EXCHANGE_GREETING).await.unwrap();
let command = read_line(&mut sock).await;
assert_eq!(
command, "a1 AUTHENTICATE PLAIN",
"backend without SASL-IR must not receive an inline initial response"
);
sock.write_all(b"+ \r\n").await.unwrap();
let response = read_line(&mut sock).await;
let raw = BASE64.decode(response.trim()).unwrap();
assert_eq!(
raw,
format!("\0{want_user}\0secret").into_bytes(),
"deferred initial response should carry the credentials"
);
sock.write_all(b"a1 OK [CAPABILITY IMAP4rev1] authenticated\r\n")
.await
.unwrap();
let _ = read_until(&mut sock, b"NOOP").await;
sock.write_all(b"a2 OK NOOP done\r\n").await.unwrap();
}
async fn run_against_backend_without_sasl_ir(
ctx: std::sync::Arc<crate::proto::common::Ctx>,
first: &[u8],
) -> Vec<u8> {
let listener = ctx.config.listener["imap"].clone();
let (mut client, server) = tcp_pair().await;
let ctx2 = ctx.clone();
let handler = tokio::spawn(async move {
let _ = crate::proto::imap::handle(
&ctx2,
&listener,
Box::new(server),
"127.0.0.1:40010".parse().unwrap(),
"127.0.0.1:143".parse().unwrap(),
)
.await;
});
let _greeting = read_line(&mut client).await;
send(&mut client, first).await;
let resp = read_until(&mut client, b"a1 OK").await;
send(&mut client, b"a2 NOOP\r\n").await;
let mut resp = resp;
resp.extend_from_slice(&read_until(&mut client, b"a2 OK").await);
handler.abort();
resp
}
#[tokio::test]
async fn imap_defers_initial_response_when_backend_lacks_sasl_ir() {
let (port, backend) =
dummy_backend(|sock| backend_without_sasl_ir(sock, "alice@example.com")).await;
let mapping = mapping_file("");
let toml = config(port, &mapping.path().display().to_string());
let ctx = make_ctx(&toml).await;
let ir = BASE64.encode(b"\0alice@example.com\0secret");
let resp = run_against_backend_without_sasl_ir(
ctx,
format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes(),
)
.await;
assert!(
find(&resp, b"a1 OK").is_some(),
"expected a1 OK, got {:?}",
String::from_utf8_lossy(&resp)
);
assert!(
find(&resp, b"a2 OK").is_some(),
"expected bridged a2 OK, got {:?}",
String::from_utf8_lossy(&resp)
);
let _ = backend.await;
}
#[tokio::test]
async fn imap_login_defers_initial_response_when_backend_lacks_sasl_ir() {
let (port, backend) =
dummy_backend(|sock| backend_without_sasl_ir(sock, "bob@example.com")).await;
let mapping = mapping_file("");
let toml = config(port, &mapping.path().display().to_string());
let ctx = make_ctx(&toml).await;
let resp =
run_against_backend_without_sasl_ir(ctx, b"a1 LOGIN bob@example.com secret\r\n").await;
assert!(
find(&resp, b"a1 OK").is_some(),
"expected a1 OK, got {:?}",
String::from_utf8_lossy(&resp)
);
let _ = backend.await;
}
#[tokio::test]
async fn imap_keeps_inline_initial_response_when_backend_advertises_sasl_ir() {
let (port, backend) = dummy_backend(|mut sock: tokio::net::TcpStream| async move {
sock.write_all(b"* OK [CAPABILITY IMAP4rev2 SASL-IR] backend ready\r\n")
.await
.unwrap();
let command = read_line(&mut sock).await;
let ir = BASE64.encode(b"\0dave@example.com\0secret");
assert_eq!(command, format!("a1 AUTHENTICATE PLAIN {ir}"));
sock.write_all(b"a1 OK authenticated\r\n").await.unwrap();
let _ = read_until(&mut sock, b"NOOP").await;
sock.write_all(b"a2 OK NOOP done\r\n").await.unwrap();
})
.await;
let mapping = mapping_file("");
let toml = config(port, &mapping.path().display().to_string());
let ctx = make_ctx(&toml).await;
let listener = ctx.config.listener["imap"].clone();
let (mut client, server) = tcp_pair().await;
let ctx2 = ctx.clone();
let handler = tokio::spawn(async move {
let _ = crate::proto::imap::handle(
&ctx2,
&listener,
Box::new(server),
"127.0.0.1:40011".parse().unwrap(),
"127.0.0.1:143".parse().unwrap(),
)
.await;
});
let _greeting = read_line(&mut client).await;
let ir = BASE64.encode(b"\0dave@example.com\0secret");
send(
&mut client,
format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes(),
)
.await;
let resp = read_until(&mut client, b"a1 OK").await;
assert!(find(&resp, b"a1 OK").is_some());
let _ = backend.await;
handler.abort();
}
+15 -3
View File
@@ -4,16 +4,28 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::redis::{REDIS_PORT, Redis};
use testcontainers::{ContainerAsync, GenericImage};
use crate::config::RedisMappingConfig;
use crate::route::store::redis::RedisStore;
const REDIS_PORT: u16 = 6379;
async fn redis_container() -> ContainerAsync<GenericImage> {
GenericImage::new("redis", "7.4-alpine")
.with_exposed_port(REDIS_PORT.tcp())
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
.start()
.await
.unwrap()
}
#[tokio::test]
async fn redis_store_resolves_and_misses() {
let node = Redis::default().start().await.unwrap();
let port = node.get_host_port_ipv4(REDIS_PORT).await.unwrap();
let node = redis_container().await;
let port = node.get_host_port_ipv4(REDIS_PORT.tcp()).await.unwrap();
let url = format!("redis://127.0.0.1:{port}");
let client = redis::Client::open(url.as_str()).unwrap();
+25 -5
View File
@@ -4,16 +4,36 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
use crate::config::SqlMappingConfig;
use crate::route::store::sql::SqlStore;
const POSTGRES_PORT: u16 = 5432;
async fn postgres_container() -> ContainerAsync<GenericImage> {
GenericImage::new("postgres", "17-alpine")
.with_exposed_port(POSTGRES_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr(
"database system is ready to accept connections",
))
.with_wait_for(WaitFor::message_on_stderr(
"database system is ready to accept connections",
))
.with_env_var("POSTGRES_USER", "postgres")
.with_env_var("POSTGRES_PASSWORD", "postgres")
.with_env_var("POSTGRES_DB", "postgres")
.start()
.await
.unwrap()
}
#[tokio::test]
async fn sql_store_resolves_and_misses() {
let node = Postgres::default().start().await.unwrap();
let port = node.get_host_port_ipv4(5432).await.unwrap();
let node = postgres_container().await;
let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
sqlx::any::install_default_drivers();
@@ -49,8 +69,8 @@ async fn sql_store_resolves_and_misses() {
#[tokio::test]
async fn sql_store_upsert_and_remove() {
let node = Postgres::default().start().await.unwrap();
let port = node.get_host_port_ipv4(5432).await.unwrap();
let node = postgres_container().await;
let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
sqlx::any::install_default_drivers();