Ferroma
Operations · Documentation

Security

Who should read this: anyone reviewing Ferroma before exposing it to the internet, anyone changing authentication, TLS, mail policy or the storage layer, and any operator who needs to know what a Ferroma deployment does and does not defend against.

This document is the threat model and the control inventory. It states the assumptions, then walks each control — password hashing, tokens and sessions, login throttling, relay prevention, sender and recipient validation, SPF/DKIM/ DMARC, TLS, rate and size limits, HTML handling, path traversal, log hygiene and secrets management — with the real type, function and config key that implements it. It ends with a table mapping every control to its implementation site, and a "known gaps" section listing what Ferroma v1 deliberately does not defend against.

Status: mixed. Everything under §2–§7 and §11–§12 is implemented in ferroma-core, ferroma-auth, ferroma-storage and ferroma-mail and can be read there. Everything under §8 (SPF/DKIM/DMARC), §9 (Received: policy on inbound), §10 (HTML sanitisation) and the API-level controls is (planned): ferroma-smtp and ferroma-api are crate skeletons. The configuration keys, limits and error variants those sections name do exist.


1. Threat model

1.1 What Ferroma is protecting

AssetWhere it livesConsequence of compromise
Mail contentMaildir under storage.maildir_root, blobs under storage.attachment_rootfull read of every user's correspondence
Credentialsusers.password_hash (Argon2id PHC strings)offline crack, then account takeover
Sessions and tokenssessions.token_hash, in-memory access tokenslive account takeover without the password
DKIM private keysdomains.dkim_private_key, [dkim] private_key_pathforge signed mail as the operator's domains
TLS private keytls.key_pathimpersonate the server, decrypt recorded traffic
api.jwt_secretenvironment (FERROMA_JWT_SECRET)mint valid access tokens for any user
The server's sending reputationthe IP address and the domainsthe machine becomes a spam source and gets blocklisted

1.2 Who the adversaries are

AdversaryCapabilityPrimary controls
A remote SMTP peersends arbitrary commands, MAIL FROM, recipients, DATA, MIMErelay policy (§5), sender/recipient validation (§6), size and rate limits (§11), parse limits (§10)
A remote IMAP peerarbitrary commands, literals, huge sequence setsauthentication (§2–§4), imap.require_tls_for_login, limits.max_fetch_messages, imap.max_append_size
An unauthenticated HTTP clientarbitrary JSON bodies, headers, URLstoken verification (§3), error envelope that leaks nothing (api.md §1.3), api.trust_proxy_headers off by default
A legitimate user abusing their accountcan authenticate, send, storeper-account rate limits (submission_rate_limit, daily_send_limit), quota, mailboxes.user_id ownership checks on From
A compromised client deviceholds a refresh tokenrotation-based theft detection (§3), device revocation (§4), token hashing at rest
A local attacker with filesystem accessreads filespassword hashes are Argon2id, tokens are only stored hashed, no plaintext secrets in the mail store
A local attacker with database accessreads and writes rowspath-traversal gates (§12) mean a compromised storage_path still cannot escape the mail root
A malformed-message authordeeply nested MIME, enormous headers, bad encodingsParseLimits (20 levels, 200 parts, limits.max_message_size), total parsing that never panics

1.3 Explicit non-assumptions


2. Passwords

2.1 Argon2id parameters

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 }
    }
}

The stored form is a PHC string, which is why the parameters travel with the hash:

$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
ParameterValueWhy
AlgorithmArgon2id (Algorithm::Argon2id)the hybrid: data-independent first pass resists side-channel attacks, data-dependent later passes resist time-memory tradeoffs. It is the OWASP first choice for new applications
Memory19 456 KiB (19 MiB)memory-hardness is what makes GPU and ASIC cracking expensive. 19 MiB is OWASP's 2024 recommendation and fits comfortably in a per-connection budget
Iterations2the second OWASP-recommended axis; with 19 MiB, more passes buy less than more memory
Parallelism1one lane per hash, so a login flood cannot be amplified by the device's core count
Salt16 random bytes from OsRng (SaltString::generate)unique per password, so a rainbow table is useless and two users with the same password have different hashes
VersionVersion::V0x13current Argon2 version

The requirement this satisfies is offline resistance: users.password_hash may end up in a backup, a database dump or a SQL injection result, and 19 MiB × 2 passes makes a dictionary attack cost real money per guess.

2.2 Verification

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,
    }
}

Two deliberate properties:

2.3 Transparent rehashing

PasswordHasher::needs_rehash(stored) compares the parameters recorded in the PHC string with the current ones, and AuthService::login re-hashes the password while it holds the plaintext:

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

A failed upgrade is logged at warn and does not fail the login. So the parameter set can be raised later and the whole database upgrades itself over time, without a migration and without locking anyone out. Argon2Params::stored_params reads the parameters out of an existing hash, for auditing.

2.4 Password policy

validate_password enforces the length bounds, and strength(password) -> u8 provides a score for the UI:

ConstantValue
MIN_PASSWORD_LENGTH8
MAX_PASSWORD_LENGTH1024 — Argon2 itself has no limit; this bounds request size

