Installez Postfix depuis votre distribution et acceptez les défauts, et vous avez un serveur de messagerie qui envoie votre courrier en clair à tout serveur qui n’offre pas de TLS, et qui accepte du courrier de quiconque prétend être qui il veut. Le main.cf livré par Debian met les deux niveaux TLS à may. Rien ne vérifie SPF. Rien ne vérifie DKIM. Un domaine expéditeur qui n’existe pas est accepté.

Ce n’est pas un bug. C’est ce que les standards demandaient il y a vingt ans, et c’est ainsi que tournent encore aujourd’hui quantité de serveurs de messagerie sur internet.

Ce billet construit l’autre genre. Il reprend les règles maison du billet d’ouverture de cette série et met chacune d’elles dans un Postfix qui fonctionne, avec la réponse que chacune produit lorsqu’elle refuse quelque chose. L’argumentaire en faveur des règles est dans ce billet-là et je ne le referai pas deux fois. Celui-ci, c’est la construction.

RègleLe Postfix de Debian tel que livréCette installationCe qu’une compromission obtient
Pas de clair en entréeTLS proposé, jamais exigéencrypt sur chaque écouteur530 5.7.0 Must issue a STARTTLS command first
Pas de clair en sortieTLS si proposé, clair sinonencrypt, DANE ou MTA-STS là où c’est publiédifféré, dsn=4.7.4 ou dsn=4.7.5
IPv6 sur toutinet_protocols = allpareil, plus un AAAA sur chaque MXchaque test ci-dessous a tourné sur les deux familles
Relais seulement avec un loginrelaie pour mynetworksle port 25 ne relaie jamais, 587 et 465 exigent un login554 5.7.1 … Relay access denied
Un login envoie en son propre nomnon vérifiéenveloppe et From: tous deux liés au login553 5.7.1 … not owned by user
Le domaine expéditeur doit existernon vérifiéreject_unknown_sender_domain, code 550550 5.1.8 … Domain not found
Échec SPF, échec DKIMnon vérifiéRspamd, avec rejets forcés554 5.7.1 SPF hard fail / DKIM signature does not verify

Chaque réponse de ce billet a été capturée depuis un serveur en marche, en IPv4 et IPv6. Plusieurs réglages se sont révélés ne rien faire tant qu’un autre n’était pas posé lui aussi. J’ai trouvé chacun d’eux en le regardant ne rien faire. Ils sont signalés plus bas, parce que ce sont eux qui vous mordront.

Le labo

Un réseau isolé, chaque hôte sur les deux familles d'adressesDNS ET POLITIQUEréseau maillab : 10.89.50.0/24 et fd89:50::/64, internelab-nsBIND 9.20.30, signe test..10 et ::10lab-resolverunbound 1.26.1, valide.11 et ::11lab-tlspolpostfix-tlspol 1.14.4.25 et ::25lab-alphaPostfix 3.10.13alpha.test.20 et ::20lab-bravoStalwart 0.16.27bravo.test.21 et ::21lab-plainPostfix, sans TLSplain.test.22 et ::22lab-clientswaks, expéditeur de testsender.test.50 et ::50SERVEURS DE MESSAGERIEAC jetable pour les certificats. Chaque test de ces billets a tourné sur les deux familles.
Tout dans le billet a d’abord tourné ici. Rien là-dessus ne peut atteindre internet, donc rien là-dessus n’est non plus une supposition sur internet
ComposantVersionRôle
Postfix3.10.13, Debian trixiele serveur MX et de soumission pour alpha.test
Dovecot2.4.1les mots de passe des logins, tous les protocoles mail désactivés
Rspamd, Redis3.12.1, 8.0.2SPF, DKIM, DMARC, signature DKIM
postfix-tlspol1.14.4la politique sortante depuis TLSA et MTA-STS
BIND, unbound9.20.30, 1.26.1une zone test. privée et signée, et un résolveur qui la valide
DomaineÀ quoi il sert
alpha.testle serveur que construit ce billet
bravo.testStalwart, qui aura son propre billet
plain.testle mauvais voisin : zone non signée, pas de STARTTLS
sender.testSPF -all, DKIM, DMARC p=reject : pour forger, altérer et réexpédier

Chaque écouteur s’attache aux deux familles, et chaque MX a un enregistrement A et un AAAA. IPv6 n’est pas une option ici. Un serveur de messagerie sans elle est un défaut, et prouver que celui-ci l’a est simple : lancez chaque vérification deux fois.

Étape par étape

