Installeer Postfix uit je distributie en neem de standaardinstellingen over, en je hebt een mailserver die je post onversleuteld verstuurt naar elke server die geen TLS aanbiedt, en post aanneemt van iedereen die beweert iemand te zijn. De main.cf die Debian meelevert zet beide TLS-niveaus op may. Niets controleert SPF. Niets controleert DKIM. Een afzenderdomein dat niet bestaat wordt geaccepteerd.

Dat is geen bug. Het is wat de standaarden twintig jaar geleden vroegen, en het is hoe heel veel mailservers op het internet vandaag nog draaien.

Deze post bouwt het andere soort. Het neemt de huisregels uit de hoofdpost in deze serie en zet ze stuk voor stuk in een werkende Postfix, met het antwoord dat elke regel geeft wanneer hij iets weigert. Het pleidooi voor de regels staat in die post en ik voer het niet twee keer. Deze gaat over de bouw.

RegelDebian’s Postfix zoals geleverdDeze buildWat een inbraak oplevert
Geen platte tekst inTLS aangeboden, nooit vereistencrypt op elke listener530 5.7.0 Must issue a STARTTLS command first
Geen platte tekst uitTLS indien aangeboden, anders platte tekstencrypt, DANE of MTA-STS waar gepubliceerduitgesteld, dsn=4.7.4 of dsn=4.7.5
IPv6 op allesinet_protocols = allhetzelfde, plus AAAA op elke MXelke test hieronder liep op beide families
Alleen relayen met een loginrelayt voor mynetworkspoort 25 relayt nooit, 587 en 465 vereisen een login554 5.7.1 … Relay access denied
Een login verstuurt als zichzelfniet gecontroleerdenvelop en From: beide gebonden aan de login553 5.7.1 … not owned by user
Afzenderdomein moet bestaanniet gecontroleerdreject_unknown_sender_domain, code 550550 5.1.8 … Domain not found
SPF-fail, DKIM-failniet gecontroleerdRspamd, met geforceerde weigeringen554 5.7.1 SPF hard fail / DKIM signature does not verify

Elk antwoord in deze post is opgevangen van een draaiende server, over IPv4 en IPv6. Een aantal instellingen bleken niets te doen totdat er ook iets anders was gezet. Ik vond ze stuk voor stuk door te kijken hoe ze niets deden. Ze worden hieronder aangewezen, want het zijn degene die je zullen bijten.

Het lab

Eén geïsoleerd netwerk, elke host op beide adresfamiliesDNS EN BELEIDnetwerk maillab: 10.89.50.0/24 en fd89:50::/64, internlab-nsBIND 9.20.30, ondertekent.10 en ::10lab-resolverunbound 1.26.1, valideert.11 en ::11lab-tlspolpostfix-tlspol 1.14.4.25 en ::25lab-alphaPostfix 3.10.13alpha.test.20 en ::20lab-bravoStalwart 0.16.27bravo.test.21 en ::21lab-plainPostfix, geen TLSplain.test.22 en ::22lab-clientswaks, testafzendersender.test.50 en ::50MAILSERVERSWegwerp-CA voor de certificaten. Elke test in deze posts liep over beide families.
Alles in de post is hier eerst gedraaid. Niets erop kan het internet bereiken, dus niets erop is een gok over het internet.
OnderdeelVersieTaak
Postfix3.10.13, Debian trixiede MX- en submission-server voor alpha.test
Dovecot2.4.1wachtwoorden voor logins, elk mailprotocol uit
Rspamd, Redis3.12.1, 8.0.2SPF, DKIM, DMARC, DKIM-ondertekening
postfix-tlspol1.14.4uitgaand beleid uit TLSA en MTA-STS
BIND, unbound9.20.30, 1.26.1een ondertekende privé test.-zone, en een resolver die hem valideert
DomeinWaar het voor dient
alpha.testde server die deze post bouwt
bravo.testStalwart, die een eigen post krijgt
plain.testde slechte buur: niet-ondertekende zone, geen STARTTLS
sender.testSPF -all, DKIM, DMARC p=reject: voor vervalsen, knoeien en doorsturen

Elke listener bindt beide families, en elke MX heeft een A- en een AAAA-record. IPv6 is hier geen extraatje. Een mailserver zonder is een defect, en bewijzen dat deze het heeft is simpel: voer elke controle twee keer uit.

