Skip to content
devtls

Mail, DNS & config rules

Three checks beyond the HTTPS deep scan. All are browser-delivered: users type a domain or paste a file, the server does the work, nothing is downloaded or run locally.

  • Mail TLS — the transport on SMTP/IMAP/POP3 ports.
  • Mail DNS — SPF, DKIM, DMARC, MTA-STS, TLS-RPT.
  • Config analysis — Docker Compose + Traefik labels, Traefik file config, Nginx, Caddy.

A mail target is a domains row with kind = 'mail' (port 0, so the same host can be tracked as both a web and a mail target). One row covers the whole stack: its scan fans out to every selected port and to DNS, and stores one composite result.

The domain a user types is rarely the host their mail runs on, so the scan follows the MX records and probes the client ports there. Two refinements matter:

  • Port 25 is probed on every MX, not just the primary — a neglected backup MX is a classic weak link.
  • Client ports fall back to the apex domain when the MX host doesn’t answer. A domain behind a filtering MX (Proofpoint, Mimecast, a spam gateway) runs submission somewhere else entirely.

When nothing answers and the MX host differs from the domain, the error says so explicitly rather than repeating four identical timeouts.

Port Protocol TLS Default
587 SMTP submission STARTTLS ✓
465 SMTPS implicit ✓
993 IMAPS implicit ✓
995 POP3S implicit ✓
25 SMTP relay (MX) STARTTLS opt-in
143 IMAP STARTTLS opt-in
110 POP3 STLS opt-in

25/143/110 are opt-in because they are legacy or server-to-server, and their failures read as noise on a typical setup. Port 25 is also blocked on most cloud egress paths.

A STARTTLS port opens in the clear and only becomes encrypted if the server advertises the upgrade and the client asks. Two facts are therefore reported before any certificate is examined:

  • Is the upgrade offered at all? If not, every session on that port is permanently plaintext.
  • Is authentication offered before the upgrade? SMTP advertising AUTH, or IMAP not advertising LOGINDISABLED, means credentials can cross in the clear — and an attacker who strips the advertisement can force exactly that.

Only the capability announcement is read. No credentials are ever sent and no mail is submitted.

Version probing is serial. Mail servers cap concurrent connections per client as standard anti-abuse configuration (Postfix’s smtpd_client_connection_count_limit). Probing the four TLS versions in parallel earns a 421 too many connections, which is indistinguishable from the server refusing that version — it fabricated “TLS 1.2 disabled” findings against a perfectly healthy server. Mail probes run one at a time; the HTTPS path is unaffected and stays parallel.

A failure before the handshake is indeterminate, not disabled. “Not tested” and “refused” are different claims, and only one of them is a finding.

Serial probing is slow: one blackholing STARTTLS port can spend 110s in timeouts, and all seven ports could reach ~440s — past the job runner’s DEVTLS_SCAN_TIMEOUT (180s default). Being killed there discards every result, so the scan stops itself first at two thirds of the job cap and reports which ports it never reached.

Separate from src/grade.ts — see src/grade-mail.ts. The overall grade is the worst endpoint’s, because an attacker picks the port. Two rules differ from the web grader deliberately:

  • HSTS is not considered. It is an HTTP header; port 993 has none.
  • A hostname mismatch is a 10-point warning, not a cap. Browsers reject a mismatched web certificate outright, but server-to-server SMTP is opportunistic and routinely presents a certificate for the mail host rather than the domain. Capping would bury the real findings.
Finding Severity
STARTTLS not offered on a STARTTLS port critical (forces F)
Authentication offered before TLS critical
Expired certificate · SSLv2/SSLv3 enabled critical (forces F)
TLS 1.0 · weak cipher · untrusted chain · self-signed high
TLS 1.1 · no forward secrecy · hostname mismatch medium
No TLS 1.3 · certificate expiring soon low–medium
A selected port that never answered low, no score penalty

That last row matters: most stacks run only some of these ports, so penalizing a closed one would manufacture failures.


