Ferroma
运维 · 文档

安全

谁应该读这份文档:任何准备把Ferroma暴露到互联网之前评审它的人,任何改动认证、TLS、 邮件策略或存储层的人,以及任何需要知道一次Ferroma部署防住了什么、又没防住什么的操作者。

这份文档是威胁模型,也是控制项清单。它先陈述前提假设,再逐条走过每一项控制(密码哈希、 令牌与会话、登录限流、中继预防、发件人与收件人校验、SPF/DKIM/DMARC、TLS、速率与大小限制、 HTML 处理、路径穿越、日志卫生与密钥管理),并给出实现它的真实类型、函数与配置键。文档以 一张把每项控制映射到实现位置的表结束,另有一节「已知缺口」,列出Ferroma v1刻意不防的东西。

状态:混合。§2–§7与§11–§12下的一切都已在ferroma-coreferroma-authferroma-storageferroma-mail中实现,可以直接在那里读到。§8(SPF/DKIM/DMARC)、 §9(收信时的Received:策略)、§10(HTML 净化)以及 API 层的控制项都是_(计划中)_: ferroma-smtpferroma-api还是 crate 骨架。这些小节点名的配置键、限制与错误变体 确实存在。


1. 威胁模型

1.1 Ferroma 在保护什么

资产存放位置失陷后果
邮件内容storage.maildir_root下的Maildir,storage.attachment_root下的二进制对象每个用户的往来信件被完整读取
凭据users.password_hash(Argon2id PHC 字符串)离线破解,随后账号被接管
会话与令牌sessions.token_hash,内存中的访问令牌无需密码即可实时接管账号
DKIM 私钥domains.dkim_private_key[dkim] private_key_path以操作者的域名伪造已签名邮件
TLS 私钥tls.key_path冒充这台服务器,解密已录制的流量
api.jwt_secret环境变量(FERROMA_JWT_SECRET为任意用户签发有效访问令牌
服务器的发信声誉IP 地址与域名这台机器变成垃圾邮件源并被列入黑名单

1.2 对手是谁

对手能力主要控制
远端 SMTP 对端发送任意命令、MAIL FROM、收件人、DATA、MIME中继策略(§5)、发件人/收件人校验(§6)、大小与速率限制(§11)、解析限制(§10)
远端 IMAP 对端任意命令、字面量、超大序号集合认证(§2–§4)、imap.require_tls_for_loginlimits.max_fetch_messagesimap.max_append_size
未认证的 HTTP 客户端任意 JSON 正文、请求头、URL令牌校验(§3)、不泄露任何信息的错误信封(api.md §1.3)、api.trust_proxy_headers默认关闭
滥用自身账号的合法用户能认证、能发信、能存储按账号的速率限制(submission_rate_limitdaily_send_limit)、配额、From上的mailboxes.user_id归属校验
被攻陷的客户端设备持有一枚刷新令牌基于轮换的盗用检测(§3)、设备吊销(§4)、令牌在静态存储时以哈希形式保存
拥有文件系统访问权的本地攻击者读取文件密码哈希是 Argon2id,令牌只以哈希形式存储,邮件存储中没有明文机密
拥有数据库访问权的本地攻击者读写数据行路径穿越门控(§12)意味着即使storage_path被篡改,也仍然逃不出邮件根目录
畸形邮件的作者深层嵌套的 MIME、超大头部、错误编码ParseLimits(20 层、200 个部分、limits.max_message_size)、永不 panic 的全量解析

1.3 明确的非假设


2. 密码

2.1 Argon2id 参数

crates/ferroma-auth/src/password.rs

impl Default for Argon2Params {
    fn default() -> Self {
        // OWASP 2024:m=19456 KiB(19 MiB),t=2,p=1。
        Argon2Params { memory_kib: 19_456, iterations: 2, parallelism: 1 }
    }
}

存储形式是一个 PHC 字符串,参数因此随哈希一起走:

$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
参数取值理由
算法Argon2id(Algorithm::Argon2id混合型:与数据无关的第一趟抵御侧信道攻击,与数据相关的后续趟抵御时间与内存的权衡。它是 OWASP 对新应用的首选
内存19 456 KiB(19 MiB)内存硬性才是让 GPU 与 ASIC 破解昂贵的根本。19 MiB 是 OWASP 2024 年的建议值,也能从容放进每连接的预算
迭代次数2OWASP 推荐的第二个调节轴;在 19 MiB 之下,增加趟数买到的东西不如增加内存
并行度1每个哈希一条通道,登录洪水无法借设备的核心数被放大
来自OsRng的 16 个随机字节(SaltString::generate每个密码唯一,彩虹表因此无用,两个用同一密码的用户哈希也不同
版本Version::V0x13当前的 Argon2 版本

它满足的要求是*离线*抗性:users.password_hash可能出现在备份、数据库转储或 SQL 注入的 结果里,而 19 MiB × 2 趟让字典攻击的每一次猜测都要付出真金白银。

2.2 校验

pub fn verify(&self, password: &str, stored: &str) -> bool {
    match PasswordHash::new(stored) {
        Ok(parsed) => Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(),
        Err(_) => false,
    }
}

两条刻意的性质:

2.3 透明重哈希

PasswordHasher::needs_rehash(stored)把 PHC 字符串里记录的参数与当前参数比较, AuthService::login则在还持有明文的时候重哈希密码:

if self.hasher.needs_rehash(&user.password_hash) {
    match self.hash_password(password).await { … }
}

升级失败会以warn记录,并且不会让登录失败。于是参数集日后可以调高,整个数据库会 随时间自我升级,既不需要迁移,也不会把任何人锁在门外。Argon2Params::stored_params 从一个已有哈希里读出参数,用于审计。

2.4 密码策略

validate_password强制执行长度上下界,strength(password) -> u8为 UI 提供一个评分:

常量取值
MIN_PASSWORD_LENGTH8
MAX_PASSWORD_LENGTH1024,Argon2 本身没有上限,这一条限定的是请求大小

没有组合规则(没有「必须包含一个数字」),因为真正要紧的性质是长度,而组合规则会把 用户推向Password1!没有泄露语料库检查,见§14。

AuthService::change_password会吊销其他每一个会话:改密码是用户怀疑自己被攻陷时做出的 动作,让其他会话继续活着会使这个动作失效。

2.5 登录失败记账

列 / 配置效果
users.failed_logins连续失败次数,由record_login_success重置
users.locked_untilrecord_login_failure(user_id, now, lockout_secs, max_failed_logins)设置
limits.max_failed_logins10,阈值
limits.login_lockout_secs900,即 15 分钟

3. 访问令牌、刷新令牌与基于轮换的盗用检测

3.1 两种令牌

访问令牌刷新令牌
格式JWT,HS256不透明:rt_+base64url(32 个随机字节)
常量REFRESH_PREFIX = "rt_"OPAQUE_TOKEN_BYTES = 32
生存期api.access_token_ttl_secs,3600 秒api.refresh_token_ttl_secs,2 592 000 秒(30 天)
服务端是否存储?,无状态,靠签名校验是,存哈希sessions.token_hash
发送方式每个请求上的Authorization: Bearer …只发给/auth/refresh
可否吊销不能直接吊销;靠吊销会话或轮换密钥来吊销可以,sessions.revoked_at

为什么两者都要:无状态的访问令牌让热路径免去一次数据库往返,而短生存期给泄露令牌造成的 损害封顶。不透明的刷新令牌才是服务器真正能吊销的令牌,而 JWT 从根本上做不到这一点。

3.2 访问令牌的声明及其校验

crates/ferroma-auth/src/token.rs中的Claims / AccessClaims

{ "sub": "7", "sid": 12, "typ": "access", "iss": "mail.example.com",
  "iat": 1789000000, "exp": 1789003600, "jti": "9f2c41…" }

TokenService::verify_access_at按以下顺序校验:

校验失败时
恰好是三个以点分隔的部分unauthorized("malformed token")
头部可解码且可解析unauthorized("malformed token header")
alg == "HS256",精确匹配unauthorized("unsupported token algorithm"),这是对alg: none与算法混淆的防御;不存在协商
签名校验通过,且载荷被解析之前unauthorized("invalid token signature")
typ == "access"unauthorized("not an access token"),刷新令牌不能当访问令牌用
iss == self.issuerunauthorized("token issued for a different server")
exp > nowunauthorized("token expired")
iat <= now + 5 minutesunauthorized("token issued in the future"),伪造或时钟错乱的声明会被拒绝,而不是被无限信任

顺序本身就是重点:在把任何攻击者控制的声明反序列化成结构体之前,先校验签名,而 HMAC 比较是常数时间的(mac.verify_slice,底层是subtle)。这里没有由alg驱动的分派可供混淆。

jti是每枚令牌一个 UUID,因此单枚令牌可以在日志里被指认出来,而不必把令牌记进日志。

3.3 基于轮换的盗用检测

每次刷新都会轮换:出示的那个会话被吊销,并开一个同类型的新会话。

// crates/ferroma-auth/src/service.rs
if session.revoked_at.is_some() {
    // 复用了已吊销的令牌:假定已被攻陷,烧掉整个家族。
    let revoked = self.repos.sessions.revoke_all_for_user(user_id).await?;
    tracing::warn!(user_id = …, session_id = session.id, revoked,
                   "revoked refresh token reused; all sessions revoked");
    return Err(FerromaError::Unauthorized(
        "refresh token was already used; all sessions have been revoked".into(),
    ));
}

理由:诚实的客户端只会出示一次刷新令牌,然后把它丢掉。若一枚令牌被出示两次,要么客户端 有 bug,要么有两方持有它,而服务器无法分辨是哪一种。吊销整个家族是保守的答案,也是刷新 令牌轮换的标准模式。

  登录       ──► refresh_1(以哈希形式存入 sessions)
  刷新       ──► refresh_1 被吊销,签发 refresh_2
  refresh_2  ──► 正常
  攻击者出示 refresh_1
             ──► "already used" ⇒ 吊销该用户的每一个会话
             ──► 合法客户端的 refresh_2 也随之失效
             ──► 用户被迫重新登录,而这次盗用在日志里看得见

AuthService::refresh也会拒绝已过期的会话(unauthorized("refresh token expired"))和 已停用的账号(unauthorized("account disabled"))。

3.4 为什么存哈希,而不是存令牌

sessions.token_hash保存的是hash_token(raw),即 SHA-256、小写十六进制,来自 crates/ferroma-auth/src/token.rs

原始令牌的 SHA-256,小写十六进制。这是唯一会被写入数据库的形式。

这里用普通 SHA-256 而不是 Argon2 是正确的,不是抄近路:输入是 32 字节的 CSPRNG 输出, 没有字典可攻,也没有必要再加工作因子。真正要紧的是,一份数据库转储里不会含有可直接使用 的刷新令牌。

looks_like_opaque_token(value)存在的目的,是让日志清洗能凭前缀(rt_st_)认出 一枚令牌,而不必知道它的值。

3.5 api.jwt_secret

TokenService::from_configapi.jwt_secret已设置且非空白时使用它。未设置时:

tracing::warn!(
    "api.jwt_secret is not configured: generating an ephemeral secret. \
     Every restart will invalidate all sessions. Set FERROMA_JWT_SECRET in production."
);

并且has_ephemeral_secret()返回true,调用方因此可以拒绝在该状态下提供服务。 docker-compose.ymldocker-compose.prod.yml都要求它 (${FERROMA_JWT_SECRET:?set FERROMA_JWT_SECRET in .env}),.env.example给出了生成 命令:

openssl rand -base64 48

HS256 是对称签名,所以这个密钥与它签发的每一个会话同等敏感。见§13。


4. 会话与设备吊销

4.1 sessions

kindwebapiclientimapsmtp之一(由sessions_kind_known强制), 因此 IMAP 会话与 Webmail 会话可以区分,也能各自独立吊销。SessionKind::is_refreshable() 标记出可以使用/auth/refresh的类型。

生命周期:

操作方法效果
开启AuthService::open_session(user, kind, device_id, ip, user_agent)插入数据行,返回(Session, TokenPair)
认证AuthService::authenticate(bearer)校验访问令牌,随后要求会话存在且未被吊销
吊销单个AuthService::logout(session_id)设置revoked_at
吊销某用户全部AuthService::logout_all(user_id) / SessionsRepository::revoke_all_for_user一次管理性锁定
清理AuthService::purge_expired_sessions()删除超过expires_at的数据行,由sessions_expiry_idx … WHERE revoked_at IS NULL索引

签名有效并不够。authenticate在校验令牌之后还会检查会话行,这才使吊销立即生效, 而不是等到访问令牌过期。sessions_expiry_idxrevoked_at IS NULL是部分索引,因此清扫器 不会扫描已死的数据行。

4.2 设备

devices按安装实例划分,键为(user_id, device_uid),其中device_uid由客户端生成且保持 稳定。device_uid会被校验:非空且不超过 128 个字符(AuthService::register_device)。

吊销一台设备就是规范§33所说的远程擦除动作:

pub async fn revoke_device(&self, device_id: DeviceId) -> Result<u64>
  1. 把设备标记为已吊销(devices.revoked_at),
  2. 吊销属于它的每一个会话,
  3. 发布Event::device_revoked(device_id, user_id),使该设备上活跃的 WebSocket 断开
    fcp.md §9)。

被吊销客户端的下一次请求是401。吊销你正在发起调用的那台设备是允许的,并且立即生效: 丢了笔记本的用户必须能从任何其他设备切断它,包括一台看起来像它的设备。

4.3 空闲会话

client.session_idle_days(90)是设备会话在多长时间未使用后应被吊销的策略。它由周期性 清扫执行,而不是靠expires_at列:刷新令牌自身的api.refresh_token_ttl_secs(30 天)是 硬上限,而空闲策略捕捉的是那种一直在刷新、却从未真正被使用的设备。


5. 登录限流与锁定

两套相互独立的机制,在AuthService::login中按此顺序检查。

5.1 按源 IP,在任何昂贵工作之前检查

if let Some(ref ip_text) = ip_str {
    let failures = self.repos.login_attempts
        .count_failures_for_ip(ip_text, now - self.failure_window).await?;
    if failures >= i64::from(self.limits.max_failed_logins) * 3 {
        tracing::warn!(ip = %ip_text, failures, "login throttled by source address");
        self.record_attempt(&email, ip_str.as_deref(), "password", false).await;
        return Err(FerromaError::RateLimited);
    }
}

阈值是每账号阈值的三倍,因为一个 NAT 出口或办公楼出口地址完全可能合法地容纳十个以上 的用户。检查发生在用户查询之前、Argon2之前,因此凭据洪水无法从单一来源为每个 请求花掉 19 MiB 和两趟计算。这个顺序正是这段代码放在最前面的全部理由。

failure_windowAuthService::with_failure_window设置,测试因此可以把它钉死。

5.2 按账号锁定

步骤效果
密码错误UsersRepository::record_login_failure(user_id, now, lockout_secs, max_failed_logins)递增failed_logins,并在达到阈值时设置locked_until
已锁定User::is_login_allowed(now)为假 ⇒ FerromaError::RateLimited
成功record_login_success清除failed_loginslocked_until,写入last_login_at
两种情况无论哪种都会写下一行login_attempts,用于审计轨迹

两者在 HTTP 上都是429 rate_limitedapi.md §1.3),在 SMTP 上是454 4.7.0smtp.md §8)。