Fase voor fase

Een SMTP-sessie is een vaste volgorde, en elke regel in deze build heeft daarin een natuurlijke plek. De volgorde doet er om één reden toe: weiger zo vroeg als het bewijs toelaat. Een afzenderdomein dat niet bestaat is bekend bij MAIL FROM, dus er is geen reden om de body aan te nemen voordat je het weigert. Een gemanipuleerde body is pas bekend na DATA, dus daar wordt die geweigerd.

Waar elke regel bijt, en wat de server zeiFASEDE REGEL, EN HET ANTWOORD UIT HET LABEHLOSTARTTLSAUTHMAIL FROMRCPT TODATAgeen AUTH aangeboden totdat TLS staatsmtpd_tls_auth_only = yessla het over en niets anders wordt geaccepteerd530 5.7.0 Must issue a STARTTLS command firstalleen 587 en 465; poort 25 heeft geen login554 5.7.1 Client host rejected: Access deniedafzenderdomein moet bestaan550 5.1.8 Sender address rejected: Domain not foundons eigen domein vereist een login554 5.7.1 Our own domain: send through submissionde login moet het adres bezitten553 5.7.1 not owned by user alice@alpha.testpoort 25 relayt nooit554 5.7.1 Relay access deniedRspamd: SPF, DKIM, DMARC554 5.7.1 SPF hard fail / DKIM does not verifyFrom:-header moet de login zijn554 5.7.1 You may only send as your own addressPostfix houdt MAIL FROM-weigeringen vast tot RCPT TO, dus die drie antwoorden komen een stap later.
Elke regel heeft een fase. Weiger zo vroeg als het bewijs toelaat

De rest van de post loopt die fasen in volgorde af: TLS in, TLS uit, logins en relayen, afzenderadressen, en dan wat Rspamd met de body doet.

Geen platte tekst die binnenkomt

Eén regel:

smtpd_tls_security_level = encrypt

Een client die STARTTLS probeert over te slaan krijgt niets dan de weigering:

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

Lees nu wat Postfix’ eigen handleiding over die instelling zegt:

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 is het oude nummer voor RFC 3207, en die regel is waarom de meeste MX-hosts op het internet nog steeds post onversleuteld aannemen. Ik pas hem toch toe op een publiek verwezen MX. Met opzet. De hoofdpost legt uit waarom ik denk dat de regel verkeerd is, en de kosten zijn echt: een afzender wiens server geen TLS kan, bereikt je niet. In 2026 is dat een server van wie je geen post wilt.

Geen platte tekst die uitgaat

Dit is de helft die de meeste handleidingen fout doen, want de voor de hand liggende instelling doet niet wat haar naam suggereert.

Postfix heeft een dane-beveiligingsniveau. Zet het en je zou verwachten dat post geauthenticeerd uitgaat. Dat gebeurt niet. Zo beschrijft postconf(5) wat het doet als de andere kant niets publiceert:

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

may is opportunistische TLS, wat de TLS_README onomwonden beschrijft: “Otherwise, messages are sent in the clear.”3 Het grootste deel van het internet publiceert geen TLSA-records. Dus smtp_tls_security_level = dane op zichzelf betekent DANE voor het handjevol domeinen dat het doet, en platte tekst voor iedereen anders wiens server geen STARTTLS aanbiedt. Opportunistisch, met een betere naam.

De oplossing is om encrypt de ondergrens te maken en elke bestemming erboven te tillen wanneer die iets beters publiceert. postfix-tlspol doet het tillen. Het is een kleine policyserver, MIT-gelicentieerd, die Postfix over elk ontvangerdomein bevraagt via een socketmap. Hij kijkt eerst naar bruikbare TLSA-records in een ondertekende zone en daarna naar MTA-STS, en antwoordt met het beleid dat Postfix moet gebruiken:

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
Drie beleidsregels, en geen ervan is platte tekstVRAAGWAT HET DOMEIN PUBLICEERTBELEID DAT POSTFIX GEBRUIKTpostfix-tlspolper ontvangerdomein,via DNSSECBruikbare TLSAin een ondertekende zoneMTA-STS, enforcebeleid opgehaald via HTTPSGeen van beidegrootste deel van het internetdane-onlyde sleutel moet matchensecure match=…CA-certificaat, alleen vermelde MXencryptTLS vereist, niet geauthenticeerdEN KAN DAT NIET, DAN WACHT DE POSTgeen STARTTLS aangeboden: dsn=4.7.4sleutel matcht niet: dsn=4.7.5
Het meegeleverde dane-niveau zou een vierde rij toevoegen: geen TLSA, verstuur in platte tekst. Deze opzet laat die weg

