From c35b24b123f1ad721950d0dc531b34e581d12669 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 15:12:45 -0700 Subject: [PATCH] inbuxa:ProtocolPolicy, the switch itself The server-wide legacy-protocols policy: the switch, whether submission closes with it, the listeners taken away to honour it, and who last changed it. Stored like inbuxa:AiLimits, as JSON in the fork's subspace, so an unset field reads as its default and an old record still loads. closes() is where LP-3 lives. imap, pop3 and manageSieve are named outright; smtp is not, because an SMTP listener is inbound or submission depending on its port and nothing else can tell them apart. A listener bound to 25 is inbound whatever it is called, including one that also binds 465, so it stays. http and lmtp are never candidates at all. savedListeners keeps each listener's registry object whole rather than a few fields of it. LP-5 promises the listeners come back exactly as they were, and a listener carries proxy networks, TLS timeouts and socket options that no one should have to re-derive -- a field this code has never heard of has to survive the round trip too, and a test holds that. The module is under security/ rather than beside the rebuilt features, because this one is not a rebuild: upstream has nothing like it. Still only a fact. Nothing reads this policy yet, so no port closes and no sign-in is refused; the acting code needs the listener registry and the config store, which live above this crate. --- crates/features/src/lib.rs | 1 + crates/features/src/security/mod.rs | 13 + .../features/src/security/protocol_policy.rs | 341 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 crates/features/src/security/mod.rs create mode 100644 crates/features/src/security/protocol_policy.rs diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index f0d7fbe..a4ee4a9 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -21,5 +21,6 @@ pub mod ai; pub mod branding; pub mod masked_email; +pub mod security; pub mod tenancy; pub mod undelete; diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs new file mode 100644 index 0000000..d4b16c3 --- /dev/null +++ b/crates/features/src/security/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Security hardening INBUXA adds of its own. +//! +//! Unlike the rest of this crate, these are not rebuilds of anything upstream +//! ships. The legacy-protocols switch is INBUXA's own design, specified in +//! `legacy-protocols.md`. + +pub mod protocol_policy; diff --git a/crates/features/src/security/protocol_policy.rs b/crates/features/src/security/protocol_policy.rs new file mode 100644 index 0000000..057dfb2 --- /dev/null +++ b/crates/features/src/security/protocol_policy.rs @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch +//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under +//! `P` + `p` in the fork's subspace; unset fields read as the defaults. +//! +//! This module is the fact, not the act. It holds what the operator chose and +//! which listeners were taken away to honour it. Closing sockets belongs to +//! `common`, which owns the listener registry, and removing the listener +//! objects belongs to the caller that has the registry to hand: this crate +//! sits below both. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// Whether the legacy mail protocols may be used at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum LegacyProtocols { + /// IMAP, POP3, ManageSieve and SMTP submission work as configured. + #[default] + Enabled, + /// They are off: the ports are closed and sign-in over them is refused. + Disabled, +} + +impl LegacyProtocols { + pub fn is_disabled(&self) -> bool { + matches!(self, LegacyProtocols::Disabled) + } +} + +/// A listener taken away to honour the switch, kept whole so it can be put +/// back exactly as it was (LP-1, LP-5). +/// +/// `object` is the listener's registry object verbatim. Keeping the whole +/// object rather than a few fields is what lets LP-5 promise "exactly the +/// saved listeners": a listener has proxy networks, TLS timeouts and socket +/// options that nobody should have to re-derive, and a field this code has +/// never heard of must survive the round trip too. +#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedListener { + /// The listener's name, as the operator knows it. + pub id: String, + /// `imap`, `pop3`, `manageSieve` or `smtp`, as the registry spells it. + pub protocol: String, + /// The ports it was accepting on, for the confirmation's list (LP-16). + pub ports: Vec, + /// The registry object, whole. + pub object: serde_json::Value, +} + +/// The server-wide switch. +#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ProtocolPolicy { + /// The switch itself. + pub legacy_protocols: LegacyProtocols, + /// With `disabled`, also close SMTP submission (LP-3). The inbound + /// listener on port 25 is never closed, whatever this says. + pub close_submission: bool, + /// The listeners removed when the switch went off (LP-1), for LP-5. + pub saved_listeners: Vec, + /// When the switch last changed, in milliseconds since the epoch. + pub changed_at: Option, + /// The account that last changed it. + pub changed_by: Option, +} + +impl Default for ProtocolPolicy { + fn default() -> Self { + ProtocolPolicy { + legacy_protocols: LegacyProtocols::Enabled, + close_submission: true, + saved_listeners: Vec::new(), + changed_at: None, + changed_by: None, + } + } +} + +/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP. +pub const PROPERTIES: &[&str] = &[ + "legacyProtocols", + "closeSubmission", + "savedListeners", + "changedAt", + "changedBy", +]; + +/// The registry protocols the switch closes, as the schema spells them. +/// +/// `smtp` is deliberately absent: an SMTP listener is submission or inbound +/// depending on its port, which only the caller can tell (LP-3). +pub const LEGACY_PROTOCOLS: &[&str] = &["imap", "pop3", "manageSieve"]; + +/// The port that always means inbound mail, and is never closed (LP-3). +pub const INBOUND_SMTP_PORT: u16 = 25; + +impl ProtocolPolicy { + /// Whether a listener of this protocol and these ports is one the switch + /// closes. A listener bound to port 25 is inbound whatever its name, and + /// any other SMTP listener counts as submission (LP-3). + pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool { + if !self.legacy_protocols.is_disabled() { + return false; + } + if LEGACY_PROTOCOLS.contains(&protocol) { + return true; + } + protocol.eq_ignore_ascii_case("smtp") + && self.close_submission + && !ports.contains(&INBOUND_SMTP_PORT) + } + + /// Whether creating a listener of this protocol is refused right now + /// (LP-4), so a closed port cannot be quietly reopened. + pub fn refuses_new_listener(&self, protocol: &str, ports: &[u16]) -> bool { + self.closes(protocol, ports) + } + + /// What's wrong with these values, naming the property. + pub fn check(&self) -> Result<(), (&'static str, String)> { + if self.saved_listeners.len() > 1024 { + return Err(( + "savedListeners", + "must hold at most 1024 listeners".to_string(), + )); + } + for saved in &self.saved_listeners { + if saved.id.is_empty() { + return Err(("savedListeners", "a saved listener has no id".to_string())); + } + } + Ok(()) + } +} + +fn key() -> ValueClass { + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: b"Pp".to_vec(), + }) +} + +struct Json(ProtocolPolicy); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// The policy in force. +pub async fn get(data: &Store) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key())) + .await + .caused_by(trc::location!())? + .map(|Json(policy)| policy) + .unwrap_or_default()) +} + +/// Stores a new policy. +pub async fn set(data: &Store, policy: &ProtocolPolicy) -> trc::Result<()> { + let bytes = serde_json::to_vec(policy).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disabled() -> ProtocolPolicy { + ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + ..Default::default() + } + } + + /// The default is on, and every property survives the round trip. + #[test] + fn defaults_and_partial_json() { + let policy = ProtocolPolicy::default(); + assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled); + assert!(policy.close_submission); + assert!(policy.check().is_ok()); + + let partial: ProtocolPolicy = + serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap(); + assert!(partial.legacy_protocols.is_disabled()); + assert!( + partial.close_submission, + "an unset closeSubmission reads as the default, true" + ); + + let json = serde_json::to_value(&policy).unwrap(); + for property in PROPERTIES { + assert!(json.get(property).is_some(), "{property}"); + } + assert_eq!(json["legacyProtocols"], "enabled"); + } + + /// While the switch is on, nothing closes (LP-1). + #[test] + fn enabled_closes_nothing() { + let policy = ProtocolPolicy::default(); + for (protocol, ports) in [ + ("imap", vec![993]), + ("pop3", vec![995]), + ("manageSieve", vec![4190]), + ("smtp", vec![465]), + ("smtp", vec![25]), + ] { + assert!(!policy.closes(protocol, &ports), "{protocol} {ports:?}"); + } + } + + /// The mail-app protocols close, and so does submission by default (LP-1). + #[test] + fn disabled_closes_the_legacy_protocols() { + let policy = disabled(); + assert!(policy.closes("imap", &[993])); + assert!(policy.closes("pop3", &[995])); + assert!(policy.closes("manageSieve", &[4190])); + assert!(policy.closes("smtp", &[465])); + assert!(policy.closes("smtp", &[587])); + } + + /// Port 25 is inbound whatever the listener is called, and never closes + /// (LP-3). + #[test] + fn port_25_is_never_closed() { + let policy = disabled(); + assert!(!policy.closes("smtp", &[25])); + assert!( + !policy.closes("smtp", &[25, 465]), + "a listener that also binds 25 is inbound and stays" + ); + } + + /// JMAP, DAV and internal delivery are never touched. + #[test] + fn http_and_lmtp_are_never_closed() { + let policy = disabled(); + assert!(!policy.closes("http", &[443])); + assert!(!policy.closes("lmtp", &[11200])); + } + + /// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2). + #[test] + fn submission_stays_open_when_asked() { + let policy = ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + close_submission: false, + ..Default::default() + }; + assert!(!policy.closes("smtp", &[465])); + assert!(!policy.closes("smtp", &[587])); + assert!( + policy.closes("imap", &[993]), + "the mail-app protocols close regardless" + ); + } + + /// A new legacy listener is refused while the switch is on (LP-4), and an + /// inbound one is still allowed. + #[test] + fn new_legacy_listeners_are_refused() { + let policy = disabled(); + assert!(policy.refuses_new_listener("imap", &[143])); + assert!(!policy.refuses_new_listener("smtp", &[25])); + assert!(!ProtocolPolicy::default().refuses_new_listener("imap", &[143])); + } + + /// A saved listener keeps its whole registry object, so LP-5 can put back + /// fields this code never reads. + #[test] + fn saved_listeners_survive_the_round_trip() { + let policy = ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + saved_listeners: vec![SavedListener { + id: "imaps".to_string(), + protocol: "imap".to_string(), + ports: vec![993], + object: serde_json::json!({ + "bind": ["[::]:993"], + "tls": {"implicit": true}, + "somethingThisCodeHasNeverHeardOf": 7, + }), + }], + ..Default::default() + }; + assert!(policy.check().is_ok()); + + let round_tripped: ProtocolPolicy = + serde_json::from_slice(&serde_json::to_vec(&policy).unwrap()).unwrap(); + assert_eq!(round_tripped, policy); + assert_eq!( + round_tripped.saved_listeners[0].object["somethingThisCodeHasNeverHeardOf"], + 7 + ); + } + + /// A saved listener with no id is refused, naming the property. + #[test] + fn a_nameless_saved_listener_is_refused() { + let policy = ProtocolPolicy { + saved_listeners: vec![SavedListener { + id: String::new(), + protocol: "imap".to_string(), + ports: vec![993], + object: serde_json::Value::Null, + }], + ..Default::default() + }; + assert_eq!(policy.check().unwrap_err().0, "savedListeners"); + } +}