5.3 刻意保持一致的部分

情形响应
未知账号invalid_credentials()
密码错误invalid_credentials()
已停用账号invalid_credentials()并且记一条点名用户 id 的warn日志

代码里的注释说得很明确:*「未知账号:与密码错误相同的消息、相同的开销特征。」*攻击者无法 通过登录端点枚举账号,而两种情况下每次尝试都恰好花掉一次 Argon2 校验,这也正是上面的限流 必须存在的原因。

服务器确实区别对待的唯一地方是审计日志,已停用账号会在那里产生 "login refused: account disabled"。那是给操作者看的,不是给调用方看的。

5.4 保留期

login_attempts会随每一次尝试增长。针对(created_at)login_attempts_created_idx就是 为按时间删除的保留期清扫而存在的。没有它,在繁忙的服务器上这张表会是整个 schema 中最大的 一张。


6. 开放中继预防、发件人与收件人校验

6.1 中继策略

在../AGENTS.md §4.6与规范§9.4中陈述,并在smtp.md §6中完整规定:

  收件人域为本地  →  接受(配额与限制照常生效)
  收件人域为远端  →  必须先成功完成 AUTH
连接收件人结果
未认证,25 端口本地接受并投递
未认证,25 端口远端550 5.7.1 Relaying denied
已认证本地或远端接受,远端则入队

