From 1ce83bb71a64727b2940a67c3936e55155b5a5a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 13:14:58 +0000 Subject: [PATCH] feat: add /learn/security-txt and /learn/tls-rpt pages Closes #362. Adds RFC 9116 and RFC 8460 learn pages following the existing renderLearnX() pattern, wires both routes, adds both URLs to SITEMAP_URLS, updates the markdown hub, and adds smoke tests verifying 200 responses with correct canonicals and substantive content. https://claude.ai/code/session_01CnbNthXu35yeCa6PrvcVM2 --- src/index.ts | 6 ++ src/views/learn.ts | 178 ++++++++++++++++++++++++++++++++++++++++++ src/views/markdown.ts | 2 + test/index.test.ts | 37 +++++++++ 4 files changed, 223 insertions(+) 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.

+ +
+
How security.txt works
+
+

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
+
+
Contact
Required. One or more URI values — mailto:, https: (for a bug-bounty form), or tel:. Multiple Contact lines are allowed; researchers use the first they can reach.
+
Expires
Required. An ISO 8601 datetime after which the file should be considered stale. RFC 9116 recommends no more than one year in the future. dmarcheck flags files with an expiry more than 12 months out or that have already expired.
+
Policy
Recommended. A URL linking to your full vulnerability disclosure policy — scope, safe-harbor language, response SLA, and reward structure if you run a bug-bounty program.
+
Acknowledgments
Recommended. A page where you credit researchers who have disclosed past issues. Builds trust and encourages future reports.
+
Encryption
Optional. A URL to a PGP public key so researchers can send reports end-to-end encrypted.
+
Canonical
Optional. The definitive URL of this security.txt file. Useful when the file is served from a CDN or a different origin than the one being scanned.
+
Preferred-Languages
Optional. A comma-separated list of BCP 47 language tags indicating which languages the security team can respond in.
+
CSAF
Optional. A URL to a CSAF (Common Security Advisory Framework) provider-metadata.json, for organizations that publish machine-readable advisories.
+
+
+
+ +
+
The two canonical paths and redirect behavior
+
+

RFC 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.

+
+
+ +
+
Common misconfigurations
+
+
    +
  • No Contact field. Contact is the only field RFC 9116 marks as mandatory. A file without it is malformed and dmarcheck reports the missing field.
  • +
  • No Expires field. Also required. Files without an expiry date have no freshness signal, which means researchers cannot tell if the contact is still monitored.
  • +
  • Expired file. An 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.
  • +
  • Expiry too far in the future. RFC 9116 recommends the expiry be no more than one year out. Setting it to 2099 defeats the purpose of the freshness signal.
  • +
  • Served over HTTP instead of HTTPS. The RFC requires HTTPS. A plain-HTTP file can be tampered with in transit and most security scanners will reject it.
  • +
  • Wrong content type. RFC 9116 specifies text/plain. Serving it as application/octet-stream or text/html confuses automated parsers.
  • +
  • File exists at /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.
  • +
+
+
+ +
+
What to fix first
+
+
    +
  1. Create the file at 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.
  2. +
  3. Set Contact to an email address or HTTPS form that your security team actually monitors. A generic security@ alias works if it goes somewhere real.
  4. +
  5. Set 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.
  6. +
  7. Add a Policy link once you have written a disclosure policy — even a brief page clarifying scope and response expectations meaningfully lowers the friction for researchers.
  8. +
  9. Serve the file with Content-Type: text/plain; charset=utf-8. Verify with curl -I https://yourdomain.com/.well-known/security.txt.
  10. +
+
+
+ + ${learnCta("Enter a domain to scan for security.txt")} + `; + + return renderLearnPage({ + protocol: "security.txt", + slug: "security-txt", + title: "What is security.txt? Contact, Expires, and RFC 9116 — dmarcheck", + headline: + "What is security.txt? Responsible disclosure made machine-readable", + description: + "A plain-English guide to security.txt (RFC 9116): the two canonical paths, required Contact and Expires fields, redirect behavior, and the common misconfigurations that make security researchers give up.", + body, + }); +} + +// --------------------------------------------------------------------------- +// TLS-RPT +// --------------------------------------------------------------------------- + +export function renderLearnTlsRpt(): string { + const body = ` +

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.

+ +
+
How to read a TLS-RPT record
+
+

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
+
+
v
Version. Must be TLSRPTv1 — the only defined version. Records without this prefix or with a different version are ignored by sending MTAs.
+
rua
Report URI for aggregates. Accepts mailto: addresses and https: endpoints. Sending MTAs POST JSON reports to HTTPS endpoints or email them as gzip attachments to mailto: addresses.
+
+
+
+ +
+
What the reports contain
+
+