De README is specifiek over wat het teruggeeft: het zal “Verify TLSA records for correctness and supported parameters, only then the dane-only policy (Mandatory DANE) will be returned.”4 Gevraagd naar de drie labdomeinen, zei het precies dat:

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

Geen beleid voor plain.test betekent dat Postfix terugvalt op de encrypt-ondergrens, en plain.test biedt geen STARTTLS aan. De post wacht:

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])

En het geval dat bewijst dat DANE iets doet. Vroeg in de bouw serveerde Stalwart op bravo.test nog zijn eigen bootstrap-certificaat terwijl de zone het TLSA-record voor het echte publiceerde. Een server met een prima certificaat, alleen niet het certificaat waar de domeineigenaar voor instond:

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)

Uitgesteld is in beide gevallen het juiste antwoord. Een uitgesteld bericht wordt dagenlang opnieuw geprobeerd, wat de andere kant de tijd geeft om het aan hun kant te herstellen, en er gaat niets verloren. Een bericht dat onversleuteld is verstuurd kun je niet terugnemen. Als zodanig is wachten altijd de goedkopere mislukking.

De resolver is onderdeel van de vertrouwensketen

smtp_dns_support_level = dnssec laat Postfix zijn resolver om gevalideerde antwoorden vragen, en vervolgens de AD-bit van de resolver geloven, de vlag die zegt “ik heb de handtekeningen gecontroleerd”. Postfix valideert zelf niets. Het vertrouwt. Welke resolver er ook in /etc/resolv.conf staat, die bepaalt nu of je post geauthenticeerd is.

Je vindt advies dat zegt dat glibc die bit weggooit tenzij resolv.conf options trust-ad bevat. Ik heb het getest, en op deze Postfix maakt het geen verschil: DANE matchte met de optie en zonder. De broncode legt uit waarom. Postfix zet de vlag zelf zodra het om DNSSEC vraagt:

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

Dat komt uit src/dns/dns_lookup.c.5 Dus wat telt is geen resolveroptie. Het is naar welke resolver je wijst. Die moet valideren, en het pad ernaartoe moet er een zijn die je vertrouwt, wat in de praktijk betekent dat hij op de mailserver zelf draait of op een netwerk dat niemand anders kan bereiken. Een publieke resolver over het internet zet met plezier AD voor je. En zo ook iedereen die tussen jou en hem zit.

DANE: publiceer de sleutel, niet het certificaat

Een TLSA-record pint vast wat een verzendende server moet zien als hij verbinding maakt. Het record om te gebruiken is 3 1 1, en elk van de drie getallen is een keuze:

VeldWaardeBetekenisWaarom deze
Usage3, DANE-EEmatch het eigen certificaat van de servergeen CA in de keten om te vertrouwen of te laten falen
Selector1, SPKIhash alleen de publieke sleuteloverleeft elke vernieuwing op dezelfde sleutel
Matching1, SHA-256een digest, niet de volledige sleutelelke DANE-client moet het ondersteunen

RFC 7672 geeft de reden voor die combinatie in één regel:

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

De hash is één pipeline, en hier is hij tegen het labcertificaat en het record dat de ondertekende zone serveert:

$ 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

Het lab bewees het “need not change”-deel bij toeval. Halverwege weigerden de strengere certificaatcontroles van Python 3.13 de labcertificaten omdat ze een authority key identifier misten, dus werden ze allemaal opnieuw uitgegeven. Dezelfde privésleutels, nieuwe certificaten. Geen enkel TLSA-record veranderde, en geen enkele DANE-afzender merkte het.

Nu de val. De meeste mensen vernieuwen hun certificaten met certbot, automatisch, om de paar weken. Uit certbots eigen documentatie:

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