没有任何配置键能打开中继。安全的行为是「打错字也打不开」,这正是策略与建议之间的差别。

6.2 收件人校验

RCPT TO通过数据库解析,而不是靠猜文件系统:

  1. 域名:DomainsRepository::find_by_name(normalise_domain(domain)),并且
    domains.enabled必须为真。未知或已停用 ⇒ 550 5.1.2
  2. 本地部分:MailboxesRepository::find_by_address(domain, local_part),索引是
    mailboxes_address_key (domain_id, local_part)。找不到 ⇒ 试AliasesRepository
    再试domains.catch_all;仍然找不到 ⇒ 550 5.1.1
  3. mailboxes.enabled必须为真;已停用的地址是550 5.1.1
  4. 收件人数量对照limits.max_recipients ⇒ 超限时452 4.5.3

两次查询都使用小写形式(EmailAddress::to_lowercasenormalise_domain),schema 也强制 这一点:mailboxes_local_lowercase CHECK (local_part = lower(local_part))domains_name_lowercase CHECK (name = lower(name))users_email_lowercase CHECK (email = lower(email))。大小写不敏感既是正确性要求,也是 安全要求:两行只在大小写上不同的数据会让「这个地址是谁」变得含混。

6.3 发件人校验与地址语法

MAIL FROMferroma_core::EmailAddress::parse解析,它刻意严格。validate_local_partvalidate_domain会拒绝(其中包括):