There is no composition rule (no "must contain a digit") because length is the property that matters and composition rules push users toward Password1!. There is no breach-corpus check — see §14.

AuthService::change_password revokes every other session: changing a password is the gesture a user makes when they suspect compromise, and leaving other sessions alive would defeat it.

2.5 Login failure accounting

Column / configEffect
users.failed_loginsconsecutive failures, reset by record_login_success
users.locked_untilset by record_login_failure(user_id, now, lockout_secs, max_failed_logins)
limits.max_failed_logins10 — the threshold
limits.login_lockout_secs900 — 15 minutes

3. Access tokens, refresh tokens and rotation-based theft detection

3.1 Two token types

Access tokenRefresh token
FormatJWT, HS256opaque: rt_ + base64url(32 random bytes)
ConstantREFRESH_PREFIX = "rt_", OPAQUE_TOKEN_BYTES = 32
Lifetimeapi.access_token_ttl_secs, 3600 sapi.refresh_token_ttl_secs, 2 592 000 s (30 days)
Stored server-side?no — stateless, verified by signatureyes, hashed: sessions.token_hash
SentAuthorization: Bearer … on every requestonly to /auth/refresh
Revocablenot directly; revoked by revoking the session or rotating the secretyes, sessions.revoked_at

Why both: a stateless access token keeps the hot path free of a database round trip, and a short lifetime bounds the damage of a leaked one. An opaque refresh token is one the server can actually revoke, which a JWT fundamentally is not.

3.2 The access token's claims and their checks

Claims / AccessClaims in crates/ferroma-auth/src/token.rs:

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

TokenService::verify_access_at checks, in this order:

CheckFailure
exactly three dot-separated partsunauthorized("malformed token")
header decodes and parsesunauthorized("malformed token header")
alg == "HS256", exactlyunauthorized("unsupported token algorithm") — this is the alg: none / algorithm-confusion defence; there is no negotiation
signature verifies, before the payload is parsedunauthorized("invalid token signature")
typ == "access"unauthorized("not an access token") — a refresh token cannot be used as 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") — a forged or clock-broken claim is refused rather than trusted indefinitely

The ordering is the point: the signature is verified before any attacker-controlled claim is deserialised into a struct, and the HMAC comparison is constant-time (mac.verify_slice, backed by subtle). There is no alg-driven dispatch to confuse.

jti is a per-token UUID, so an individual token can be identified in a log without logging the token.

3.3 Rotation-based theft detection

Every refresh rotates: the presented session is revoked and a replacement of the same kind is opened.

// crates/ferroma-auth/src/service.rs
if session.revoked_at.is_some() {
    // Reuse of a revoked token: assume compromise and burn the family.
    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(),
    ));
}

The reasoning: an honest client presents a refresh token exactly once and throws it away. If a token is presented twice, either the client is buggy or two parties hold it — and the server cannot tell which. Revoking the whole family is the conservative answer, and it is the standard refresh-token-rotation pattern.

  login      ──► refresh_1 (stored as a hash in sessions)
  refresh    ──► refresh_1 revoked, refresh_2 issued
  refresh_2  ──► fine
  attacker presents refresh_1
             ──► "already used" ⇒ revoke every session of the user
             ──► the legitimate client's refresh_2 stops working too
             ──► the user is forced to log in again, and the theft is visible in the log

AuthService::refresh also refuses an expired session (unauthorized("refresh token expired")) and a disabled account (unauthorized("account disabled")).

3.4 Why the hash, not the token

sessions.token_hash holds hash_token(raw) — SHA-256, lower-case hex, from crates/ferroma-auth/src/token.rs:

SHA-256 of a raw token, lower-case hex. The only form ever written to the database.

A plain SHA-256 rather than Argon2 is correct here and not a shortcut: the input is 32 bytes of CSPRNG output, so there is no dictionary to attack and no work-factor to add. What matters is that a database dump does not contain a directly usable refresh token.

looks_like_opaque_token(value) exists so log scrubbing can recognise a token by its prefix (rt_ or st_) without knowing its value.

3.5 api.jwt_secret

TokenService::from_config uses api.jwt_secret when it is set and non-blank. When it is not:

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

and has_ephemeral_secret() returns true so a caller can refuse to serve in that state. docker-compose.yml and docker-compose.prod.yml both require it (${FERROMA_JWT_SECRET:?set FERROMA_JWT_SECRET in .env}), and .env.example gives the generation command:

openssl rand -base64 48

HS256 is a symmetric signature, so the secret is as sensitive as every session it signs. See §13.


4. Sessions and device revocation

4.1 The sessions row

kind is one of web, api, client, imap, smtp (enforced by sessions_kind_known), so an IMAP session and a Webmail session are distinguishable and independently revocable. SessionKind::is_refreshable() marks the kinds that may use /auth/refresh.

Lifecycle:

OperationMethodEffect
OpenAuthService::open_session(user, kind, device_id, ip, user_agent)inserts the row, returns (Session, TokenPair)
AuthenticateAuthService::authenticate(bearer)verifies the access token, then requires the session to exist and not be revoked
Revoke oneAuthService::logout(session_id)sets revoked_at
Revoke all for a userAuthService::logout_all(user_id) / SessionsRepository::revoke_all_for_useran administrative lockout
PurgeAuthService::purge_expired_sessions()deletes rows past expires_at, indexed by sessions_expiry_idx … WHERE revoked_at IS NULL