Dus de standaardvernieuwing maakt elke keer een nieuwe sleutel. Het TLSA-record matcht niet meer, en elke afzender die DANE controleert begint je post uit te stellen op de vernieuwingsdag. Aan jouw kant breekt er niets. Niets zegt het je. Draai certbot met --reuse-key en het record overleeft elk certificaat dat je erachter zet. Wanneer de sleutel wel moet veranderen, zit er een volgorde in:

Zelfde sleutel: niets te doen. Nieuwe sleutel: vier stappen, op volgordeVERNIEUWEN OP DEZELFDE SLEUTELVernieuw certificaatdezelfde privésleutelTLSA ongewijzigd093a80… voor en naNiets te publicerenDANE-afzenders zien nietsDE SLEUTEL VERANDEREN1. Nieuwe TLSA erbijnaast de oude2. Wacht twee TTL'scaches houden de oude set3. Wissel de sleutelserver toont de nieuwe4. Verwijder de oudepas nu, nooit eerderSla stap 2 overen elke DANE-afzender die het oude record cachete stelt je post uit totdat het verloopt.
Hergebruik de sleutel bij vernieuwing en het TLSA-record overleeft elk certificaat dat je erachter zet

Stap twee is de stap die mensen overslaan. Het gevolg is uitgestelde post totdat het oude record uit elke cache is verlopen, en het komt juist van de afzenders die beveiliging goed doen.

Niemand relayt zonder login

Relayen is post doorsturen naar een domein waarvoor je niet de bestemming bent. Een open relay is hoe spam door de reputatie van iemand anders wordt gewassen. De oude manier om er een te sluiten was een adresbereik vertrouwen, zodat alles vanaf het kantoornetwerk mocht relayen. Dat is vertrouwen op locatie. Op een netwerk met gasten-wifi, VPN-gebruikers en af en toe een gecompromitteerde laptop is locatie heel weinig waard.

Dus geen enkel adres wordt vertrouwd behalve de machine zelf:

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

Poort 25 is voor post die voor dit domein binnenkomt en niets anders. Hij relayt nooit, voor niemand:

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

Gebruikers versturen via submission: poort 587 met STARTTLS, of 465 met TLS vanaf de eerste byte. Beide willen een login voordat ze iets doen. Dovecot controleert de wachtwoorden, met elk mailprotocol uitgeschakeld zodat het niets anders doet, en Postfix vraagt het via een socket. De submission-listener in 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 betekent dat de server niet eens AUTH aanbiedt totdat TLS staat, zodat een wachtwoord nooit onversleuteld over de lijn gaat. AUTH-regels in het antwoord op een EHLO zonder TLS: nul. Een client die verbinding maakt met 587 en probeert te versturen zonder in te loggen komt nergens:

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

Een login bezit zijn adressen

Een login bewijst wie je bent. Het zegt niets over welke adressen je als afzender mag gebruiken. Zonder die koppeling kan elke gebruiker op de server als de financieel directeur versturen, en de post wordt ondertekend en afgeleverd alsof hij echt is. Postfix knoopt de twee aan elkaar met een map van adres naar eigenaar:

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

reject_sender_login_mismatch op de submission-poorten dwingt het af tegen de envelopafzender. Ingelogd als alice, proberen te versturen als bob:

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

Dat dekt de envelop. Het dekt niet de From:-header, het adres dat de ontvanger werkelijk ziet, en postconf(5) zegt dat in de aantekeningen bij juist deze restrictie:

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

Rspamd is hier al de milter, dus de headercontrole is een korte Lua-regel die de ingelogde gebruiker vergelijkt met het From:-adres, plus een geforceerde weigering op het symbool dat hij opwerpt:

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 verstuurt met MAIL FROM:<alice@alpha.test> en From: bob@alpha.test:

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

Een weergavenaam is prima en een ander hoofdlettergebruik ook. Alice <ALICE@alpha.test> gaat erdoor, want de vergelijking gaat over het adres en negeert hoofdletters.

De controle die zichzelf uitschakelt

Hier is de eerste van de instellingen die stil niets doen. De voor de hand liggende manier om te voorkomen dat buitenstaanders je eigen domein op poort 25 vervalsen, is om de login-mismatch-controles daar ook te zetten. Postfix accepteert de configuratie, start, en logt dit:

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