被拒绝的输入原因
alice(无域名)不是地址
alice@@example.com有一半是空的
alice@@example.com两个分隔符
.alice@…alice.@…al.ice..x@…点的位置非法
Alice <[email protected]>显示名不是地址;解析器不做猜测
ali ce@…含空白
[email protected][email protected]标签不能以-开头或结尾
[email protected]空标签
alice@[192.0.2.1]域名字面量在 SMTP 中合法,但永远不是本地邮箱域,接受它只会造出一个谁也到不了的邮箱
\r\n\0的带引号本地部分头部注入

长度界限:MAX_LOCAL_PART_LEN 64、MAX_DOMAIN_LABEL_LEN 63、MAX_DOMAIN_LEN 255。

本地From必须归已认证用户所有。一次已认证的提交,如果MAIL FROM落在本地域,就只 允许点名user_id属于该会话的那一行mailboxes;否则返回 550 5.7.1 Sender address rejected: not owned by user。没有这项检查,任何用户都能以域内 任何其他用户的身份发信,而这正是 SPF 与 DMARC 在*接收*端存在的原因。

6.4 别名与 catch-all

aliases.target是一个完整地址,或者一个表示「同域」的裸本地部分。domains.catch_all是 一个本地部分,用于接收发往不存在邮箱的邮件。两者都由管理员控制,从不由用户控制,并且都在 直接查询邮箱*之后*解析,因此catch-all永远不会遮蔽一个真实地址。

catch-all天生就是垃圾邮件放大面:它会为任意的本地部分收信。它默认关闭(catch_allNULL),只有在有理由时才应打开。


7. TLS

7.1 策略

监听器配置策略
SMTP 25smtp.port明文,但提供STARTTLS,属机会式,因为拒绝明文收信会丢邮件
提交 587smtp.submission_portSTARTTLSsmtp.require_auth_on_submission强制 AUTH,smtp.require_tls_for_auth强制在 AUTH 之前完成 TLS
SMTPS 465smtp.smtps_port从第一个八位组起就是隐式 TLS
IMAP 143imap.port明文,支持STARTTLS
IMAPS 993imap.imaps_port隐式 TLS
HTTPSapi.tls_port0表示由反向代理终止 TLS

tls.enabled统管这一切。当tls.enabled = falsesmtp.smtps_port != 0imap.imaps_port != 0api.tls_port != 0时,Config::validate()会拒绝启动:一个配置了 TLS 端口却关掉了 TLS 的监听器,会在客户端以为已加密的端口上提供明文。

7.2 只用 rustls

工作区中每一个支持 TLS 的依赖都钉在 rustls 上:rustlstokio-rustlsrustls-pemfilerustls-pki-typeswebpki-roots,以及 default-features = false, features = ["rustls-tls", …]reqwestAGENTS.md §1.1 禁止引入任何会拉进native-tlsopensslschannel的 crate,并给出两条彼此一致的理由: 本开发主机上的 Windows schannel栈会以SEC_E_NO_CREDENTIALS失败;而对一台自己终止 SMTP、 IMAP 与 HTTPS 的服务器来说,内存安全且带显式密码套件策略的 TLS 实现才是正确选择。

7.3 证书处理

含义
tls.cert_pathPEM 包:叶证书后跟中间证书
tls.key_pathPEM 私钥,PKCS#8 或 PKCS#1
tls.self_signed_fallback未配置 PEM 时在启动时生成一张证书
tls.allow_insecure_dev_mode使用回退所必需
tls.min_version"1.2""1.3";其他任何值都拒绝启动
tls.use_platform_roots发信校验时也信任操作系统安装的根证书

self_signed_fallback明确只用于本地开发与 CI。它由rcgen生成,并被两重门控: tls.allow_insecure_dev_mode必须为真,且Config::validate()会拒绝其他组合:

tls.self_signed_fallback requires tls.allow_insecure_dev_mode = true

一台MX用自签名证书就无法被任何发信服务器校验,于是每一次发信 TLS 握手都会失败,更糟的是, 操作者可能被引诱去在别处关掉校验。

7.4 TLS 买到了什么、没买到什么

7.5 require_tls_for_authrequire_tls_for_login

两者默认都是false,这样一次裸的cargo run无需证书就能跑起来,而两者在 docker-compose.prod.yml中都被设为true

FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH: 'true'
FERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN: 'true'

AUTH PLAINAUTH LOGIN与 IMAP 的LOGIN都会以 base64 或明文发送密码。在未加密的 套接字上,被动观察者读得到。生产部署若把它们留作 false,距离账号被接管只差一次tcpdump, 而拒绝是显式的而不是静默的:SMTP 上返回 538 5.7.11 Encryption required for requested authentication mechanism,IMAP 上返回 NO [PRIVACYREQUIRED]

Config::allows_plaintext_auth()无需重新推导就能回答整个配置的这个问题。


8. SPF、DKIM 与 DMARC

_(计划中)_,ferroma-smtpspfdkimdmarc模块已规定但未实现。这些配置键及其 默认值存在于config/ferroma.toml[dkim][policy]中,也存在于 crates/ferroma-core/src/config.rsDkimConfig / PolicyConfig中。

8.1 收信

校验配置失败时
SPF(RFC 7208)policy.spf_enabledpolicy.spf_max_lookups(10)-all硬失败时返回550 5.7.23_(计划中)_
DKIM 校验(RFC 6376)dkim.verify_inbound550 5.7.20_(计划中)_
DMARC(RFC 7489)policy.dmarc_enabledpolicy.dmarc_failure_actionpolicy.dmarc_failure_actionnonequarantinereject;默认是"quarantine",投进Junk_(计划中)_
Authentication-Resultspolicy.add_auth_results该头字段会被前置插入判定结果