Une session SMTP est une séquence fixe, et chaque règle de cette installation y a une place naturelle. L’ordre importe pour une raison : refuser aussi tôt que la preuve le permet. Un domaine expéditeur qui n’existe pas est connu dès MAIL FROM, donc il n’y a aucune raison de prendre le corps avant de le refuser. Un corps altéré n’est connu qu’après DATA, et c’est donc là qu’il est refusé.

Où chaque règle mord, et ce que le serveur a ditÉTAPELA RÈGLE, ET LA RÉPONSE CAPTURÉE DANS LE LABOEHLOSTARTTLSAUTHMAIL FROMRCPT TODATApas d'AUTH proposé tant que TLS n'est pas làsmtpd_tls_auth_only = yessautez-le et rien d'autre n'est accepté530 5.7.0 Must issue a STARTTLS command first587 et 465 seulement ; le port 25 n'a pas de login554 5.7.1 Client host rejected: Access deniedle domaine expéditeur doit exister550 5.1.8 Sender address rejected: Domain not foundnotre propre domaine exige un login554 5.7.1 Our own domain: send through submissionle login doit posséder l'adresse553 5.7.1 not owned by user alice@alpha.testle port 25 ne relaie jamais554 5.7.1 Relay access deniedRspamd: SPF, DKIM, DMARC554 5.7.1 SPF hard fail / DKIM does not verifyl'en-tête From: doit être le login554 5.7.1 You may only send as your own addressPostfix retient les rejets de MAIL FROM jusqu'à RCPT TO, donc ces trois réponses arrivent une étape plus tard.
Chaque règle a son étape. Rejetez aussi tôt que la preuve le permet

Le reste du billet parcourt ces étapes dans l’ordre : TLS en entrée, TLS en sortie, logins et relais, adresses expéditrices, puis ce que Rspamd fait du corps.

Pas de clair en entrée

Une ligne :

smtpd_tls_security_level = encrypt

Un client qui tente de sauter STARTTLS n’obtient que le refus :

<-  220 mail.alpha.test ESMTP Postfix
 -> MAIL FROM:<carol@sender.test>
<** 530 5.7.0 Must issue a STARTTLS command first

Lisez maintenant ce que le propre manuel de Postfix dit de ce réglage :

According to RFC 2487 this MUST NOT be applied in case of a publicly-referenced SMTP server. Instead, this should be used on dedicated servers, for example submission (port 587).1

RFC 2487 est l’ancien numéro de RFC 3207, et cette règle est la raison pour laquelle la plupart des hôtes MX sur internet acceptent encore le courrier en clair. Je l’applique quand même à un MX référencé publiquement. Volontairement. Le billet d’ouverture explique pourquoi je pense que la règle est mauvaise, et le coût est réel : un expéditeur dont le serveur ne sait pas faire de TLS ne peut pas vous joindre. En 2026, c’est un serveur dont vous ne voulez pas de courrier.

Pas de clair en sortie

C’est la moitié que la plupart des guides ratent, parce que le réglage évident ne fait pas ce que son nom laisse croire.

Postfix a un niveau de sécurité dane. Posez-le et vous vous attendriez à ce que le courrier parte authentifié. Ce n’est pas le cas. Voici ce que postconf(5) dit qu’il fait quand l’autre bout ne publie rien :

When DNSSEC-validated TLSA records are not found the effective tls security level is “may”.2

may, c’est du TLS opportuniste, ce que le TLS_README décrit sans détour : « Otherwise, messages are sent in the clear. »3 La majeure partie d’internet ne publie aucun enregistrement TLSA. Donc smtp_tls_security_level = dane tout seul, c’est DANE pour la poignée de domaines qui le font, et du texte en clair pour tous les autres dont le serveur n’offre pas STARTTLS. De l’opportuniste, avec un meilleur nom.

Le correctif consiste à faire de encrypt le plancher et à hisser chaque destination au-dessus quand elle publie quelque chose de mieux. C’est postfix-tlspol qui hisse. C’est un petit serveur de politique, sous licence MIT, à qui Postfix demande pour chaque domaine destinataire via une socketmap. Il cherche d’abord des enregistrements TLSA utilisables dans une zone signée, puis MTA-STS, et répond avec la politique que Postfix doit utiliser :

