compliance: MTA-STS cross-check policy mx vs zone MX

Adds zone-aware checks to the MTA-STS async validator. Once the policy
file is fetched and parsed, compare its mx patterns to the apex MX
records of the current zone (RFC 8461 sec. 4.1):

- mta_sts.zone-no-mx (warning): the policy lists mx entries but the
  zone has no MX records, so receivers will refuse delivery.
- mta_sts.zone-mx-not-covered (error in enforce, warning in testing):
  one of the apex MX hosts is not matched by any policy pattern.
  Senders enforcing the policy will reject mail to that host.
- mta_sts.policy-mx-unused (info): a policy pattern matches no MX in
  the zone, hinting at a stale entry.

Pattern matching follows the spec: "*." matches exactly one DNS label,
otherwise a case-insensitive FQDN match is required (trailing dots
stripped).

The check is skipped when mode is "none" or when the zone state is
unknown (avoids false positives during initial editor load).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-27 12:28:11 +07:00
commit fefb1f385a
4 changed files with 204 additions and 1 deletions

View file

@ -1084,6 +1084,18 @@
"policy-short-max-age": {
"title": "Policy max_age is short ({{maxAge}}s)",
"detail": "RFC 8461 recommends at least one week. Short values defeat caching and force receivers to refetch often."
},
"zone-no-mx": {
"title": "Policy lists mx patterns but the zone has no MX records",
"detail": "Receivers will reject mail to this domain because no apex MX matches the policy at {{url}}."
},
"zone-mx-not-covered": {
"title": "MX \"{{host}}\" is not covered by any policy mx pattern",
"detail": "Senders enforcing the {{mode}} policy at {{url}} will refuse to deliver to this host. Add a matching mx entry to the policy."
},
"policy-mx-unused": {
"title": "Policy mx pattern \"{{pattern}}\" matches no MX in the zone",
"detail": "The pattern listed at {{url}} does not correspond to any MX record. Remove the entry or add the missing MX record."
}
},
"mx": {

View file

@ -855,6 +855,18 @@
"policy-short-max-age": {
"title": "max_age court ({{maxAge}}s)",
"detail": "La RFC 8461 recommande au moins une semaine. Des valeurs trop courtes empêchent le cache et forcent les re-téléchargements."
},
"zone-no-mx": {
"title": "La politique liste des motifs mx mais la zone n'a aucun enregistrement MX",
"detail": "Les expéditeurs refuseront de livrer le courrier vers ce domaine car aucun MX de l'apex ne correspond à la politique de {{url}}."
},
"zone-mx-not-covered": {
"title": "Le MX « {{host}} » n'est couvert par aucun motif mx de la politique",
"detail": "Les expéditeurs appliquant la politique {{mode}} de {{url}} refuseront de livrer vers cet hôte. Ajoutez une entrée mx correspondante."
},
"policy-mx-unused": {
"title": "Le motif mx « {{pattern}} » de la politique ne correspond à aucun MX de la zone",
"detail": "Le motif listé dans {{url}} ne correspond à aucun enregistrement MX. Retirez l'entrée ou ajoutez le MX manquant."
}
},
"mx": {

View file

@ -19,10 +19,18 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("$lib/api/resolver", () => ({
fetchMTAStsPolicy: vi.fn(),
}));
import "./compliance";
import { buildContext, getValidators, type ComplianceIssue } from "$lib/services/compliance";
import type { Domain } from "$lib/model/domain";
import type { ServiceWithValue } from "$lib/model/service.svelte";
import type { Zone } from "$lib/model/zone";
import { fetchMTAStsPolicy } from "$lib/api/resolver";
const ORIGIN = { domain: "example.com." } as unknown as Domain;
const CTX = buildContext("_mta-sts", ORIGIN, null);
@ -33,6 +41,35 @@ function run(txt: string, name = "_mta-sts.example.com."): ComplianceIssue[] {
}
const ids = (issues: ComplianceIssue[]) => issues.map((i) => i.id);
function mxSvc(targets: string[]): ServiceWithValue {
return {
_svctype: "svcs.MXs",
Service: { mx: targets.map((t, i) => ({ Mx: t, Preference: 10 + i })) },
} as unknown as ServiceWithValue;
}
function makeZone(apexMx: string[]): Zone {
return { services: { "": [mxSvc(apexMx)] } } as unknown as Zone;
}
async function runAsync(zone: Zone | null, policyOverride: Record<string, any>): Promise<ComplianceIssue[]> {
const v = getValidators("svcs.MTA_STS");
expect(v?.async).toBeDefined();
(fetchMTAStsPolicy as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
status: "ok",
url: "https://mta-sts.example.com/.well-known/mta-sts.txt",
version: "STSv1",
mode: "enforce",
maxAge: 604800,
...policyOverride,
});
const ctx = buildContext("_mta-sts", ORIGIN, zone);
return v!.async!(
{ txt: { Hdr: { Name: "_mta-sts.example.com." }, Txt: "v=STSv1;id=20240101" } },
ctx,
new AbortController().signal,
);
}
describe("MTA-STS compliance", () => {
it("accepts a clean record", () => {
expect(ids(run("v=STSv1;id=20240101"))).toEqual([]);
@ -59,3 +96,77 @@ describe("MTA-STS compliance", () => {
expect(run("")).toEqual([]);
});
});
describe("MTA-STS cross-check: policy mx vs zone MX", () => {
beforeEach(() => {
(fetchMTAStsPolicy as unknown as ReturnType<typeof vi.fn>).mockReset();
});
it("does not flag when every zone MX matches a policy pattern", async () => {
const issues = await runAsync(makeZone(["mx1.example.com.", "mx2.example.com."]), {
mx: ["mx1.example.com", "mx2.example.com"],
});
expect(ids(issues)).not.toContain("mta_sts.zone-mx-not-covered");
expect(ids(issues)).not.toContain("mta_sts.policy-mx-unused");
expect(ids(issues)).not.toContain("mta_sts.zone-no-mx");
});
it("flags zone MX not covered by any policy pattern (error in enforce)", async () => {
const issues = await runAsync(makeZone(["mx1.example.com.", "rogue.example.com."]), {
mode: "enforce",
mx: ["mx1.example.com"],
});
const e = issues.find((i) => i.id === "mta_sts.zone-mx-not-covered");
expect(e).toBeDefined();
expect(e!.severity).toBe("error");
expect(e!.params?.host).toBe("rogue.example.com.");
});
it("downgrades to warning in testing mode", async () => {
const issues = await runAsync(makeZone(["rogue.example.com."]), {
mode: "testing",
mx: ["mx1.example.com"],
});
const e = issues.find((i) => i.id === "mta_sts.zone-mx-not-covered");
expect(e?.severity).toBe("warning");
});
it("supports wildcard patterns (one label only)", async () => {
const issues = await runAsync(
makeZone(["mx1.mail.example.com.", "deep.nested.mail.example.com."]),
{ mx: ["*.mail.example.com"] },
);
const flagged = issues.filter((i) => i.id === "mta_sts.zone-mx-not-covered");
expect(flagged).toHaveLength(1);
expect(flagged[0].params?.host).toBe("deep.nested.mail.example.com.");
});
it("flags policy patterns that match no MX (info)", async () => {
const issues = await runAsync(makeZone(["mx1.example.com."]), {
mx: ["mx1.example.com", "ghost.example.com"],
});
const u = issues.find((i) => i.id === "mta_sts.policy-mx-unused");
expect(u?.severity).toBe("info");
expect(u?.params?.pattern).toBe("ghost.example.com");
});
it("warns when policy lists mx but the zone has none", async () => {
const issues = await runAsync(makeZone([]), { mx: ["mx1.example.com"] });
expect(ids(issues)).toContain("mta_sts.zone-no-mx");
});
it("skips cross-check when mode is none", async () => {
const issues = await runAsync(makeZone(["rogue.example.com."]), {
mode: "none",
mx: ["mx1.example.com"],
});
expect(ids(issues)).not.toContain("mta_sts.zone-mx-not-covered");
expect(ids(issues)).not.toContain("mta_sts.policy-mx-unused");
});
it("skips cross-check when zone is unknown", async () => {
const issues = await runAsync(null, { mx: ["mx1.example.com"] });
expect(ids(issues)).not.toContain("mta_sts.zone-mx-not-covered");
expect(ids(issues)).not.toContain("mta_sts.zone-no-mx");
});
});

View file

@ -227,6 +227,45 @@ async function mtaStsAsync(
});
}
// Cross-check the policy patterns against the apex MX records of the
// current zone (RFC 8461 sec. 4.1). Only meaningful when the policy
// actually filters mail and the zone state is known.
if ((mode === "enforce" || mode === "testing") && ctx.zone) {
const zoneMx = getZoneApexMxHosts(ctx);
if (zoneMx.length === 0 && mxList.length > 0) {
issues.push({
id: "mta_sts.zone-no-mx",
severity: "warning",
params: { url },
docUrl: RFC + "#section-4.1",
});
} else if (zoneMx.length > 0 && mxList.length > 0) {
for (const host of zoneMx) {
const matched = mxList.some((p) => mtaStsPatternMatches(p, host));
if (!matched) {
issues.push({
id: "mta_sts.zone-mx-not-covered",
severity: mode === "enforce" ? "error" : "warning",
params: { url, host, mode },
field: host,
docUrl: RFC + "#section-4.1",
});
}
}
for (const pattern of mxList) {
const matched = zoneMx.some((h) => mtaStsPatternMatches(pattern, h));
if (!matched) {
issues.push({
id: "mta_sts.policy-mx-unused",
severity: "info",
params: { url, pattern },
docUrl: RFC + "#section-4.1",
});
}
}
}
}
const maxAge = resp.maxAge ?? 0;
if (!maxAge) {
issues.push({
@ -254,4 +293,33 @@ async function mtaStsAsync(
return issues;
}
// RFC 8461 sec. 4.1: a "*." prefix matches exactly one DNS label; otherwise
// an exact (case-insensitive) FQDN match is required.
function mtaStsPatternMatches(pattern: string, host: string): boolean {
const p = pattern.toLowerCase().replace(/\.$/, "");
const h = host.toLowerCase().replace(/\.$/, "");
if (p.startsWith("*.")) {
const suffix = p.slice(2);
if (!suffix) return false;
if (!h.endsWith("." + suffix)) return false;
const head = h.slice(0, h.length - suffix.length - 1);
return head.length > 0 && !head.includes(".");
}
return p === h;
}
function getZoneApexMxHosts(ctx: ComplianceContext): string[] {
const services = ctx.findServices("", "svcs.MXs");
const hosts: string[] = [];
for (const s of services) {
const mx = (s.Service as Record<string, any> | undefined)?.mx;
if (!Array.isArray(mx)) continue;
for (const entry of mx) {
const target = entry?.Mx;
if (typeof target === "string" && target) hosts.push(target);
}
}
return hosts;
}
registerValidators("svcs.MTA_STS", { sync: mtaStsSync, async: mtaStsAsync });