Each TLS-RPT report is a JSON document (gzip-compressed) covering a 24-hour period. The key fields are:

+
+
organization-name
The sending MTA's organization — tells you which mail providers are attempting delivery.
+
date-range
The reporting window: start-datetime and end-datetime in UTC.
+
policies
An array of policy objects. Each one describes a policy type (sts for MTA-STS, tlsa for DANE, no-policy-found) and its outcome.
+
summary
Inside each policy: total-successful-session-count and total-failure-session-count — the core delivery health signal.
+
failure-details
An array of objects with a result-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.
+
+
+
+ +
+
Why TLS-RPT matters alongside DMARC reporting
+
+

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.

+
+
+ +
+
Common misconfigurations
+
+
    +
  • No record. Without _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.
  • +
  • Record present but 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.
  • +
  • Wrong subdomain. The record must be at _smtp._tls.yourdomain.com, not _tls.yourdomain.com or any other location. The double-underscore prefix is significant.
  • +
  • The 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.
  • +
  • Using an HTTPS endpoint that rejects the report format. If you point 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.
  • +
+
+
+ +
+
What to fix first
+
+
    +
  1. Publish the TXT record at _smtp._tls.yourdomain.com with at minimum v=TLSRPTv1; rua=mailto:tlsrpt@yourdomain.com.
  2. +
  3. Make sure the address in 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.
  4. +
  5. If you also have an MTA-STS policy in 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.
  6. +
  7. Once you switch MTA-STS to 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.
  8. +
  9. Keep the TLS-RPT record even after your MTA-STS setup is stable — it provides ongoing assurance that TLS delivery is healthy and catches certificate renewals that were missed before they cause failures.
  10. +
+
+
+ + ${learnCta("Enter a domain to scan for TLS-RPT")} + `; + + return renderLearnPage({ + protocol: "TLS-RPT", + slug: "tls-rpt", + title: "What is TLS-RPT? SMTP TLS Reporting and RFC 8460 — dmarcheck", + headline: "What is TLS-RPT? SMTP TLS Reporting for inbound mail", + description: + "A plain-English guide to TLS-RPT (RFC 8460): the _smtp._tls DNS record, the rua= report URI, what the JSON reports contain, and how TLS-RPT complements MTA-STS and DMARC reporting.", + body, + }); +} diff --git a/src/views/markdown.ts b/src/views/markdown.ts index 612c7e1..b5613e2 100644 --- a/src/views/markdown.ts +++ b/src/views/markdown.ts @@ -314,6 +314,8 @@ export function renderLearnHubMarkdown(): string { - [DKIM](${MD_SITE}/learn/dkim) — cryptographic signatures - [BIMI](${MD_SITE}/learn/bimi) — brand logos in inboxes - [MTA-STS](${MD_SITE}/learn/mta-sts) — TLS enforcement for inbound mail +- [security.txt](${MD_SITE}/learn/security-txt) — machine-readable security disclosure policies +- [TLS-RPT](${MD_SITE}/learn/tls-rpt) — SMTP TLS failure reporting via DNS Run a scan: ${MD_SITE}/check?domain=example.com `; diff --git a/test/index.test.ts b/test/index.test.ts index bdf6c2e..fcd0066 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -700,6 +700,8 @@ describe("SEO routes", () => { expect(body).toContain("https://dmarc.mx/learn/dkim"); expect(body).toContain("https://dmarc.mx/learn/bimi"); expect(body).toContain("https://dmarc.mx/learn/mta-sts"); + expect(body).toContain("https://dmarc.mx/learn/security-txt"); + expect(body).toContain("https://dmarc.mx/learn/tls-rpt"); }); it("/robots.txt is NOT marked noindex (must stay crawlable)", async () => { @@ -777,6 +779,8 @@ describe("Learn pages", () => { { slug: "dkim", label: "DKIM" }, { slug: "bimi", label: "BIMI" }, { slug: "mta-sts", label: "MTA-STS" }, + { slug: "security-txt", label: "security.txt" }, + { slug: "tls-rpt", label: "TLS-RPT" }, ]; it("serves the /learn hub with a CollectionPage + BreadcrumbList", async () => { @@ -877,6 +881,39 @@ describe("Learn pages", () => { expect(html).toContain("enforce"); expect(html).toContain("testing"); }); + + it("security.txt learn page mentions RFC 9116, Contact, and Expires fields", async () => { + const res = await app.request("/learn/security-txt"); + const html = await res.text(); + expect(res.status).toBe(200); + expect(html).toContain( + '', + ); + expect(html).toContain("RFC 9116"); + expect(html).toContain("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("https://dmarc.mx/learn/security-txt"); + expect(body).toContain("https://dmarc.mx/learn/tls-rpt"); + }); }); describe("bare /check request", () => {