smtp_dns_support_level = dnssec
smtp_tls_security_level = encrypt
smtp_tls_mandatory_protocols = >=TLSv1.2
smtp_tls_policy_maps = socketmap:inet:[fd89:50::25]:8642:QUERYwithTLSRPT
Trois politiques, et aucune n'est en clairDEMANDECE QUE LE DOMAINE PUBLIEPOLITIQUE QUE POSTFIX UTILISEpostfix-tlspolpar domaine destinataire,via DNSSECTLSA utilisabledans une zone signéeMTA-STS, enforcepolitique récupérée via HTTPSNi l'un ni l'autrela majeure partie d'internetdane-onlyla clé du serveur doit correspondresecure match=…certificat d'AC, MX listés seulementencryptTLS exigé, non authentifiéET QUAND ELLE NE PEUT PAS ÊTRE TENUE, LE COURRIER ATTENDpas de STARTTLS proposé : dsn=4.7.4la clé ne correspond pas : dsn=4.7.5
Le niveau dane livré ajouterait une quatrième ligne : pas de TLSA, envoi en clair. Ce montage la laisse de côté

Son README est précis sur ce qu’il renvoie : il va « Verify TLSA records for correctness and supported parameters, only then the dane-only policy (Mandatory DANE) will be returned. »4 Interrogé sur les trois domaines du labo, il a dit exactement cela :

level=INFO msg="Evaluated policy" origin=network domain=alpha.test policy=dane-only ttl=214
level=INFO msg="Evaluated policy" origin=network domain=bravo.test policy=dane-only ttl=198
level=INFO msg="No policy found" origin=network domain=plain.test ttl=1800

Pas de politique pour plain.test signifie que Postfix retombe sur le plancher encrypt, et plain.test n’offre pas STARTTLS. Le courrier attend :

postfix/smtp[194]: 78598130DA54: to=<someone@plain.test>, relay=mail.plain.test[fd89:50::22]:25,
  delay=0.02, delays=0.01/0.01/0.01/0, dsn=4.7.4,
  status=deferred (TLS is required, but was not offered by host mail.plain.test[fd89:50::22])

Et le cas qui prouve que DANE fait quelque chose. Au début de la construction, Stalwart sur bravo.test servait encore son propre certificat d’amorçage alors que la zone publiait l’enregistrement TLSA du vrai. Un serveur avec un certificat parfaitement valable, mais pas celui dont le propriétaire du domaine s’est porté garant :

postfix/smtp[196]: server certificate verification failed for mail.bravo.test[fd89:50::21]:25:
  num=65:no matching DANE TLSA records
postfix/smtp[196]: 79160130DA56: to=<someone@bravo.test>, relay=mail.bravo.test[10.89.50.21]:25,
  delay=5.9, delays=0/0.01/5.9/0, dsn=4.7.5, status=deferred (Server certificate not verified)

Différer est la bonne réponse dans les deux cas. Un message différé est réessayé pendant des jours, ce qui laisse à l’autre côté le temps de corriger chez lui, et rien n’est perdu. Un message envoyé en clair ne peut pas être dé-envoyé. De ce fait, attendre est toujours l’échec le moins coûteux.

Le résolveur fait partie de la chaîne de confiance

smtp_dns_support_level = dnssec fait que Postfix demande à son résolveur des réponses validées, puis croit le bit AD du résolveur, le drapeau qui dit « j’ai vérifié les signatures ». Postfix ne valide rien lui-même. Il fait confiance. Quel que soit le résolveur nommé dans /etc/resolv.conf, c’est lui qui décide désormais si votre courrier est authentifié.

Vous trouverez des conseils affirmant que glibc jette ce bit à moins que resolv.conf ne porte options trust-ad. Je l’ai testé, et sur ce Postfix cela ne change rien : DANE a correspondu avec l’option comme sans. La source explique pourquoi. Postfix pose le drapeau lui-même dès qu’il demande du DNSSEC :

    if (DNS_WANT_DNSSEC_VALIDATION(flags))
	flags |= (RES_USE_EDNS0 | RES_TRUSTAD);

Cela vient de src/dns/dns_lookup.c.5 Donc ce qui compte, ce n’est pas une option de résolveur. C’est le résolveur que vous visez. Il doit valider, et le chemin jusqu’à lui doit être un chemin de confiance, ce qui en pratique veut dire qu’il tourne sur le serveur de messagerie lui-même ou sur un réseau que personne d’autre ne peut atteindre. Un résolveur public à l’autre bout d’internet posera volontiers le bit AD pour vous. Et quiconque se trouve entre vous et lui aussi.

DANE : publier la clé, pas le certificat

Un enregistrement TLSA épingle ce qu’un serveur expéditeur devrait voir en se connectant. Celui à utiliser est 3 1 1, et chacun des trois nombres est un choix :