dmarc_failure_action默认取quarantine而不是reject,有一个具体原因:对一封*被转发*的 邮件(邮件列表、校友转发器)评估 DMARC p=reject,通常会 SPF 与 DKIM 双双失败,而这封 邮件是合法的。隔离把它放进Junk,用户还能找到;拒绝则直接丢掉。已经测量过自己转发容忍度 的操作者可以提高它。

policy.spf_max_lookups(10)是 RFC 7208 §4.6.4 的限制;它存在,是因为一条 SPF 记录可以被 构造成强制产生无上限次数的 DNS 查询,这是同时针对 Ferroma 和解析器的拒绝服务途径。

8.2 发信

控制配置
对哪些域签名dkim.domain(单个域),未设置时对每一个本地域签名
选择器dkim.selector(默认default),发布在<selector>._domainkey.<domain>
签名密钥dkim.private_key_path,或domains.dkim_private_key
规范化dkim.canonicalization"relaxed""simple"
参与签名的头字段dkim.headers_to_signFromToCcSubjectDateMessage-IDMIME-VersionContent-TypeContent-Transfer-EncodingReply-ToIn-Reply-ToReferences

From被签名不是可选项:一个不覆盖From的 DKIM 签名可以被换上别的发件人重放,而这正是 DMARC 对齐要检查的东西。headers_to_sign把它列在第一位,这个列表与规范§16的流程一致 (规范化 → 头字段哈希 → 正文哈希 → 签名 → DKIM-Signature)。

私钥绝不能被记入日志、绝不能在 API 响应里导出到公开记录之外、也绝不能被放进保护强度低于 数据库的备份里。GET /api/v1/domains/:id/dkim只返回公开记录:

{ "selector": "default", "record_name": "default._domainkey.example.com", "record_type": "TXT", "record_value": "v=DKIM1; k=rsa; p=MIIBIjANBg…" }

8.3 与邮件相关的 DNS 安全