Poort 25 heeft SASL uitgeschakeld, en terecht, want niemand logt daar in. Dus de controle wordt overgeslagen. De post wordt geaccepteerd. Het enige bewijs is een waarschuwing in een log dat niemand leest, en nu de controle weg is, zou iedereen op het internet MAIL FROM:<alice@alpha.test> naar deze server kunnen sturen en het laten afleveren bij alice’ collega’s alsof zij het had geschreven.

De oplossing is om onze eigen domeinen bij naam te weigeren op poort 25, want post van ons komt daar nooit rechtmatig binnen:

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

waarbij own_domains één regel bevat, alpha.test REJECT Our own domain: send through submission with a login. Van buitenaf getest:

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

Afzenderdomeinen die niet bestaan

Als het domein in MAIL FROM niet bestaat, kan er nooit iets naar teruggestuurd worden, en kwam er nooit iets rechtmatigs vandaan. reject_unknown_sender_domain weigert post wanneer dat domein “no DNS MX and no DNS A record”9 heeft. Postfix’ standaard antwoordcode ervoor is 450, een tijdelijke fout, die de vervalser uitnodigt om het later nog eens te proberen. Ik heb het veranderd in 550.

postconf(5) is bot over die instelling: “Do not change this unless you have a complete understanding of RFC 5321.”10 Terecht. Hier is de redenering. Een 4xx zegt de afzender om in de wachtrij te zetten en opnieuw te proberen, wat juist is als het antwoord nog kan veranderen. Een domein zonder records nu gaat er de komende vijf minuten geen krijgen. Postfix scheidt dat van het geval waarin het antwoord nog niet bekend is: een DNS-fout wordt afgehandeld door unknown_address_tempfail_action, die nog steeds uitstelt. De code veranderen verandert alleen het geval dat een antwoord heeft.

Het lab testte alle drie, plus degene waar de IPv6-regel om geeft. De postconf-tekst zegt “A record”, en een domein waarvan het enige adres een AAAA-record is zou niet als ontbrekend behandeld moeten worden:

AfzenderdomeinDNS zegtAntwoord
nosuch-lab.co.ukNXDOMAIN550 5.1.8 <someone@nosuch-lab.co.uk>: Sender address rejected: Domain not found
v6only-lab.co.ukalleen AAAA, geen A, geen MXgeaccepteerd
dnsfail-lab.co.ukSERVFAIL450 4.1.8 … Sender address rejected: Domain not found, later opnieuw geprobeerd
sender.testMX, A, AAAAgeaccepteerd

In de praktijk dekt “A record” ook AAAA, en een IPv6-only domein is veilig. De weigering komt bij MAIL FROM, voordat de afzender ook maar één byte van de body heeft gestuurd. Postfix houdt een MAIL FROM-weigering standaard vast tot de eerste RCPT TO, dus het antwoord komt een stap later dan je zou verwachten, en daarom laat het fasediagram het daar zien.

SPF, DKIM en DMARC, met tanden

Deze drie komen goed aan bod in de hoofdpost. In één regel per stuk: SPF zegt welke servers voor een domein mogen versturen, DKIM ondertekent het bericht zodat wijzigingen zichtbaar worden, en DMARC zegt wat te doen als ze falen en of ze matchen met het zichtbare From:-domein. De huisregel is om te weigeren bij een SPF hard fail en bij een DKIM-handtekening die aanwezig is en niet verifieert. Ongetekende post is geen DKIM-fout. DMARC p=reject op het domein van de afzender handelt dat af.

Rspamd doet alle drie in één milter, plus DKIM-ondertekening voor uitgaande post. Postfix geeft het elk bericht door, en als Rspamd niet antwoordt, wacht de post in plaats van ongecontroleerd door te gaan:

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

Rspamd werkt met scores, en dat is het eerste wat je erover moet begrijpen. Standaard wordt er niets geweigerd omdat SPF faalde. Niets. Elk resultaat telt een paar punten op of af, en het bericht wordt geweigerd wanneer het totaal boven de 15 komt. De module force_actions maakt van een enkel resultaat een beslissing:

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