ChampValeurSignificationPourquoi celui-ci
Usage3, DANE-EEcorrespond au certificat du serveur lui-mêmeaucune AC dans la chaîne à valider ou à voir échouer
Sélecteur1, SPKIne hache que la clé publiquesurvit à chaque renouvellement sur la même clé
Correspondance1, SHA-256une empreinte, pas la clé entièretout client DANE doit la gérer

RFC 7672 donne la raison de cette combinaison en une ligne :

this record type works for all clients and need not change across certificate renewals with the same key.6

Le hachage tient en un seul pipeline, et le voici face au certificat du labo et à l’enregistrement que sert la zone signée :

$ openssl x509 -in mail.alpha.test.crt -noout -pubkey \
    | openssl pkey -pubin -outform DER | sha256sum
093a80cac907a52eb41d17df20163c95f2e15d6702c76f65174d7fc32460c69f  -

$ dig +dnssec _25._tcp.mail.alpha.test TLSA
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
_25._tcp.mail.alpha.test. 165 IN TLSA 3 1 1 093A80CAC907A52EB41D17DF20163C95F2E15D6702C76F65174D7FC3 2460C69F

Le labo a prouvé le « need not change » par accident. À mi-chemin, les contrôles de certificat plus stricts de Python 3.13 ont refusé les certificats du labo parce qu’il leur manquait un authority key identifier, donc chacun d’eux a été réémis. Mêmes clés privées, nouveaux certificats. Pas un seul enregistrement TLSA n’a changé, et pas un seul expéditeur DANE ne l’a remarqué.

Maintenant le piège. La plupart des gens renouvellent leurs certificats avec certbot, automatiquement, toutes les quelques semaines. D’après la propre documentation de certbot :

Not reusing private keys is the default behavior of Certbot.7

Donc le renouvellement par défaut fabrique une nouvelle clé à chaque fois. L’enregistrement TLSA cesse de correspondre, et chaque expéditeur qui vérifie DANE se met à différer votre courrier le jour du renouvellement. Rien ne casse de votre côté. Rien ne vous prévient. Lancez certbot avec --reuse-key et l’enregistrement survit à chaque certificat que vous mettez derrière lui. Quand la clé doit vraiment changer, il y a un ordre à respecter :

Même clé : rien à faire. Nouvelle clé : quatre étapes, dans l'ordreRENOUVELER SUR LA MÊME CLÉRenouveler le certificatmême clé privéeTLSA inchangé093a80… avant et aprèsRien à publierDANE ne remarque rienCHANGER LA CLÉ1. Publier le TLSAà côté de l'ancien2. Attendre deux TTLles caches gardent l'ancien3. Basculer la cléle serveur sert la nouvelle4. Retirer l'ancienseulement là, jamais avantSautez l'étape 2et chaque expéditeur DANE qui a mis l'ancien enregistrement en cache diffère votre courrier jusqu'à son expiration.
Réutilisez la clé au renouvellement et l’enregistrement TLSA survit à chaque certificat que vous mettez derrière lui

L’étape deux est celle que les gens sautent. Le résultat, c’est du courrier différé jusqu’à ce que l’ancien enregistrement sorte de tous les caches, et il vient précisément des expéditeurs qui font la sécurité correctement.

Personne ne relaie sans login

Relayer, c’est faire suivre du courrier vers un domaine dont vous n’êtes pas le destinataire. Un relais ouvert, c’est la façon dont le spam se blanchit à travers la réputation de quelqu’un d’autre. L’ancienne façon d’en fermer un était de faire confiance à une plage d’adresses, si bien que tout ce qui venait du réseau du bureau pouvait relayer. C’est de la confiance par emplacement. Sur un réseau avec du WiFi invité, des utilisateurs VPN et le portable compromis de temps en temps, l’emplacement ne vaut pas grand-chose.

Donc aucune adresse n’est de confiance, sauf la machine elle-même :

mynetworks = 127.0.0.0/8 [::1]/128
smtpd_relay_restrictions = reject_unauth_destination

Le port 25 est pour le courrier qui arrive pour ce domaine et rien d’autre. Il ne relaie jamais, pour personne :

<** 554 5.7.1 <someone@plain.test>: Relay access denied

Les utilisateurs envoient par soumission : le port 587 avec STARTTLS, ou le 465 avec TLS dès le premier octet. Les deux exigent un login avant de faire quoi que ce soit. Dovecot vérifie les mots de passe, avec tous les protocoles mail coupés pour qu’il ne fasse rien d’autre, et Postfix l’interroge par une socket. L’écouteur de soumission dans master.cf :