记录安全作用
PTRPTR 缺失或不匹配是合法服务器被拒的最常见单一原因。它是投递率控制,不是认证控制,这也是它在deployment.md §2的原因
SPF授权发信主机;-all是严格形式
DKIM证明邮件由该域签名,且未被改动
DMARC告诉接收方在 SPF 与 DKIM 双双失败时该怎么做,以及如何上报(rua
MTA-STS要求发往该域的收信必须用 TLS,挫败降级
CAA限制哪些 CA 可以为该域签发证书
DNSSECFerroma 未实现也不要求;在区域支持它的地方,它保护上面这些记录

[dns]配置Ferroma自己使用的解析器:显式的resolverstimeout_secs(5)、attempts(3)、 cache_ttl_secs(300)、negative_ttl_secs(60)、tcp_fallback。请使用你信任的解析器: 控制了解析器的攻击者可以伪造收件人域的 MX 记录,从而收走你正在投递的邮件。


9. 限制与请求面

完整的逐项限制表在smtp.md §7、imap.md §10与api.md §1.6。 以下是安全相关的部分摘要:

限制默认值它约束的威胁
邮件大小limits.max_message_size25 MiB磁盘耗尽、每连接内存
每事务收件人数limits.max_recipients100放大:一条连接,多个受害者
并发连接数limits.max_connections100资源耗尽
每 IP 连接数limits.max_connections_per_ip10单一来源独占监听器
收信命令数/分钟/IPlimits.smtp_rate_limit100命令洪水
提交数/小时/账号limits.submission_rate_limit50被攻陷的账号变成垃圾邮件炮台
邮件数/天/账号limits.daily_send_limit500同上,节奏更慢
邮箱配额limits.mailbox_quota1 GiB单个用户塞满磁盘
失败登录limits.max_failed_logins10密码猜测
锁定窗口limits.login_lockout_secs900
MIME 嵌套深度limits.max_mime_depth20解析器递归
每封邮件的部分数ParseLimits::max_parts200解析器扇出
IMAP 每条命令的抓取量limits.max_fetch_messages5000在超大文件夹上的一次FETCH 1:*
IMAP APPEND字面量imap.max_append_size25 MiB
HTTP 请求正文api.max_request_size25 MiB
认证命令limits.idle_timeout_secslimits.data_timeout_secs300 / 600slowloris

畸形输入必须是限制失败,而不是 panic。ParseLimits

pub struct ParseLimits { pub max_depth: usize, pub max_parts: usize,
                        pub max_part_size: usize, pub max_message_size: usize }
// 默认值:20 / 200 / 26214400 / 26214400

ParsedMessage::parse_with_limits返回FerromaError::LimitExceeded,而不是无界递归; ParseLimits::from_limits(&Limits)从平台配置派生出这些值,于是修改它们只有一个地方。 AGENTS.md §4.4禁止在对端输入上使用unwrap(),解析器就是原因。


10. 邮件处理:MIME、HTML 与逃生通道

10.1 全量解析

解析是*全量*的:任意字节串都会产出一个ParsedMessage。唯一的错误是ParseLimits中显式 的资源限制,因为一台拒绝自己无法渲染的邮件的邮件服务器会丢掉真实邮件。

(引自crates/ferroma-mail/src/message.rs

这既是可用性属性,也是安全属性。一个会在某类输入上失败的解析器会造出一类被静默丢弃的邮件, 攻击者只要追加一段字节序列,就能用它压掉一封邮件(比如说一封密码重置邮件)。MIME 解码同样 宽容:decode_base64decode_quoted_printabledecode_charset返回尽力而为的结果,只有 真正无法解码的输入才有显式错误。

10.2 HTML 净化

_(计划中)_api.md §5.4承诺html_body在服务端被净化,客户端仍须在沙箱里 渲染它的要求也在那里写明。ferroma-mailferroma-api里目前还没有净化代码, config/ferroma.toml中也不存在security.sanitize_html键。因此本节的每一句陈述都是对 实现的一项义务,而不是对它的描述。

在它存在之前,规则是:

净化器落地时必须剥掉<script>、带expression<style><iframe><object><embed><form><base><meta http-equiv>、所有on*属性以及所有 javascript:/data: URL,并且必须在服务端运行(客户端不可信任,而 API 会喂给多个客户端)。

10.3 Ferroma 不对邮件内容做的事


11. Maildir 与二进制存储中的路径穿越防御

它们存在,而它们正是一行被篡改的数据库记录读不到/etc/passwd的原因。完整细节见 storage.md §7。

11.1 sanitize_component

// crates/ferroma-storage/src/maildir.rs
pub fn sanitize_component(component: &str) -> Result<String> {
    let trimmed = component.trim();
    if trimmed.is_empty() { return Err(StorageError::Invalid("empty path component".into())); }
    if trimmed == "." || trimmed == ".." {
        return Err(StorageError::Invalid(format!("invalid path component: {trimmed}")));
    }
    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
        return Err(StorageError::Invalid(format!("path component contains a separator: {trimmed}")));
    }
    if trimmed.contains(':') {
        return Err(StorageError::Invalid(format!("path component contains a colon: {trimmed}")));
    }
    Ok(trimmed.to_string())
}

它被施加于域名、本地部分(在Maildir::mailbox_dir中)、主机名(在Maildir::new中)以及 文件夹名的每一段(在maildir_folder_name中)。有一个测试枚举了那些有意思的输入: ["..", ".", "a/b", "a\\b", "", " ", "a:b", "x\0y"]必须全部被拒绝。

:的拒绝不是装饰性的:它是 Maildir 的信息分隔符,而在 Windows 上它会引入 NTFS 备用 数据流(imap.md §10)。

11.2 absolute

两个存储层各暴露一个,它是唯一把存储的storage_path变成真实路径的函数:

// Maildir::absolute 与 AttachmentStore::absolute
if candidate.is_absolute() {
    return Err(StorageError::Invalid(format!("storage path must be relative: {relative_path}")));
}
for component in candidate.components() {
    match component {
        std::path::Component::ParentDir
        | std::path::Component::RootDir
        | std::path::Component::Prefix(_) => {
            return Err(StorageError::Invalid(format!(
                "storage path escapes the mail root: {relative_path}"
            )));
        }
        _ => {}
    }
}
Ok(self.root.join(candidate))

每一次读、写、删除、set_flagsexists都会先调用它。已测:

assert!(m.absolute("../../etc/passwd").is_err());
assert!(m.absolute("/etc/passwd").is_err());
assert!(s.absolute("../../secret").is_err());
assert!(!s.exists("../../secret"));

Component::Prefix(_)正是阻止 Windows 上的C:\Windows\...被当作相对路径并拼接到根目录上 的东西。

11.3 残留风险

两道门控校验的都是*字符串*。由其他进程放进邮件根目录的符号链接不会被发现,因为std::fs 会跟随符号链接。Ferroma 在任何地方都不创建符号链接,而邮件根目录应当归服务用户所有、 没有其他写入者:Dockerfile以 uid 10001(ferroma)运行,并执行 chown -R ferroma:ferroma /var/lib/ferroma。能往邮件根目录里写的攻击者早已通过其他手段 赢了,但值得说明:这项控制针对的是被攻陷的*数据库*,不是被攻陷的*主机*。


12. 日志:记录什么、永不记录什么

12.1 永不记录

AGENTS.md §4.7与规范§40:

永不记录规则在何处被遵守
任何形式的密码AuthService校验后丢弃;PasswordHasher从不返回明文
刷新令牌与会话令牌只有hash_token(raw)被存储;looks_like_opaque_token的存在使令牌能被认出以便清洗
访问令牌jti声明能指认一枚令牌而不泄露它
私钥DKIM 与 TLS 密钥由签名者和监听器读取
完整邮件正文MailReceived携带snippet,而不是正文(architecture.md §6)
info级别的 SMTP/IMAP 协议载荷协议调试是debug,那不是生产级别
静态日志中的邮件主题database.log_statements默认是false,配置注释里把理由写明了:*「它会打印邮件主题」*

crates/ferroma-core/src/logging.rs在 crate 文档中写明了这一点:

密码、AUTH 令牌、私钥与完整邮件正文永不记录。

12.2 记录什么

规范§40中的 SMTP 会话字段,正是让一次事故可以在不读任何人邮件的前提下被调查的那些字段:

connection_id   remote_ip   helo   authenticated_user
sender          recipient   message_id   result   duration

connection_id是一个 UUID,它也会出现在Ferroma前置插入的Received:头字段中,因此一行 日志与一个头字段可以关联起来(smtp.md §10),操作者无需打开邮件就能回答 「这封邮件从哪来」。

12.3 日志配置

默认值安全提示
server.log_level"info"ferroma_smtpferroma_imap上用debug/trace会记录协议细节;生产环境请保持关闭
server.log_format"text"投递日志时用"json";两种格式的字段相同
database.log_statementsfalse生产环境绝不启用
NOISY_DEFAULTShyper=warn,h2=warn,sqlx=warn,hickory_resolver=warn,hickory_proto=warn,rustls=warn,tokio_tungstenite=warn除非操作者显式选择加入,第三方 crate 都被保持在warn,因此一个依赖无法开始打印请求数据
RUST_LOG / FERROMA_LOG_LEVELlogging::init_for_tests会读取它们;测试运行默认是安静的

logging::build_filter保留操作者的指令,只对指令尚未提及的目标追加嘈杂 crate 的默认值, 因此sqlx=debug会被尊重,而不是被覆盖。

12.4 审计轨迹

audit_logs与运行日志分开,且意在持久:actor_user_id(外键ON DELETE SET NULL,因此 数据行能在账号消失后存活)、actiontarget_typetarget_idipuser_agentdetails JSONBcreated_at。管理动作(创建用户、删除域、吊销设备、修改设置)属于这里, 而不属于一条会被轮转掉的tracing行。


13. 密钥管理

密钥必须放在哪里绝不能放在哪里
api.jwt_secret / FERROMA_JWT_SECRET环境变量,或由密钥管理器以环境变量注入版本控制里的配置文件;备份归档(scripts/backup.sh排除了*.envcredentials*
POSTGRES_PASSWORD.env,已 gitignore,或密钥管理器compose 文件,它们会插值${POSTGRES_PASSWORD:?…}并在缺少它时拒绝启动
DKIM 私钥只读挂载上的dkim.private_key_path,或domains.dkim_private_key公开的GET /api/v1/domains/:id/dkim响应,它只返回p=公钥
TLS 私钥tls.key_path,只读挂载(./tls:/etc/ferroma/tls:ro镜像里
用户密码任何地方都不放,永远不放

仓库已经强制执行的实践:

需要轮换时:

密钥轮换代价
FERROMA_JWT_SECRET每一个访问令牌都失效;客户端刷新后继续。用户不会被登出(刷新令牌是不透明的,不受影响)
POSTGRES_PASSWORD更新.env,重启两个服务
DKIM 密钥先发布新选择器的 TXT 记录,再切换dkim.selector。在用它签名的邮件老去之前不要删除旧记录(一周安全;30 天更安全)
TLS 证书重新加载;监听器在启动时读取 PEM

14. 控制项 → 实现位置对照表

#控制项实现状态
1Argon2id,m=19456 t=2 p=1Argon2Params::defaultPasswordHashercrates/ferroma-auth/src/password.rs已实现
2登录时透明重哈希PasswordHasher::needs_rehashAuthService::login已实现
3密码长度策略validate_passwordMIN_PASSWORD_LENGTHMAX_PASSWORD_LENGTH已实现
4常数时间的密码校验PasswordHasher::verify(argon2 + subtle已实现
5访问令牌:HS256,无alg协商TokenService::verify_access_at已实现
6解析声明之前先校验签名verify_access_at的顺序已实现
7访问令牌 TTLapi.access_token_ttl_secs(3600)、TokenService::access_ttl_secs已实现
8刷新令牌轮换AuthService::refresh已实现
9盗用检测:复用即吊销整个家族AuthService::refresh + revoke_all_for_user已实现
10刷新令牌只以哈希形式存储sessions.token_hashhash_token已实现
11会话吊销立即生效AuthService::authenticate检查会话行已实现
12改密码吊销其他会话AuthService::change_password已实现
13设备注册与吊销devicesAuthService::register_device / revoke_deviceEvent::device_revoked已实现
14空闲会话策略client.session_idle_days(90)已实现(清扫_(计划中)_)
15哈希之前的按 IP 登录限流AuthService::login + LoginAttemptsRepository::count_failures_for_ip已实现
16按账号锁定users.failed_loginsusers.locked_untilrecord_login_failureUser::is_login_allowed已实现
17不枚举账号未知/错误/停用一律返回相同的invalid_credentials()已实现
18登录尝试审计轨迹login_attemptsAuthService::record_attempt已实现
19开放中继预防smtp.require_auth_on_submissionFerromaError::Forbiddenferroma-smtp中_(计划中)_
20收件人校验MailboxesRepository::find_by_addressDomainsRepositoryAliasesRepositorydomains.catch_all已实现(仓储层);接线_(计划中)_
21发件人地址语法校验EmailAddress::parsevalidate_local_partvalidate_domain已实现
22本地From必须归该用户所有提交时检查mailboxes.user_id(计划中)
23地址大小写归一schema 的CHECK + normalise_domain + to_lowercase已实现
24SPFpolicy.spf_enabledpolicy.spf_max_lookups(计划中)
25DKIM 校验dkim.verify_inbound(计划中)
26DKIM 签名[dkim]块、DkimConfig(计划中)
27DMARCpolicy.dmarc_enabledpolicy.dmarc_failure_action(计划中)
28Authentication-Resultspolicy.add_auth_results已实现
29凡能终止TLS处都用TLS[tls]tls.min_versionsmtps_portimaps_portapi.tls_port已实现(配置);监听器_(计划中)_
30只用 rustls工作区Cargo.toml的钉版;AGENTS.md §1.1已实现
31自签名证书两重门控Config::validate() + tls.allow_insecure_dev_mode已实现
32生产环境无明文 AUTH/LOGINsmtp.require_tls_for_authimap.require_tls_for_login已实现(配置);强制_(计划中)_
33邮件大小、收件人、连接数与速率限制LimitsLimits::validate()[limits]已实现(定义);强制_(计划中)_
34解析限制,没有无界递归ParseLimitsParsedMessage::parse_with_limits已实现
35全量解析:不因解析错误丢邮件ferroma-mail解析器的设计已实现
36HTML 净化security.sanitize_html(计划中)
37路径穿越防御,邮件根目录sanitize_componentMaildir::absolute已实现
38路径穿越防御,二进制存储AttachmentStore::absolutepath_for_digest校验已实现
39不对对端输入使用unwrap()约定,AGENTS.md §4.4已实现
40只用绑定的 SQL 参数sqlx::query_as,不用sqlx::query!,见AGENTS.md §4.3已实现
41密钥永不记录logging.rs契约、looks_like_opaque_token已实现
42关闭log_statementsdatabase.log_statements = false已实现
43嘈杂依赖被压在warnNOISY_DEFAULTSbuild_filter已实现
44持久审计轨迹audit_logsAuditRepository已实现
45启动时强制要求密钥compose 的${VAR:?}插值已实现
46容器以非特权身份运行DockerfileUSER ferroma,uid 10001已实现
47数据库不对外发布postgres服务上用expose而不是ports已实现
48生产环境的Secure cookieapi.secure_cookies,由docker-compose.prod.yml设为 true已实现(配置)
49默认关闭 CORSapi.cors_origins = [](仅同源)已实现(配置)
50仅在可信时使用X-Forwarded-Forapi.trust_proxy_headers = false默认已实现(配置)
51未认证 HTTP 到不了数据/health/version/.well-known/*外,每条/api/v1路由都要 bearer/cookieferroma-api中_(计划中)_
52Admin 端点要求is_adminAuthenticated::is_admin()检查(计划中)

标记为_(计划中)_的行来自同一批源头(FerromaErrorLimitsApiConfig),并在 api.mdsmtp.md中描述。


15. 已知缺口

Ferroma v1 中刻意的省略。每一条都是决定,不是疏忽;「缓解」一栏说的是操作者应当改做什么。

15.1 没有杀毒或恶意软件扫描

Ferroma 不扫描附件。没有 ClamAV 集成、没有clamd套接字、没有virus_scan配置键。附件被 存储只是因为它到了;它是否有恶意是收件人的问题。

为什么 v1 可以接受:杀毒引擎是一个庞大、有状态、有自己的更新通道和自己的失败模式的 依赖,而一个悄悄停止更新的扫描器比没有扫描器更糟,因为它制造虚假的信心。它在可扩展性清单 上(规范§57,「病毒扫描」)。

操作者的缓解措施:跑一个clamd,在带外扫描邮件根目录,或者让收信经过一道网关。在接收 客户端屏蔽可执行附件类型,那才是用户真正打开它们的地方。

15.2 没有贝叶斯或启发式垃圾邮件过滤

没有内容分类器、没有X-Spam-Score、没有spamassassin集成。唯一的收信过滤是:

为什么:贝叶斯过滤器需要语料、按用户的训练和一个调参闭环,而调得不好的过滤器会产生 误报、丢掉真实邮件,规范§54把这一点认定为风险。发布一个悄悄吃掉发票的过滤器,比不发布 更糟。

操作者的缓解措施:在前面放一道过滤网关,或者用托管过滤服务。Junk文件夹与\Junk 特殊用途标记已经在 schema 里(special_useCHECK),因此日后加过滤器不需要迁移。

15.3 没有 OIDC、没有 OAuth2、没有 2FA

规范§15把OAuth2OIDC2FA列在「后续」之下,§57又重复了一遍。Ferroma v1只支持 密码认证,经由:

没有 TOTP、没有 WebAuthn、没有恢复码、没有mfa_required标志。一个被钓走的密码就是完整的 账号接管,只受登录限流和limits.max_failed_logins约束。

操作者的缓解措施:对 Webmail 这一面,在前面放一个执行 2FA 并传递已认证身份的 SSO 代理;对 SMTP/IMAP,除了在Ferroma之外管理应用专用密码,没有诚实的缓解办法。不要把一次 Ferroma部署说成「受 2FA 保护」。

15.4 没有跨进程事件总线

EventBus是一个进程内对象(crates/ferroma-events/src/bus.rs)。没有 Redis、NATS 或 PostgreSQL 的LISTEN/NOTIFY后端。

后果:

情形结果
两个ferroma进程共用一个数据库两条互不相干的事件流
进程 A 上的 WebSocket 客户端收不到进程 B 产生的事件
进程 A 上正在IDLE的 IMAP 会话不会被推送通过进程 B 做出的变更
重连 / 下一次同步变更被投递,因为change_log在共享数据库里

因此失败模式是通知延迟,而不是数据丢失,而这正是让单进程总线在 v1 可接受的性质。但这 意味着水平扩展不是一次配置变更:在负载均衡后面跑两个副本,用户的实时体验取决于他落在 了哪个副本上。

为什么:broker 是又一个需要运维、加固和监控的有状态服务,而规范的第一版 Docker 栈 (§41)明确就是 Ferroma 加 PostgreSQL,Redis 被放在「后期」。

操作者的缓解措施:只跑一个ferroma进程。如果需要更多容量,先扩数据库和存储;两者都 比事件扇出更可能是瓶颈。

15.5 更小的缺口,直说

缺口后果缓解
没有SEARCH BODY / TEXTimap.md §8)搜索正文的客户端从服务器得不到结果在客户端里搜,或用 API 的头字段/主题搜索
没有 S/MIME 或 PGPFerroma 无法端到端加密或验证签名用一个能做的客户端
没有 DKIM ARC 封签被转发的邮件失去它的认证把转发留给能封签的客户端
没有 Sieve 或服务端规则过滤只能在客户端做
没有泄露语料库密码检查用户可能设置一个已知被泄露的密码在创建账号时按你信任的清单强制执行
没有针对AUTH的按用户 IP 白名单偷来的密码在任何地方都能用设备吊销,并监控sessions.ip
没有 DMARC 聚合报告处理除非操作者去读,rua报告无人问津rua指向你会查看的邮箱
webhook 没有请求签名_(计划中)_的 webhook 是未认证的 HTTP POST不要在不可信网络上启用 webhook
GET /.well-known/ferroma没有速率限制一个未认证端点可被用于侦察和加载如果在意,就在前面加一层代理限制
收信时不校验 PTR来自没有 PTR 的主机的邮件仍被接受SPF/DKIM/DMARC_(计划中)_与一道网关
没有告警队列增长、磁盘写满或登录洪水,只有盯着看的人才知道监控健康检查端点与mail_queue_status_idx计数,见deployment.md §10

16. 相关文档

主题文档
端点级错误映射与认证头api.md §1
FCP 认证、客户端侧的令牌轮换fcp.md §2、§11
应答码、中继策略、TLS 端口角色、Received:头字段smtp.md
require_tls_for_login、标志处理、APPEND限制imap.md
路径穿越函数的全文、配额、备份与恢复storage.md §7、§5、§8
幂等性、墓碑保留、失败矩阵sync.md
DNS 记录、TLS 终止、.env中的密钥、加固检查清单deployment.md
crate 依赖图、分层规则、事件总线的范围architecture.md
构建期的 rustls 约束与约定../AGENTS.md
Ferroma · MIT OR Apache-2.0 · 由 docs/ 生成