Vijf berichten van sender.test, verstuurd vanaf de client in het lab. Het SPF-record staat ip6:fd89:50::50 toe en niets anders, dus dezelfde client die over IPv4 verstuurt is een ongeautoriseerde server voor dat domein. Dat is ook precies hoe een doorgestuurd bericht eruitziet:

GevalVerstuurd alsSPFDKIMDMARCRspamd-scoreAntwoord
Aondertekend, vanaf het vermelde IPv6-adrespasspasspass3.59 van 15geaccepteerd
Bondertekend, daarna de bankgegevens gewijzigdpassfailpass2.30 van 15554 5.7.1 DKIM signature does not verify
Congetekend, vanaf IPv4failnonereject5.00 van 15554 5.7.1 SPF hard fail
Dondertekend en intact, vanaf IPv4failpasspass2.30 van 15554 5.7.1 SPF hard fail
Eongetekend, vanaf het vermelde IPv6-adrespassnonepass3.80 van 15geaccepteerd

Kijk naar geval B. Het is een factuur waarvan het bankrekeningnummer en de bankcode zijn gewijzigd nadat hij was ondertekend. Factuurfraude, met andere woorden. SPF slaagde, want hij kwam van de juiste server. DMARC slaagde, want SPF slaagde en lijnde op met het From:-domein. Op zijn scores gaf Rspamd hem 2.30 van de 15 en had hem afgeleverd. Het enige wat het tegenhield was de geforceerde weigering op de gebroken handtekening. DMARC laat op zichzelf een gemanipuleerd bericht door zolang een van de twee controles overleeft, en dat is het waard om te weten voordat je een domein beschermd noemt omdat zijn DMARC reject zegt.

Geval D is de prijs, en die moet eerlijk gezegd worden. Een correct ondertekend, onaangeroerd bericht wordt geweigerd omdat het van een server kwam die niet in het SPF-record staat. Dat is wat doorsturen doet: de doorstuurder stuurt je bericht door vanaf zijn eigen adres. Weigeren op alleen SPF betekent dat doorgestuurde post van domeinen met -all wordt teruggestuurd. SRS op de doorstuurder lost het op, en DKIM boven SPF vertrouwen ook, maar de huisregel is de huisregel, en de prijs gaat op schrift.

De standaard die elk privénetwerk vertrouwt

De tweede instelling die stil niets doet. Rspamd slaat SPF en enkele andere controles over voor adressen die het als lokaal beschouwt, en de meegeleverde lijst in options.inc is deze:

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];

Elk privébereik, beide families. Als je MX achter een load balancer, een NAT of een proxy op een privéadres zit, wordt SPF daar stilletjes voor overgeslagen. In het lab, dat volledig privéadressering is, faalde SPF nooit totdat dit was veranderd.

De voor de hand liggende oplossing werkt niet. Rspamd heeft twee mappen voor lokale instellingen, en de FAQ legt het verschil uit: local.d “Merges with defaults. Collections and lists are combined”, terwijl override.d “Replaces defaults.”11 Een smallere lijst in local.d/options.inc wordt gecombineerd met de meegeleverde, wat betekent dat hij niets versmalt. Hij moet in override.d/options.inc:

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

en met rspamadm configdump options controleer je waar Rspamd daadwerkelijk mee eindigde, in plaats van wat je denkt dat je schreef.

Greylisting is in deze build ook uitgeschakeld. Het stuurt een tijdelijke weigering naar onbekende afzenders en accepteert ze bij de nieuwe poging, wat inderdaad wat spam tegenhoudt, maar de greylisting van Rspamd antwoordt voordat SPF en DMARC hun zegje hebben gedaan, dus tijdens het testen zie je een uitstel waar je een oordeel verwachtte.12 De tests hierboven zijn alleen zinvol met greylisting uit.

Je eigen bounces, geweigerd door je eigen DMARC

De laatste val dook op toen Stalwart in het lab een bounce van deze server weigerde. Postfix verstuurt bounces met een lege envelopafzender en From: MAILER-DAEMON@mail.alpha.test. Zonder envelopafzender blijft de enige SPF-controle over op de naam die de server in HELO gaf, mail.alpha.test, die geen SPF-record had. De bounce was ook niet ondertekend. Dus DMARC viel terug op het beleid voor alpha.test, vond p=reject, en de ontvangende server deed precies wat het domein zei:

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