submission inet n       -       y       -       -       smtpd
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_tls_auth_only=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_restrictions=reject_sender_login_mismatch
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject

smtpd_tls_auth_only signifie que le serveur ne propose même pas AUTH tant que TLS n’est pas en place, si bien qu’un mot de passe ne traverse jamais le fil en clair. Lignes AUTH dans la réponse à un EHLO en clair : zéro. Un client qui se connecte au 587 et tente d’envoyer sans se connecter n’arrive nulle part :

<** 554 5.7.1 <unknown[fd89:50::50]>: Client host rejected: Access denied

Un login possède ses adresses

Un login prouve qui vous êtes. Il ne dit rien des adresses sous lesquelles vous pouvez envoyer. Sans ce lien, n’importe quel utilisateur du serveur peut envoyer au nom du directeur financier, et le courrier sera signé et remis comme s’il était authentique. Postfix lie les deux avec une table d’adresse vers propriétaire :

alice@alpha.test       alice@alpha.test
bob@alpha.test         bob@alpha.test
postmaster@alpha.test  alice@alpha.test

reject_sender_login_mismatch sur les ports de soumission l’impose contre l’expéditeur d’enveloppe. Connecté en tant qu’alice, en essayant d’envoyer en tant que bob :

<** 553 5.7.1 <bob@alpha.test>: Sender address rejected: not owned by user alice@alpha.test

Cela couvre l’enveloppe. Cela ne couvre pas l’en-tête From:, qui est l’adresse que le destinataire voit réellement, et postconf(5) le dit dans ses notes sur cette restriction précise :

to enforce that the From: header address matches the envelope sender (MAIL FROM) address, use an external filter such as a Milter8

Rspamd est déjà le milter ici, donc la vérification de l’en-tête est une courte règle Lua qui compare l’utilisateur connecté à l’adresse From:, plus un rejet forcé sur le symbole qu’elle lève :

rspamd_config.AUTH_FROM_NOT_LOGIN = {
  callback = function(task)
    local user = task:get_user()
    if not user then return false end
    local from = task:get_from('mime')
    if not from or not from[1] or not from[1].addr then return true, 'no From address' end
    if string.lower(from[1].addr) ~= string.lower(user) then
      return true, from[1].addr .. ' is not ' .. user
    end
    return false
  end,
  score = 0.0,
  description = 'Authenticated user sent with a From: address that is not their own',
  group = 'headers',
}

Alice envoyant avec MAIL FROM:<alice@alpha.test> et From: bob@alpha.test :

<** 554 5.7.1 You may only send as your own address

Un nom d’affichage passe, et un changement de casse aussi. Alice <ALICE@alpha.test> passe, parce que la comparaison porte sur l’adresse et ignore la casse.

La vérification qui se désactive toute seule

Voici le premier des réglages qui ne font silencieusement rien. La façon évidente d’empêcher des tiers de forger votre propre domaine sur le port 25 est d’y mettre aussi les vérifications login-mismatch. Postfix accepte la configuration, démarre, et journalise ceci :

warning: restriction `reject_authenticated_sender_login_mismatch' ignored: no SASL support
warning: restriction `reject_unauthenticated_sender_login_mismatch' ignored: no SASL support

Le port 25 a SASL coupé, à juste titre, parce que personne ne s’y connecte. La vérification est donc ignorée. Le courrier est accepté. La seule preuve est un avertissement dans un log que personne ne lit, et une fois la vérification partie, n’importe qui sur internet pourrait envoyer MAIL FROM:<alice@alpha.test> à ce serveur et le faire remettre aux collègues d’alice comme si elle l’avait écrit.

Le correctif est de refuser nos propres domaines nommément sur le port 25, parce que du courrier venant de nous n’y arrive jamais légitimement :

smtpd_sender_restrictions = reject_non_fqdn_sender, reject_unknown_sender_domain,
    check_sender_access texthash:/etc/postfix/own_domains

avec own_domains contenant une ligne, alpha.test REJECT Our own domain: send through submission with a login. Testé depuis l’extérieur :

<** 554 5.7.1 <alice@alpha.test>: Sender address rejected: Our own domain: send through submission with a login

Les domaines expéditeurs qui n’existent pas