A valid signature is not enough. authenticate checks the session row after verifying the token, which is what makes revocation take effect immediately rather than when the access token expires. sessions_expiry_idx is partial on revoked_at IS NULL so the sweeper does not scan dead rows.

4.2 Devices

devices is per installation, keyed (user_id, device_uid) where device_uid is client-generated and stable. device_uid is validated: non-empty and at most 128 characters (AuthService::register_device).

Revoking a device is the remote-wipe gesture from specification §33:

pub async fn revoke_device(&self, device_id: DeviceId) -> Result<u64>
  1. marks the device revoked (devices.revoked_at),
  2. revokes every session belonging to it,
  3. publishes Event::device_revoked(device_id, user_id) so a live WebSocket for
    that device disconnects (fcp.md §9).

The revoked client's next request is 401. Revoking the device you are calling from is allowed and takes effect immediately — a user who has lost a laptop must be able to cut it off from any other device, including one that looks like it.

4.3 Idle sessions

client.session_idle_days (90) is the policy for how long a device session may go unused before it is revoked. It is enforced by a periodic sweep, not by the expires_at column: the refresh token's own api.refresh_token_ttl_secs (30 days) is the hard bound, and the idle policy catches a device that refreshes forever but is never actually used.


5. Login throttling and lockout

Two independent mechanisms, checked in this order in AuthService::login.

5.1 Per-source-IP, checked before any expensive work

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);
    }
}

The threshold is three times the per-account threshold, because one NAT or office egress address can legitimately contain more than ten users. The check happens before the user lookup and before Argon2, so a credential flood cannot spend 19 MiB and two passes per request from a single source. That ordering is the whole reason this block is first.

failure_window is set by AuthService::with_failure_window, so tests can pin it.

5.2 Per-account lockout

StepEffect
Wrong passwordUsersRepository::record_login_failure(user_id, now, lockout_secs, max_failed_logins) increments failed_logins and sets locked_until once the threshold is hit
LockedUser::is_login_allowed(now) is false ⇒ FerromaError::RateLimited
Successrecord_login_success clears failed_logins and locked_until, stamps last_login_at
Botha login_attempts row is written either way, for the audit trail

Both are 429 rate_limited over HTTP (api.md §1.3) and 454 4.7.0 over SMTP (smtp.md §8).

5.3 What is deliberately identical

SituationResponse
Unknown accountinvalid_credentials()
Wrong passwordinvalid_credentials()
Disabled accountinvalid_credentials() and a warn log naming the user id

The comment in the code is explicit: "Unknown account: same message and same cost profile as a wrong password." An attacker cannot enumerate accounts through the login endpoint, and each attempt costs one Argon2 verification either way — which is also why the throttle above has to exist.

The one place the server does distinguish is the audit log, where a disabled account produces "login refused: account disabled". That is for the operator, not for the caller.

5.4 Retention

login_attempts grows with every attempt. login_attempts_created_idx on (created_at) exists for the retention sweep that deletes by age. Without it the table is the largest in the schema on a busy server.


6. Open-relay prevention, sender and recipient validation

6.1 Relay policy

Stated in ../AGENTS.md §4.6 and specification §9.4, and specified in full in smtp.md §6:

  recipient domain is local  →  accept (quota and limits apply)
  recipient domain is remote →  a successful AUTH is required first
ConnectionRecipientResult
unauthenticated, port 25localaccepted, delivered
unauthenticated, port 25remote550 5.7.1 Relaying denied
authenticatedlocal or remoteaccepted, queued if remote

There is no configuration key that turns relaying on. The safe behaviour is unreachable-by-typo, which is the difference between a policy and a suggestion.

6.2 Recipient validation

RCPT TO is resolved through the database, not through a filesystem guess:

  1. Domain: DomainsRepository::find_by_name(normalise_domain(domain)), and
    domains.enabled must be true. Unknown or disabled ⇒ 550 5.1.2.
  2. Local part: MailboxesRepository::find_by_address(domain, local_part)
    mailboxes_address_key (domain_id, local_part) is the index. Missing ⇒ try
    AliasesRepository, then domains.catch_all; still missing ⇒ 550 5.1.1.
  3. mailboxes.enabled must be true; a disabled address is 550 5.1.1.
  4. Recipient count against limits.max_recipients452 4.5.3 when exceeded.

Both lookups use the lower-cased form (EmailAddress::to_lowercase, normalise_domain), and the schema enforces it: mailboxes_local_lowercase CHECK (local_part = lower(local_part)), domains_name_lowercase CHECK (name = lower(name)), users_email_lowercase CHECK (email = lower(email)). Case-insensitivity is a correctness requirement and a security one: two rows differing only in case would make "who is this address" ambiguous.

6.3 Sender validation and address syntax

MAIL FROM is parsed by ferroma_core::EmailAddress::parse, which is strict on purpose. validate_local_part and validate_domain reject, among others:

RejectedWhy
alice (no domain)not an address
alice@ , @example.comempty halves
alice@@example.comtwo separators
.alice@… , alice.@… , al.ice..x@…invalid dot placement
Alice <[email protected]>a display name is not an address; the parser does not guess
ali ce@…whitespace
[email protected] , [email protected]a label may not start or end with -
[email protected]empty label
alice@[192.0.2.1]a domain literal is legal SMTP but never a local mailbox domain, so accepting one would create a mailbox nothing can reach
a quoted local part containing \r, \n or \0header injection

Length bounds: MAX_LOCAL_PART_LEN 64, MAX_DOMAIN_LABEL_LEN 63, MAX_DOMAIN_LEN 255.

A local From must be owned by the authenticated user. An authenticated submission with a MAIL FROM in a local domain may only name a mailboxes row whose user_id is the session's; otherwise 550 5.7.1 Sender address rejected: not owned by user. Without that check, any user could send as any other user in the domain, which is the sender-forgery problem that SPF and DMARC exist to detect at the receiving end.

6.4 Aliases and catch-all

aliases.target is a full address, or a bare local part meaning "same domain". domains.catch_all is a local part that receives mail addressed to a non-existent mailbox. Both are admin-controlled, never user-controlled, and both are resolved after the direct mailbox lookup so that a catch-all can never shadow a real address.

The catch-all is a spam-amplification surface by nature: it accepts mail for arbitrary local parts. It is off by default (catch_all is NULL) and should be turned on only with a reason.


7. TLS

7.1 Policy

ListenerConfigPolicy
SMTP 25smtp.portplaintext with STARTTLS offered — opportunistic, because refusing plaintext inbound loses mail
Submission 587smtp.submission_portSTARTTLS; smtp.require_auth_on_submission forces AUTH, smtp.require_tls_for_auth forces TLS before AUTH
SMTPS 465smtp.smtps_portimplicit TLS from the first octet
IMAP 143imap.portplaintext with STARTTLS
IMAPS 993imap.imaps_portimplicit TLS
HTTPSapi.tls_port0 means the reverse proxy terminates it

tls.enabled gates all of it. Config::validate() refuses to start when smtp.smtps_port != 0 or imap.imaps_port != 0 or api.tls_port != 0 while tls.enabled = false — a configured TLS port with TLS off is a listener that would serve plaintext on a port clients believe is encrypted.

7.2 rustls only

Every TLS-capable dependency in the workspace is pinned to rustls: rustls, tokio-rustls, rustls-pemfile, rustls-pki-types, webpki-roots, and reqwest with default-features = false, features = ["rustls-tls", …]. AGENTS.md §1.1 forbids adding a crate that pulls in native-tls, openssl or schannel, and gives two reasons that agree: the Windows schannel stack on this development host fails with SEC_E_NO_CREDENTIALS, and a memory-safe TLS implementation with an explicit cipher-suite policy is the right choice for a server that terminates SMTP, IMAP and HTTPS itself.

7.3 Certificate handling

KeyMeaning
tls.cert_pathPEM bundle: leaf certificate followed by intermediates
tls.key_pathPEM private key, PKCS#8 or PKCS#1
tls.self_signed_fallbackgenerate a certificate at boot when no PEM is configured
tls.allow_insecure_dev_moderequired for the fallback
tls.min_version"1.2" or "1.3"; anything else refuses to boot
tls.use_platform_rootsalso trust OS-installed roots for outbound verification

self_signed_fallback is explicitly local-development and CI only. It is generated with rcgen, and it is gated twice: tls.allow_insecure_dev_mode must be true, and Config::validate() refuses the combination otherwise:

tls.self_signed_fallback requires tls.allow_insecure_dev_mode = true

An MX with a self-signed certificate cannot be validated by any sending server, so every outbound TLS handshake fails and — worse — the operator may be tempted to turn verification off somewhere else.

7.4 What TLS does and does not buy

7.5 require_tls_for_auth and require_tls_for_login

Both default to false so a bare cargo run works without certificates, and both are set to true in docker-compose.prod.yml:

FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH: 'true'
FERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN: 'true'

AUTH PLAIN, AUTH LOGIN and IMAP LOGIN all send the password in base64 or plaintext. Over an unencrypted socket, a passive observer reads it. A production deployment that leaves these false is one tcpdump away from an account takeover, and the refusal is explicit rather than silent: 538 5.7.11 Encryption required for requested authentication mechanism over SMTP, NO [PRIVACYREQUIRED] over IMAP.

Config::allows_plaintext_auth() answers the question for the whole config without re-deriving it.


8. SPF, DKIM and DMARC

(planned)ferroma-smtp's spf, dkim and dmarc modules are specified but not implemented. The configuration keys and their defaults exist in [dkim] and [policy] of config/ferroma.toml and in DkimConfig / PolicyConfig in crates/ferroma-core/src/config.rs.

8.1 Inbound

CheckConfigOn failure
SPF (RFC 7208)policy.spf_enabled, policy.spf_max_lookups (10)550 5.7.23 on a -all hard fail (planned)
DKIM verification (RFC 6376)dkim.verify_inbound550 5.7.20 (planned)
DMARC (RFC 7489)policy.dmarc_enabled, policy.dmarc_failure_actionpolicy.dmarc_failure_action is none, quarantine or reject; the default is "quarantine" — file to Junk (planned)
Authentication-Resultspolicy.add_auth_resultsthe header is prepended with the verdicts