Het eigen beleid van het domein gooide zijn eigen bezorgfouten weg. Elke MX-hostnaam krijgt zijn eigen SPF-record, wat het oplost:

mail IN TXT "v=spf1 a -all"

Zes instellingen die klaar lijken

De vallen hierboven delen een vorm. Ze zijn stuk voor stuk een instelling die klaar lijkt, geen fout geeft, en helemaal niets doet.

Lijkt klaarWat er werkelijk gebeurtGevonden door
smtp_tls_security_level = daneplatte tekst overal waar geen TLSA is gepubliceerdpostconf(5) lezen
login-mismatch-controles op poort 25overgeslagen, één waarschuwing in het logeen vervalsing versturen
local_addrs versmald in local.dsamengevoegd met de standaard, niets versmaldrspamadm configdump
DMARC p=reject op de afzendergemanipuleerde post komt door als SPF slaagtgeval B
certbot-vernieuwingennieuwe sleutel, TLSA matcht niet meercertbots documentatie lezen
DMARC p=reject op je eigen domeinje eigen bounces geweigerdeen geweigerde bounce

Twee ervan staan in de documentatie, als je de juiste paragraaf van de juiste pagina leest. De andere vier doken alleen op door het bericht te sturen dat de regel zou moeten tegenhouden en te kijken wat er terugkwam. De configuratie teruglezen en het ermee eens zijn vond er geen enkele. Als je één gewoonte uit deze post meeneemt, neem dan die, want elke regel hier kan in een server worden gekopieerd en nog steeds niet werken.

De standaarden zijn de beslissing van iemand anders

Elke weigering in deze post is een instelling die Postfix al jaren heeft. Zelfs DANE, het nieuwste hier, zit er sinds 2.11 in.13 Niets ervan is nieuw, niets ervan is duur, en de server eronder is dezelfde vrije software als altijd. Het gat tussen een mailserver die een gewijzigde factuur doorlaat en een die hem bij DATA weigert is een pagina configuratie en een middag testen.

Dus waarom draait zo weinig van het internet zo? Omdat de standaarden beslissen, en de standaarden waren gezet om ervoor te zorgen dat post doorkwam op een internet waar TLS de uitzondering was. Toen was dat een redelijke keuze. Laat ze nu met rust en je hebt je beveiligingsbeslissingen overgedragen aan een keuze die iemand in 2002 maakte, voor een netwerk dat niet meer bestaat, over post die niet van jou was.

Een mailserver draaien is een beslissing om verantwoordelijk te zijn voor wat er doorheen gaat. Die instellingen zijn die beslissing op schrift. De standaarden zijn wat je krijgt als niemand haar nam.


  1. postconf(5): smtpd_tls_security_level — the encrypt level: “According to RFC 2487 this MUST NOT be applied in case of a publicly-referenced SMTP server.” ↩︎

  2. postconf(5): smtp_tls_security_level — the dane level: “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 on GitHub — MIT licensed MTA-STS and DANE policy server for Postfix: “Verify TLSA records for correctness and supported parameters, only then the dane-only policy (Mandatory DANE) will be returned.” Version 1.14.4 used here. ↩︎

  5. Postfix source, src/dns/dns_lookup.c — “Automatically turns on the RES_TRUSTAD flag on systems that support this flag”, and the code that does it when DNSSEC validation is requested. ↩︎

  6. RFC 7672 §3.1.1 — SMTP security via opportunistic DANE TLS. TLSA records for SMTP servers “should, in most cases, be “DANE-EE(3) SPKI(1) SHA2-256(1)” records”. ↩︎

  7. Certbot user guide, command-line options — --reuse-key: “When renewing, use the same private key as the existing certificate. (default: False)”, and under --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 — rejects when the MAIL FROM domain has “no DNS MX and no DNS A record”, or a malformed MX. ↩︎

  10. postconf(5): unknown_address_reject_code — default 450: “Do not change this unless you have a complete understanding of RFC 5321.” ↩︎

  11. Rspamd FAQ: the difference between local.d and override.d — local.d “Merges with defaults. Collections and lists are combined”; override.d “Replaces defaults. Entire sections are overwritten.” ↩︎

  12. Rspamd greylisting module — “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”. ↩︎