Si le domaine dans MAIL FROM n’existe pas, rien ne pourra jamais lui être renvoyé, et rien de légitime n’en est jamais venu. reject_unknown_sender_domain refuse le courrier quand ce domaine a « no DNS MX and no DNS A record ».9 Le code de réponse par défaut de Postfix pour cela est 450, un échec temporaire, qui invite le faussaire à réessayer plus tard. Je l’ai changé en 550.

postconf(5) est direct sur ce réglage : « Do not change this unless you have a complete understanding of RFC 5321. »10 Soit. Voici le raisonnement. Un 4xx dit à l’expéditeur de mettre en file et de réessayer, ce qui est juste quand la réponse peut changer. Un domaine sans enregistrement aujourd’hui n’en fera pas pousser dans les cinq prochaines minutes. Postfix sépare cela du cas où la réponse n’est pas encore connue : un échec DNS est géré par unknown_address_tempfail_action, qui diffère encore. Changer le code ne change que le cas qui a une réponse.

Le labo a testé les trois, plus celui qui intéresse la règle IPv6. La formulation de postconf dit « A record », et un domaine dont la seule adresse est un enregistrement AAAA ne devrait pas être traité comme manquant :

Domaine expéditeurCe que dit le DNSRéponse
nosuch-lab.co.ukNXDOMAIN550 5.1.8 <someone@nosuch-lab.co.uk>: Sender address rejected: Domain not found
v6only-lab.co.ukAAAA seul, pas de A, pas de MXaccepté
dnsfail-lab.co.ukSERVFAIL450 4.1.8 … Sender address rejected: Domain not found, réessayé plus tard
sender.testMX, A, AAAAaccepté

En pratique, « A record » couvre AAAA, et un domaine IPv6 seul est en sécurité. Le refus arrive à MAIL FROM, avant que l’expéditeur ait envoyé un seul octet du corps. Postfix retient par défaut un rejet de MAIL FROM jusqu’au premier RCPT TO, si bien que la réponse arrive une étape plus tard qu’on pourrait s’y attendre, et c’est pourquoi le diagramme des étapes la montre là.

SPF, DKIM et DMARC, avec des dents

Ces trois-là sont traités comme il faut dans le billet d’ouverture. En une ligne chacun : SPF dit quels serveurs peuvent envoyer pour un domaine, DKIM signe le message pour que les modifications se voient, et DMARC dit quoi faire quand ils échouent et s’ils correspondent au domaine From: visible. La règle maison est de rejeter sur un échec dur SPF et sur une signature DKIM présente qui ne se vérifie pas. Le courrier non signé n’est pas un échec DKIM. C’est DMARC p=reject sur le domaine de l’expéditeur qui gère cela.

Rspamd fait les trois en un seul milter, plus la signature DKIM pour le courrier sortant. Postfix lui remet chaque message, et si Rspamd ne répond pas, le courrier attend plutôt que de passer sans contrôle :

smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_milters
milter_default_action = tempfail

Rspamd travaille sur des scores, et c’est la première chose à comprendre à son sujet. Par défaut, rien n’est rejeté parce que SPF a échoué. Rien. Chaque résultat ajoute ou retranche quelques points, et le message est rejeté quand le total dépasse 15. C’est le module force_actions qui transforme un résultat isolé en décision :

rules {
  SPF_FAIL_REJECT {
    action = "reject";
    expression = "R_SPF_FAIL";
    message = "SPF hard fail: this host is not allowed to send for that domain";
  }
  DKIM_FAIL_REJECT {
    action = "reject";
    expression = "R_DKIM_REJECT";
    message = "DKIM signature does not verify: the message was changed or forged";
  }
  DMARC_REJECT {
    action = "reject";
    expression = "DMARC_POLICY_REJECT";
    message = "DMARC policy of the sender's domain says reject";
  }
}

Cinq messages depuis sender.test, envoyés depuis le client du labo. L’enregistrement SPF autorise ip6:fd89:50::50 et rien d’autre, si bien que le même client qui envoie en IPv4 est un serveur non autorisé pour ce domaine. C’est aussi exactement à cela que ressemble un message réexpédié :

CasEnvoyé commeSPFDKIMDMARCScore RspamdRéponse
Asigné, depuis l’adresse IPv6 listéepasspasspass3,59 sur 15accepté
Bsigné, puis les coordonnées bancaires modifiéespassfailpass2,30 sur 15554 5.7.1 DKIM signature does not verify
Cnon signé, depuis IPv4failnonereject5,00 sur 15554 5.7.1 SPF hard fail
Dsigné et intact, depuis IPv4failpasspass2,30 sur 15554 5.7.1 SPF hard fail
Enon signé, depuis l’adresse IPv6 listéepassnonepass3,80 sur 15accepté