dmarc_failure_action defaults to quarantine rather than reject for a specific reason: a DMARC p=reject evaluated against a forwarded message — a mailing list, an alumni forwarder — routinely fails SPF and DKIM and is legitimate. Quarantine puts it in Junk where the user can find it; reject loses it. An operator who has measured their forwarding tolerance can raise it.

policy.spf_max_lookups (10) is the RFC 7208 §4.6.4 limit; it exists because an SPF record can be built to force an unbounded number of DNS lookups, which is a denial-of-service vector against both Ferroma and the resolver.

8.2 Outbound

ControlConfig
Which domains are signeddkim.domain (one domain) or every local domain when unset
Selectordkim.selector (default default), published at <selector>._domainkey.<domain>
Signing keydkim.private_key_path, or domains.dkim_private_key
Canonicalisationdkim.canonicalization, "relaxed" or "simple"
Signed headersdkim.headers_to_sign: From, To, Cc, Subject, Date, Message-ID, MIME-Version, Content-Type, Content-Transfer-Encoding, Reply-To, In-Reply-To, References

From being signed is not optional: a DKIM signature that does not cover From can be replayed with a different sender, which is exactly what DMARC alignment checks. headers_to_sign includes it first, and the list matches the specification §16 flow (canonicalise → header hash → body hash → sign → DKIM-Signature).

The private key must never be logged, exported in an API response beyond the public record, or included in a backup that is less protected than the database. GET /api/v1/domains/:id/dkim returns only the public record:

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

8.3 DNS security relevant to mail

RecordSecurity role
PTRa missing or mismatched PTR is the single most common reason a legitimate server is rejected. It is a deliverability control, not an authentication one, which is why it is in deployment.md §2
SPFauthorises sending hosts; -all is the strict form
DKIMproves the message was signed by the domain and was not modified
DMARCtells receivers what to do when SPF and DKIM both fail and how to report it (rua)
MTA-STSrequires TLS for inbound mail to the domain, defeating a downgrade
CAArestricts which CAs may issue for the domain
DNSSECnot implemented or required by Ferroma; it protects the records above where the zone supports it

[dns] configures the resolver Ferroma itself uses: explicit resolvers, timeout_secs (5), attempts (3), cache_ttl_secs (300), negative_ttl_secs (60), tcp_fallback. Use a resolver you trust: an attacker who controls the resolver can forge the MX record for a recipient domain and receive the mail you are delivering.


9. Limits and the request surface

Full per-limit tables are in smtp.md §7, imap.md §10 and api.md §1.6. Summary of the security-relevant ones:

LimitKeyDefaultThreat it bounds
Message sizelimits.max_message_size25 MiBdisk exhaustion, memory per connection
Recipients per transactionlimits.max_recipients100amplification: one connection, many victims
Simultaneous connectionslimits.max_connections100resource exhaustion
Connections per IPlimits.max_connections_per_ip10a single source monopolising the listener
Inbound commands/minute/IPlimits.smtp_rate_limit100command floods
Submissions/hour/accountlimits.submission_rate_limit50a compromised account as a spam cannon
Messages/day/accountlimits.daily_send_limit500the same, at a slower tempo
Mailbox quotalimits.mailbox_quota1 GiBone user filling the disk
Failed loginslimits.max_failed_logins10password guessing
Lockout windowlimits.login_lockout_secs900
MIME nesting depthlimits.max_mime_depth20parser recursion
Parts per messageParseLimits::max_parts200parser fan-out
IMAP fetch per commandlimits.max_fetch_messages5000one FETCH 1:* on a huge folder
IMAP APPEND literalimap.max_append_size25 MiB
HTTP request bodyapi.max_request_size25 MiB
Authentication commandslimits.idle_timeout_secs, limits.data_timeout_secs300 / 600slowloris

Malformed input must be a limit failure, not a panic. ParseLimits:

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

ParsedMessage::parse_with_limits returns FerromaError::LimitExceeded rather than recursing without bound, and ParseLimits::from_limits(&Limits) derives the values from the platform configuration so there is one place to change them. AGENTS.md §4.4 forbids unwrap() on peer input, and the parsers are the reason.


10. Message handling: MIME, HTML and escape hatches

10.1 Total parsing

Parsing is total: any byte string produces a ParsedMessage. The only errors are the explicit resource limits in ParseLimits, because a mail server that rejects a message it cannot render drops real mail.

crates/ferroma-mail/src/message.rs

This is a security property as much as a usability one. A parser that can fail on a class of input creates a class of mail that is silently dropped, which an attacker can use to suppress a message (a password reset, say) by appending a byte sequence. MIME decoding is likewise permissive: decode_base64, decode_quoted_printable and decode_charset return best-effort results with explicit errors only for genuinely undecodable input.

10.2 HTML sanitisation

