diff --git a/src/index.ts b/src/index.ts index 32700d5..2d5e612 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,9 @@ import { renderLearnDmarc, renderLearnHub, renderLearnMtaSts, + renderLearnSecurityTxt, renderLearnSpf, + renderLearnTlsRpt, } from "./views/learn.js"; import { renderPrivacyPage } from "./views/legal.js"; import { @@ -924,6 +926,8 @@ const STATIC_SITEMAP_URLS: Array<{ loc: string; priority: string }> = [ { loc: "https://dmarc.mx/learn/dkim", priority: "0.7" }, { loc: "https://dmarc.mx/learn/bimi", priority: "0.6" }, { loc: "https://dmarc.mx/learn/mta-sts", priority: "0.7" }, + { loc: "https://dmarc.mx/learn/security-txt", priority: "0.6" }, + { loc: "https://dmarc.mx/learn/tls-rpt", priority: "0.6" }, { loc: "https://dmarc.mx/mx", priority: "0.7" }, { loc: "https://dmarc.mx/mx/outlook", priority: "0.8" }, { loc: "https://dmarc.mx/mx/google", priority: "0.8" }, @@ -983,6 +987,8 @@ app.get("/learn/spf", (c) => c.html(renderLearnSpf())); app.get("/learn/dkim", (c) => c.html(renderLearnDkim())); app.get("/learn/bimi", (c) => c.html(renderLearnBimi())); app.get("/learn/mta-sts", (c) => c.html(renderLearnMtaSts())); +app.get("/learn/security-txt", (c) => c.html(renderLearnSecurityTxt())); +app.get("/learn/tls-rpt", (c) => c.html(renderLearnTlsRpt())); app.get("/mx", (c) => { if (wantsMarkdown(c)) return markdownResponse(c, renderMxHubMarkdown()); diff --git a/src/views/learn.ts b/src/views/learn.ts index 3da14a0..437ef72 100644 --- a/src/views/learn.ts +++ b/src/views/learn.ts @@ -105,6 +105,16 @@ const LEARN_SIBLINGS: Array<{ slug: string; protocol: string; blurb: string }> = protocol: "MTA-STS", blurb: "TLS enforcement for inbound mail.", }, + { + slug: "security-txt", + protocol: "security.txt", + blurb: "Machine-readable security disclosure policies.", + }, + { + slug: "tls-rpt", + protocol: "TLS-RPT", + blurb: "SMTP TLS failure reporting via DNS.", + }, ]; function siblingLinks(currentSlug: string): string { @@ -584,3 +594,171 @@ max_age: 604800 body, }); } + +// --------------------------------------------------------------------------- +// security.txt +// --------------------------------------------------------------------------- + +export function renderLearnSecurityTxt(): string { + const body = ` +
security.txt (RFC 9116) is a plain-text file hosted on your web server that tells security researchers how to responsibly disclose vulnerabilities. A machine-readable format means automated scanners and bug-bounty platforms can find your contact information without guessing email addresses.
+ +The file must be served over HTTPS at one of two canonical paths. The preferred location is the .well-known directory:
https://example.com/.well-known/security.txt
+https://example.com/security.txt
+ A minimal but complete security.txt looks like this:
+Contact: mailto:security@example.com
+Expires: 2027-01-01T00:00:00.000Z
+Policy: https://example.com/security-policy
+Acknowledgments: https://example.com/hall-of-fame
+ Contactmailto:, https: (for a bug-bounty form), or tel:. Multiple Contact lines are allowed; researchers use the first they can reach.ExpiresPolicyAcknowledgmentsEncryptionCanonicalPreferred-LanguagesCSAFRFC 9116 specifies that the preferred location is /.well-known/security.txt. If that path is absent, tools fall back to /security.txt at the root. dmarcheck checks /.well-known/security.txt first, then the root path.
Unlike MTA-STS (which explicitly forbids following redirects), RFC 9116 §3 places no equivalent restriction on security.txt. Real-world deployments commonly use redirects — for example, a government domain redirecting to a central vulnerability disclosure portal. dmarcheck follows redirects (redirect: "follow") to honor this convention. If the final destination is a valid security.txt, the scan passes regardless of how many hops were involved.
Contact field. Contact is the only field RFC 9116 marks as mandatory. A file without it is malformed and dmarcheck reports the missing field.Expires field. Also required. Files without an expiry date have no freshness signal, which means researchers cannot tell if the contact is still monitored.Expires date in the past means the file has not been reviewed recently. Rotate it at least annually — a cron job or calendar reminder is the usual approach.text/plain. Serving it as application/octet-stream or text/html confuses automated parsers./security.txt but not /.well-known/security.txt. The preferred path is the .well-known location. Serve the canonical copy there, and optionally redirect from the root.https://yourdomain.com/.well-known/security.txt. Use securitytxt.org to generate a signed version with a PGP key, or hand-craft the minimal two-field version to start.Contact to an email address or HTTPS form that your security team actually monitors. A generic security@ alias works if it goes somewhere real.Expires to roughly 12 months from today in ISO 8601 format (e.g. 2027-06-01T00:00:00.000Z). Add a calendar reminder to renew it.Policy link once you have written a disclosure policy — even a brief page clarifying scope and response expectations meaningfully lowers the friction for researchers.Content-Type: text/plain; charset=utf-8. Verify with curl -I https://yourdomain.com/.well-known/security.txt.TLS-RPT (SMTP TLS Reporting, RFC 8460) is a DNS TXT record that tells other mail servers where to send reports about TLS negotiation failures when they try to deliver mail to you. It is the reporting companion to MTA-STS: you enforce TLS via MTA-STS, and TLS-RPT tells you when senders cannot meet that requirement.
+ +The TLS-RPT record lives at _smtp._tls.yourdomain.com as a TXT record:
v=TLSRPTv1; rua=mailto:tlsrpt@example.com
+ Multiple report destinations are comma-separated:
+v=TLSRPTv1; rua=mailto:tlsrpt@example.com,https://tlsrpt.example.com/upload
+ vTLSRPTv1 — the only defined version. Records without this prefix or with a different version are ignored by sending MTAs.ruamailto: addresses and https: endpoints. Sending MTAs POST JSON reports to HTTPS endpoints or email them as gzip attachments to mailto: addresses.Each TLS-RPT report is a JSON document (gzip-compressed) covering a 24-hour period. The key fields are:
+organization-namedate-rangestart-datetime and end-datetime in UTC.policiessts for MTA-STS, tlsa for DANE, no-policy-found) and its outcome.summarytotal-successful-session-count and total-failure-session-count — the core delivery health signal.failure-detailsresult-type (e.g. certificate-expired, certificate-not-trusted, starttls-not-supported), the sending/receiving MX IP, and the count. This is where you learn why TLS failed.DMARC aggregate reports (via rua=) tell you whether messages are passing SPF/DKIM authentication at the receiving end. TLS-RPT fills a different gap: it tells you whether the transport layer between sending and receiving MTAs is healthy. A message can pass DMARC and still be at risk if the SMTP connection fell back to cleartext because of a TLS negotiation failure.
Together, the two reporting streams give you end-to-end visibility: authentication posture (DMARC) and transport security posture (TLS-RPT). Both are worth monitoring continuously.
+TLS-RPT is particularly important during MTA-STS rollout. While your policy is in testing mode, senders will report failures without dropping the message. Once you switch to enforce, failures start causing deferrals, so you want to see a clean TLS-RPT baseline first.
_smtp._tls.yourdomain.com, you have no visibility into TLS delivery failures to your domain. If you have MTA-STS deployed, a missing TLS-RPT record means flying blind.rua is missing. The rua tag is the only meaningful field after v=TLSRPTv1. A record without it is syntactically incomplete — sending MTAs will not know where to send reports._smtp._tls.yourdomain.com, not _tls.yourdomain.com or any other location. The double-underscore prefix is significant.mailto: address is not monitored. Reports arrive as .gz-compressed JSON attachments. If the alias goes to a ticket queue no one checks, failures will pile up silently. Set up an alert or use a third-party TLS-RPT processing service.rua at an HTTPS URL, the receiving server must accept POST requests with Content-Type: application/tlsrpt+gzip and respond 200-299. A webhook that returns 400 on unrecognized payloads will silently drop reports._smtp._tls.yourdomain.com with at minimum v=TLSRPTv1; rua=mailto:tlsrpt@yourdomain.com.rua is monitored. For low-volume domains a simple inbox alias is enough; for high-volume domains, use a third-party SMTP TLS reporting service that parses and visualizes the JSON for you.testing mode, watch the TLS-RPT reports for a week or two before switching to enforce. Any sts-policy-invalid or certificate-* failure types need investigation first.enforce, continue monitoring TLS-RPT. A spike in starttls-not-supported from a major sender signals a configuration problem on their end that may require contacting their postmaster.Contact");
+ expect(html).toContain("Expires");
+ expect(html).toContain(".well-known/security.txt");
+ });
+
+ it("TLS-RPT learn page mentions RFC 8460, _smtp._tls, and rua=", async () => {
+ const res = await app.request("/learn/tls-rpt");
+ const html = await res.text();
+ expect(res.status).toBe(200);
+ expect(html).toContain(
+ '',
+ );
+ expect(html).toContain("RFC 8460");
+ expect(html).toContain("_smtp._tls");
+ expect(html).toContain("rua");
+ expect(html).toContain("TLSRPTv1");
+ });
+
+ it("sitemap.xml lists /learn/security-txt and /learn/tls-rpt", async () => {
+ const res = await app.request("/sitemap.xml");
+ const body = await res.text();
+ expect(body).toContain("