Regardez le cas B. C’est une facture dont le code guichet et le numéro de compte ont été modifiés après la signature. De la fraude à la facture, autrement dit. SPF a passé, parce que le message venait du bon serveur. DMARC a passé, parce que SPF a passé et s’alignait sur le domaine From:. Sur ses scores, Rspamd lui a donné 2,30 sur 15 et l’aurait remis. La seule chose qui l’a arrêté, c’est le rejet forcé sur la signature cassée. DMARC tout seul laisse passer un message altéré tant que l’une des deux vérifications survit, et cela vaut la peine d’être su avant de déclarer un domaine protégé parce que son DMARC dit reject.

Le cas D est le coût, et il faut le dire franchement. Un message correctement signé et intact est refusé parce qu’il venait d’un serveur absent de l’enregistrement SPF. C’est ce que fait la réexpédition : le réexpéditeur fait suivre votre message depuis sa propre adresse. Rejeter sur le seul SPF veut dire que le courrier réexpédié depuis des domaines en -all se fait renvoyer. SRS sur le réexpéditeur corrige cela, et faire confiance à DKIM plutôt qu’à SPF aussi, mais la règle maison est la règle maison, et le coût est mis par écrit.

Le défaut qui fait confiance à tout réseau privé

Le deuxième réglage qui ne fait silencieusement rien. Rspamd saute SPF et quelques autres vérifications pour les adresses qu’il considère comme locales, et la liste livrée dans options.inc est celle-ci :

local_addrs = [192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10];

Toutes les plages privées, les deux familles. Si votre MX est derrière un répartiteur de charge, un NAT ou un proxy sur une adresse privée, SPF est discrètement sauté pour tout cela. Dans le labo, qui est entièrement en adressage privé, SPF n’a jamais échoué jusqu’à ce que ceci soit changé.

Le correctif évident ne marche pas. Rspamd a deux répertoires pour les réglages locaux, et sa FAQ explicite la différence : local.d « Merges with defaults. Collections and lists are combined », tandis que override.d « Replaces defaults. »11 Une liste plus étroite dans local.d/options.inc est combinée avec celle livrée, ce qui veut dire qu’elle ne restreint rien. Elle doit aller dans override.d/options.inc :

local_addrs = [127.0.0.0/8, ::1];

et rspamadm configdump options est la façon de vérifier ce avec quoi Rspamd a réellement fini, plutôt que ce que vous croyez avoir écrit.

Le greylisting est aussi coupé dans cette installation. Il envoie un rejet temporaire aux expéditeurs inconnus et les accepte au réessai, ce qui arrête effectivement une partie du spam, mais le greylisting de Rspamd répond avant que SPF et DMARC aient eu leur mot à dire, si bien qu’en test vous voyez un report là où vous attendiez un verdict.12 Les tests ci-dessus n’ont de sens qu’avec lui coupé.

Vos propres bounces, refusés par votre propre DMARC

Le dernier piège est apparu quand Stalwart dans le labo a refusé un bounce de ce serveur. Postfix envoie les bounces avec un expéditeur d’enveloppe vide et From: MAILER-DAEMON@mail.alpha.test. Sans expéditeur d’enveloppe, la seule vérification SPF qui reste porte sur le nom que le serveur a donné dans HELO, mail.alpha.test, qui n’avait pas d’enregistrement SPF. Le bounce n’était pas signé non plus. DMARC est donc retombé sur la politique de alpha.test, a trouvé p=reject, et le serveur récepteur a fait exactement ce que le domaine lui disait de faire :

smtp.dmarc-fail … domain = "alpha.test", policy = "reject", result = DMARC check failed
  (dmarc.fail) { causedBy = Policy not aligned }

La propre politique du domaine jetait ses propres échecs de remise. Chaque nom d’hôte MX reçoit son propre enregistrement SPF, ce qui corrige cela :

mail IN TXT "v=spf1 a -all"

Six réglages qui ont l’air faits

Les pièges ci-dessus ont une forme commune. Chacun d’eux est un réglage qui a l’air fait, ne produit aucune erreur, et ne fait rien du tout.

A l’air faitCe qui se passe vraimentTrouvé par
smtp_tls_security_level = danedu texte en clair partout où aucun TLSA n’est publiéen lisant postconf(5)
vérifications login-mismatch sur le port 25ignorées, un avertissement dans le logen envoyant une usurpation
local_addrs restreint dans local.dfusionné avec le défaut, rien de restreintrspamadm configdump
DMARC p=reject sur l’expéditeurle courrier altéré passe si SPF passecas B
renouvellements certbotnouvelle clé, TLSA ne correspond plusen lisant la doc de certbot
DMARC p=reject sur votre propre domainevos propres bounces refusésun bounce refusé