See src/engine/mail/dns.ts and src/grade-mail-dns.ts. Every finding ships a copy-paste-ready record, because the fix for all of these is a DNS entry someone has to type correctly.

  • SPF — the all qualifier, and the DNS lookup count following include: and redirect= recursively. Exceeding RFC 7208’s limit of 10 is a permerror: receivers discard SPF entirely, so a domain can look perfectly configured and authenticate nowhere. This cannot be seen without recursing. +all is treated as critical — worse than no SPF, since it actively vouches for forgeries.
  • DKIM — user-supplied selectors, or a probe of common ones. Key type and size are read from the p= tag. Selectors are not discoverable from DNS, so a probe miss is not proof of absence and the finding says so and softens to medium.
  • DMARC — policy strength, pct, rua, and sp=none exempting subdomains.
  • MTA-STS — the _mta-sts TXT and the policy fetched over HTTPS, since only mode: enforce actually stops a sender falling back to plaintext.
  • TLS-RPT — the reporting record.

TXT records are reassembled from their DNS character-strings before parsing. This is not cosmetic: GitHub’s SPF record is split mid-IP-address, and parsing the chunks separately yields garbage.

DNS is capped at 40 points of the mail score. Sound transport with no email authentication should land around C — “your transport is fine, your authentication is missing” — rather than F.


See src/config/. Entirely offline: nothing referenced in a pasted config is ever fetched, so no SSRF surface is introduced.

POST /api/config-checks accepts { content, kind?, name?, save? }. Omit kind to auto-detect. A syntax error returns 400 with code: "PARSE_ERROR" and a line number — never a 500.

Pasted configuration routinely carries basicauth hashes, API keys and database passwords. Retention is opt-in (save defaults to false), and anything stored is redacted first — passwords, tokens, keys, bcrypt hashes and PEM blocks. The findings are always kept; the credentials never are.

Compose’s ${VAR:-default} interpolation is resolved before any comparison, and network aliases are resolved to real Docker network names. Where a variable cannot be resolved statically, the rule declines to judge rather than guessing — otherwise it reports findings about the reader’s missing .env file.

Checks include: traefik.enable missing or false; routers with no rule or no entrypoint; plain HTTP with no redirect; TLS without a certificate resolver; TLS with no tls.options; no HSTS middleware; --api.insecure=true; an unauthenticated dashboard router; insecureSkipVerify; host ports that bypass the proxy; network mismatches.

The tls.options rule is the one that matters most here. Without it Traefik falls back to defaults that still accept TLS 1.0/1.1 — the site gets a valid certificate and a padlock while negotiating deprecated protocols, and a live scan grades it around C. It is exactly why devtls.fleeq.net itself graded C; see deploy/traefik-tls-options.yml.

A real tokenizer, not regexes — a CSP value like "default-src 'self'; script-src 'self'" contains both ; and {, the characters that terminate directives and open blocks.

The highest-value rule is the add_header inheritance trap: nginx replaces the add_header set rather than merging it, so a location that adds one header silently drops every header its server sets. A site passes a header check at the server level and sends nothing on the paths that matter. Flagged high, naming each header that is lost.

Also: legacy ssl_protocols; weak ssl_ciphers; port-80 servers that serve instead of redirecting; missing HSTS and security headers; add_header without always (skipped on error responses); server_tokens on; plaintext non-local proxy_pass.

Caddy obtains certificates, redirects HTTP→HTTPS and requires TLS 1.2 without being asked, so these rules look for deliberate downgrades rather than missing hardening: auto_https off, tls internal on a public host, protocols tls1.0, tls_insecure_skip_verify, missing security headers. A clean Caddyfile produces zero findings.


Data-driven in src/plans.ts, so changing them needs no migration.

Free Pro Team Founder
Mail scans ✓ ✓ ✓ ✓
Config checks / month 10 200 1000 1000

Mail targets count against maxDomains like any other row.