_(planned)_html_body is promised as sanitised server-side by api.md §5.4, and the requirement that a client still render it in a sandbox is stated there too. There is no sanitisation code in ferroma-mail or ferroma-api yet, and no security.sanitize_html key exists in config/ferroma.toml. Every statement in this section is therefore an obligation on the implementation, not a description of it.

Until it exists, the rules are:

The sanitiser, when it lands, must strip <script>, <style> with expression, <iframe>, <object>, <embed>, <form>, <base>, <meta http-equiv>, all on* attributes and all javascript:/data: URLs, and it must run server-side (the client cannot be trusted to do it and the API feeds several clients).

10.3 What Ferroma does not do with message content


11. Path-traversal defences in the Maildir and the blob store

They exist and they are the reason a compromised database row cannot read /etc/passwd. Full detail in 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())
}

Applied to the domain, the local part (in Maildir::mailbox_dir), the hostname (in Maildir::new) and every segment of a folder name (in maildir_folder_name). A test enumerates the interesting inputs: ["..", ".", "a/b", "a\\b", "", " ", "a:b", "x\0y"] must all be rejected.

The : rejection is not cosmetic: it is the Maildir info separator, and on Windows it introduces an NTFS alternate data stream (imap.md §10).

11.2 absolute

Both stores expose one, and it is the only function that turns a stored storage_path into a real path:

// Maildir::absolute and 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))

Every read, write, delete, set_flags and exists calls it first. Tested:

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(_) is what stops C:\Windows\... on Windows from being treated as relative and joined onto the root.

11.3 The residual risk

Both gates validate the string. A symlink placed inside the mail root by another process is not detected, because std::fs follows symlinks. Ferroma does not create symlinks anywhere, and the mail root should be owned by the service user with no other writer — Dockerfile runs as uid 10001 (ferroma) and chown -R ferroma:ferroma /var/lib/ferroma. An attacker who can write into the mail root has already won by other means, but it is worth stating that this control is about a compromised database, not a compromised host.


12. Logging: what is and is never recorded

12.1 Never logged

AGENTS.md §4.7 and specification §40:

Never loggedWhere the rule is honoured
Passwords, in any formAuthService verifies and discards; PasswordHasher never returns the plaintext
Refresh and session tokensonly hash_token(raw) is stored; looks_like_opaque_token exists so a token can be recognised for scrubbing
Access tokensthe jti claim identifies a token without revealing it
Private keysDKIM and TLS keys are read by the signer and the listener
Full message bodiesMailReceived carries snippet, not body (architecture.md §6)
SMTP/IMAP protocol payloads at infoprotocol debugging is debug, which is not a production level
Message subjects at rest in logsdatabase.log_statements is false by default, with the reason spelled out in the config comment: "it prints message subjects"

crates/ferroma-core/src/logging.rs states it in the crate documentation:

Passwords, AUTH tokens, private keys and full message bodies are never logged.

12.2 What is logged

The SMTP session fields from specification §40 are the ones that make an incident investigable without reading anyone's mail:

connection_id   remote_ip   helo   authenticated_user
sender          recipient   message_id   result   duration

connection_id is a UUID that also appears in the Received: header Ferroma prepends, so a log line and a header can be joined (smtp.md §10) — an operator can answer "where did this message come from" without opening the message.

12.3 Log configuration

KeyDefaultSecurity note
server.log_level"info"debug/trace on ferroma_smtp or ferroma_imap logs protocol detail; keep it off in production
server.log_format"text""json" for shipping; either way the same fields
database.log_statementsfalsenever enable in production
NOISY_DEFAULTShyper=warn,h2=warn,sqlx=warn,hickory_resolver=warn,hickory_proto=warn,rustls=warn,tokio_tungstenite=warnthird-party crates are held at warn unless the operator explicitly opts in, so a dependency cannot start printing request data
RUST_LOG / FERROMA_LOG_LEVELlogging::init_for_tests reads these; a test run is quiet by default

logging::build_filter keeps the operator's directive and appends the noisy-crate defaults only for targets the directive does not already mention, so sqlx=debug is honoured rather than overridden.

12.4 Audit trail

audit_logs is separate from the operational log and is meant to be durable: actor_user_id (FK ON DELETE SET NULL, so the row survives the account), action, target_type, target_id, ip, user_agent, details JSONB, created_at. Admin actions — creating a user, deleting a domain, revoking a device, changing a setting — belong here, not in a tracing line that rotates away.


13. Secrets management

SecretWhere it must liveWhere it must not
api.jwt_secret / FERROMA_JWT_SECRETenvironment, or a secret manager injected as an environment variablethe config file in version control; the backup archive (scripts/backup.sh excludes *.env and credentials*)
POSTGRES_PASSWORD.env, gitignored, or a secret managerthe compose files, which interpolate ${POSTGRES_PASSWORD:?…} and refuse to start without it
DKIM private keydkim.private_key_path on a read-only mount, or domains.dkim_private_keythe public GET /api/v1/domains/:id/dkim response, which returns only the p= public key
TLS private keytls.key_path, mounted read-only (./tls:/etc/ferroma/tls:ro)the image
User passwordsnowhere, ever

Practices the repository already enforces:

Rotation, when it is needed:

SecretRotation cost
FERROMA_JWT_SECRETevery access token is invalidated; clients refresh and continue. Users are not logged out (the refresh token is opaque and unaffected)
POSTGRES_PASSWORDupdate .env, restart both services
DKIM keypublish the new selector's TXT record first, then switch dkim.selector. Do not delete the old record until mail signed with it has aged out (a week is safe; 30 days is safer)
TLS certificatereload; the listener picks up the PEM on start

14. Control → implementation map

#ControlImplementationStatus
1Argon2id, m=19456 t=2 p=1Argon2Params::default, PasswordHashercrates/ferroma-auth/src/password.rsimplemented
2Transparent rehash on loginPasswordHasher::needs_rehash, AuthService::loginimplemented
3Password length policyvalidate_password, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTHimplemented
4Constant-time password verificationPasswordHasher::verify (argon2 + subtle)implemented
5Access token: HS256, no alg negotiationTokenService::verify_access_atimplemented
6Signature verified before claims are parsedverify_access_at orderingimplemented
7Access-token TTLapi.access_token_ttl_secs (3600), TokenService::access_ttl_secsimplemented
8Refresh-token rotationAuthService::refreshimplemented
9Theft detection: reuse revokes the familyAuthService::refresh + revoke_all_for_userimplemented
10Refresh tokens stored hashed onlysessions.token_hash, hash_tokenimplemented
11Session revocation takes effect immediatelyAuthService::authenticate checks the session rowimplemented
12Password change revokes other sessionsAuthService::change_passwordimplemented
13Device registration and revocationdevices, AuthService::register_device / revoke_device, Event::device_revokedimplemented
14Idle-session policyclient.session_idle_days (90)implemented (sweep (planned))
15Per-IP login throttle before hashingAuthService::login + LoginAttemptsRepository::count_failures_for_ipimplemented
16Per-account lockoutusers.failed_logins, users.locked_until, record_login_failure, User::is_login_allowedimplemented
17No account enumerationidentical invalid_credentials() for unknown/wrong/disabledimplemented
18Login attempt audit traillogin_attempts, AuthService::record_attemptimplemented
19Open-relay preventionsmtp.require_auth_on_submission, FerromaError::Forbidden(planned) in ferroma-smtp
20Recipient validationMailboxesRepository::find_by_address, DomainsRepository, AliasesRepository, domains.catch_allimplemented (repositories); (planned) wiring
21Sender address syntax validationEmailAddress::parse, validate_local_part, validate_domainimplemented
22Local From must be owned by the usermailboxes.user_id check on submission(planned)
23Case-normalised addressesschema CHECKs + normalise_domain + to_lowercaseimplemented
24SPFpolicy.spf_enabled, policy.spf_max_lookups(planned)
25DKIM verificationdkim.verify_inbound(planned)
26DKIM signing[dkim] block, DkimConfig(planned)
27DMARCpolicy.dmarc_enabled, policy.dmarc_failure_action(planned)
28Authentication-Resultspolicy.add_auth_resultsimplemented
29TLS everywhere it can be terminated[tls], tls.min_version, smtps_port, imaps_port, api.tls_portimplemented (config); listeners (planned)
30rustls onlyworkspace Cargo.toml pins; AGENTS.md §1.1implemented
31Self-signed cert gated twiceConfig::validate() + tls.allow_insecure_dev_modeimplemented
32No cleartext AUTH/LOGIN in productionsmtp.require_tls_for_auth, imap.require_tls_for_loginimplemented (config); enforcement (planned)
33Message size, recipient, connection and rate limitsLimits, Limits::validate(), [limits]implemented (definition); enforcement (planned)
34Parse limits, no unbounded recursionParseLimits, ParsedMessage::parse_with_limitsimplemented
35Total parsing: no message dropped by a parser errorferroma-mail parser designimplemented
36HTML sanitisationsecurity.sanitize_html(planned)
37Path-traversal defence, mail rootsanitize_component, Maildir::absoluteimplemented
38Path-traversal defence, blob storeAttachmentStore::absolute, path_for_digest validationimplemented
39No unwrap() on peer inputconvention, AGENTS.md §4.4implemented
40Bound SQL parameters onlysqlx::query_as, no sqlx::query!AGENTS.md §4.3implemented
41Secrets never loggedlogging.rs contract, looks_like_opaque_tokenimplemented
42log_statements offdatabase.log_statements = falseimplemented
43Noisy dependencies held at warnNOISY_DEFAULTS, build_filterimplemented
44Durable audit trailaudit_logs, AuditRepositoryimplemented
45Secrets required at bootcompose ${VAR:?} interpolationimplemented
46Container runs unprivilegedDockerfile USER ferroma, uid 10001implemented
47Database not publishedexpose, not ports, on the postgres serviceimplemented
48Secure cookies in productionapi.secure_cookies, set true by docker-compose.prod.ymlimplemented (config)
49CORS closed by defaultapi.cors_origins = [] (same-origin only)implemented (config)
50X-Forwarded-For only when trustedapi.trust_proxy_headers = false by defaultimplemented (config)
51Unauthenticated HTTP cannot reach databearer/cookie on every /api/v1 route except /health, /version, /.well-known/*(planned) in ferroma-api
52Admin endpoints require is_adminAuthenticated::is_admin() check(planned)

Rows marked (planned) follow from the same source (FerromaError, Limits, ApiConfig) and are described in api.md and smtp.md.


15. Known gaps

Deliberate omissions from Ferroma v1. Each is a decision, not an oversight; the mitigation is what an operator should do instead.

15.1 No antivirus or malware scanning

Ferroma does not scan attachments. There is no ClamAV integration, no clamd socket, no virus_scan config key. An attachment is stored because it arrived; whether it is malicious is the recipient's problem.

Why it is acceptable for v1: an antivirus engine is a large, stateful dependency with its own update channel and its own failure modes, and a scanner that silently stops updating is worse than no scanner because it creates false confidence. It is on the extensibility list (specification §57, "病毒扫描").

Operator mitigation: run a clamd and scan the mail root out of band, or route inbound through a gateway. Block executable attachment types at the receiving client, which is where the user actually opens them.

15.2 No Bayesian or heuristic spam filter

There is no content classifier, no X-Spam-Score, no spamassassin integration. The only inbound filtering is:

Why: a Bayesian filter needs a corpus, per-user training and a tuning loop, and a badly tuned one produces false positives that lose real mail — which specification §54 identifies as the risk. Shipping a filter that silently eats invoices is worse than shipping none.

Operator mitigation: put a filtering gateway in front, or use a hosted filtering service. The Junk folder and the \Junk special-use marker are already in the schema (special_use CHECK), so a filter can be added later without a migration.

15.3 No OIDC, no OAuth2, no 2FA

Specification §15 lists OAuth2, OIDC and 2FA under "后续" (later) and §57 repeats them. Ferroma v1 supports password authentication only, over:

There is no TOTP, no WebAuthn, no recovery codes, no mfa_required flag. A phished password is a full account takeover, bounded only by the login throttle and by limits.max_failed_logins.

Operator mitigation: for the Webmail surface, put an SSO proxy in front that performs 2FA and passes an authenticated identity; for SMTP/IMAP there is no honest mitigation other than app passwords managed outside Ferroma. Do not describe a Ferroma deployment as "2FA-protected".

15.4 No cross-process event bus

EventBus is one in-process object (crates/ferroma-events/src/bus.rs). There is no Redis, NATS or PostgreSQL-LISTEN/NOTIFY backend.

Consequences:

SituationResult
Two ferroma processes sharing one databasetwo independent event streams
A WebSocket client on process Adoes not receive an event produced by process B
An IDLEing IMAP session on process Adoes not get pushed a change made through process B
Reconnect / next syncthe change is delivered, because change_log is in the shared database

So the failure mode is a delayed notification, not lost data — and that is the property that makes a single-process bus acceptable for v1. But it means horizontal scaling is not a configuration change: running two replicas behind a load balancer gives users a realtime experience that depends on which replica they landed on.

Why: a broker is another stateful service to operate, secure and monitor, and the specification's first-version Docker stack (§41) is explicitly Ferroma plus PostgreSQL, with Redis under "后期".

Operator mitigation: run one ferroma process. If you need more capacity, scale the database and the storage first; both are likelier bottlenecks than the event fan-out.

15.5 Smaller gaps, stated plainly

GapConsequenceMitigation
No SEARCH BODY / TEXT (imap.md §8)a client searching body text gets no results from the serversearch in the client, or the API's header/subject search
No S/MIME or PGPFerroma cannot encrypt or verify end-to-end signaturesuse a client that does
No DKIM ARC sealinga forwarded message loses its authenticationleave forwarding to clients that can seal
No Sieve or server-side rulesfiltering is client-side only
No breach-corpus password checka user may set a known-breached passwordenforce at account creation from a list you trust
No per-user IP allow-listing for AUTHa stolen password works from anywheredevice revocation, and monitor sessions.ip
No DMARC aggregate report processingrua reports go unread unless the operator reads thempoint rua at a mailbox you check
No request signing on webhooks(planned) webhooks are unauthenticated HTTP POSTsdo not enable webhooks on an untrusted network
No rate limit on GET /.well-known/ferromaan unauthenticated endpoint can be used for reconnaissance and loadfront it with a proxy limit if it matters
PTR is not verified on inboundmail from a host with no PTR is still acceptedSPF/DKIM/DMARC (planned) and a gateway
No alertinga growing queue, a full disk or a login flood is visible only to someone lookingmonitor the health endpoint and mail_queue_status_idx counts — deployment.md §10

TopicDocument
Endpoint-level error mapping and authentication headersapi.md §1
FCP authentication, token rotation from the client's sidefcp.md §2, §11
Reply codes, relay policy, TLS port roles, Received: headersmtp.md
require_tls_for_login, flag handling, APPEND limitsimap.md
Path-traversal functions in full, quota, backup and restorestorage.md §7, §5, §8
Idempotency, tombstone retention, failure matrixsync.md
DNS records, TLS termination, secrets in .env, hardening checklistdeployment.md
Crate graph, layering rule, event bus scopearchitecture.md
Build-time rustls constraint and conventions../AGENTS.md
Ferroma · MIT OR Apache-2.0 · built from docs/