Deux d’entre eux sont dans la documentation, à condition de lire le bon paragraphe de la bonne page. Les quatre autres ne sont apparus qu’en envoyant le message que la règle était censée arrêter et en regardant ce qui revenait. Relire la configuration et l’approuver n’en a trouvé aucun. Si vous ne gardez qu’une habitude de ce billet, gardez celle-là, parce que chaque ligne ici peut être copiée dans un serveur et ne toujours pas fonctionner.

Les défauts sont la décision de quelqu’un d’autre

Chaque refus de ce billet est un réglage que Postfix a depuis des années. Même DANE, la chose la plus récente ici, y est depuis la 2.11.13 Rien de tout cela n’est nouveau, rien n’est cher, et le serveur en dessous est le même logiciel libre qu’il a toujours été. L’écart entre un serveur de messagerie qui laisse passer une facture altérée et un qui la refuse à DATA, c’est une page de configuration et un après-midi de tests.

Alors pourquoi si peu d’internet tourne-t-il comme ça ? Parce que ce sont les défauts qui décident, et les défauts ont été réglés pour s’assurer que le courrier passe sur un internet où TLS était l’exception. C’était un choix raisonnable à l’époque. Laissez-les tels quels aujourd’hui et vous avez confié vos décisions de sécurité à un choix fait par quelqu’un en 2002, pour un réseau qui n’existe plus, à propos d’un courrier qui n’était pas le vôtre.

Faire tourner un serveur de messagerie, c’est décider d’être responsable de ce qui y transite. Ces réglages sont cette décision mise par écrit. Les défauts sont ce que vous obtenez quand personne ne l’a prise.


  1. postconf(5) : smtpd_tls_security_level — le niveau encrypt : « According to RFC 2487 this MUST NOT be applied in case of a publicly-referenced SMTP server. » ↩︎

  2. postconf(5) : smtp_tls_security_level — le niveau dane : « When DNSSEC-validated TLSA records are not found the effective tls security level is « may ». » ↩︎

  3. Postfix TLS_README : Opportunistic TLS — « The SMTP transaction is encrypted if the STARTTLS ESMTP feature is supported by the server. Otherwise, messages are sent in the clear. » ↩︎

  4. postfix-tlspol sur GitHub — serveur de politique MTA-STS et DANE pour Postfix, sous licence MIT : « Verify TLSA records for correctness and supported parameters, only then the dane-only policy (Mandatory DANE) will be returned. » Version 1.14.4 utilisée ici. ↩︎

  5. Source Postfix, src/dns/dns_lookup.c — « Automatically turns on the RES_TRUSTAD flag on systems that support this flag », et le code qui le fait lorsque la validation DNSSEC est demandée. ↩︎

  6. RFC 7672 §3.1.1 — SMTP security via opportunistic DANE TLS. Les enregistrements TLSA pour les serveurs SMTP « should, in most cases, be « DANE-EE(3) SPKI(1) SHA2-256(1) » records ». ↩︎

  7. Guide utilisateur Certbot, options en ligne de commande — --reuse-key : « When renewing, use the same private key as the existing certificate. (default: False) », et sous --no-reuse-key : « Not reusing private keys is the default behavior of Certbot. » ↩︎

  8. postconf(5) : reject_known_sender_login_mismatch — « to enforce that the From: header address matches the envelope sender (MAIL FROM) address, use an external filter such as a Milter ». ↩︎

  9. postconf(5) : reject_unknown_sender_domain — rejette quand le domaine du MAIL FROM a « no DNS MX and no DNS A record », ou un MX mal formé. ↩︎

  10. postconf(5) : unknown_address_reject_code — défaut 450 : « Do not change this unless you have a complete understanding of RFC 5321. » ↩︎

  11. FAQ Rspamd : la différence entre local.d et override.d — local.d « Merges with defaults. Collections and lists are combined » ; override.d « Replaces defaults. Entire sections are overwritten. » ↩︎

  12. Module de greylisting Rspamd — « The greylisting module triggers a soft reject action, which is intended to be interpreted by the MTA as a temporary rejection ». ↩︎

  13. Postfix TLS_README : DANE — « Postfix ≥ 2.11 supports the new dane and dane-only security levels ». ↩︎