compliance: MX record sync validators

Validates a MX record set (svcs.MXs) at edit time:

- Null MX (RFC 7505): a "." target must be the only MX in the set, with
  preference 0. Both deviations are surfaced.
- Targets: invalid hostnames, out-of-range preferences (uint16) and
  duplicate targets (case-insensitive on the FQDN).
- Cross-zone: flags MX targets that are CNAME owners in the same zone
  (RFC 5321 sec. 5.1) and warns when an in-zone target lacks any
  A/AAAA service. External targets are left to runtime checkers.

Unit tests cover happy paths, the null-MX edge cases, target/preference
validation, duplicate detection and the in-zone cross checks (CNAME
collision, missing address, apex target).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-27 12:18:38 +07:00
commit d57c0849a6
5 changed files with 358 additions and 0 deletions

View file

@ -1086,6 +1086,36 @@
"detail": "RFC 8461 recommends at least one week. Short values defeat caching and force receivers to refetch often."
}
},
"mx": {
"null-mx-with-others": {
"title": "Null MX cannot coexist with other MX records",
"detail": "RFC 7505 requires that a null MX (target \".\") be the only MX record in the set."
},
"null-mx-non-zero-preference": {
"title": "Null MX preference should be 0 (got {{preference}})",
"detail": "RFC 7505 specifies preference 0 for the null MX record."
},
"invalid-target": {
"title": "Invalid MX target \"{{target}}\"",
"detail": "MX targets must be valid hostnames per RFC 1035."
},
"invalid-preference": {
"title": "MX preference \"{{preference}}\" is out of range",
"detail": "MX preference must be a 16-bit unsigned integer (0..65535)."
},
"duplicate-target": {
"title": "Duplicate MX target \"{{target}}\"",
"detail": "The same target appears more than once. Keep only one record."
},
"target-is-cname": {
"title": "MX target \"{{target}}\" is a CNAME",
"detail": "RFC 5321 sec. 5.1 forbids MX records from pointing to a CNAME."
},
"target-no-address": {
"title": "MX target \"{{target}}\" has no A/AAAA in this zone",
"detail": "Add a Server entry for this name, or check that the target FQDN is correct."
}
},
"tlsrpt": {
"wrong-owner-name": {
"title": "TLS-RPT record is not at _smtp._tls.{{name}}",

View file

@ -857,6 +857,36 @@
"detail": "La RFC 8461 recommande au moins une semaine. Des valeurs trop courtes empêchent le cache et forcent les re-téléchargements."
}
},
"mx": {
"null-mx-with-others": {
"title": "Un null MX ne peut coexister avec d'autres enregistrements MX",
"detail": "La RFC 7505 impose qu'un null MX (cible « . ») soit le seul enregistrement MX du domaine."
},
"null-mx-non-zero-preference": {
"title": "La préférence d'un null MX doit être 0 (reçu {{preference}})",
"detail": "La RFC 7505 spécifie une préférence à 0 pour l'enregistrement null MX."
},
"invalid-target": {
"title": "Cible MX invalide « {{target}} »",
"detail": "Les cibles MX doivent être des noms d'hôtes valides au sens de la RFC 1035."
},
"invalid-preference": {
"title": "Préférence MX « {{preference}} » hors plage",
"detail": "La préférence MX doit être un entier non signé sur 16 bits (0..65535)."
},
"duplicate-target": {
"title": "Cible MX dupliquée « {{target}} »",
"detail": "La même cible apparaît plusieurs fois. Ne conservez qu'un seul enregistrement."
},
"target-is-cname": {
"title": "La cible MX « {{target}} » est un CNAME",
"detail": "La RFC 5321 sec. 5.1 interdit qu'un MX pointe vers un CNAME."
},
"target-no-address": {
"title": "La cible MX « {{target}} » n'a pas d'A/AAAA dans cette zone",
"detail": "Ajoutez une entrée Serveur pour ce nom, ou vérifiez que le FQDN cible est correct."
}
},
"tlsrpt": {
"wrong-owner-name": {
"title": "L'enregistrement TLS-RPT n'est pas sous _smtp._tls.{{name}}",

View file

@ -28,6 +28,7 @@ import "$lib/services/bimi/compliance";
import "$lib/services/dkim/compliance";
import "$lib/services/dmarc/compliance";
import "$lib/services/mta_sts/compliance";
import "$lib/services/mx/compliance";
import "$lib/services/spf";
import "$lib/services/tlsrpt/compliance";

View file

@ -0,0 +1,125 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 "./compliance";
import {
buildContext,
getValidators,
type ComplianceIssue,
} from "$lib/services/compliance";
import type { Domain } from "$lib/model/domain";
import type { Zone } from "$lib/model/zone";
import type { ServiceWithValue } from "$lib/model/service.svelte";
const ORIGIN = { domain: "example.com." } as unknown as Domain;
function svc(svctype: string): ServiceWithValue {
return { _svctype: svctype, Service: {} } as unknown as ServiceWithValue;
}
function zone(services: Record<string, ServiceWithValue[]>): Zone {
return { services } as unknown as Zone;
}
function run(mx: unknown, z: Zone | null = null): ComplianceIssue[] {
const ctx = buildContext("", ORIGIN, z);
const v = getValidators("svcs.MXs");
expect(v?.sync).toBeDefined();
return v!.sync!({ mx }, ctx);
}
function ids(issues: ComplianceIssue[]): string[] {
return issues.map((i) => i.id);
}
const MX = (Mx: string, Preference = 10) => ({ Mx, Preference });
describe("MX compliance: empty / well-formed", () => {
it("returns no issues on empty list", () => {
expect(run([])).toEqual([]);
});
it("returns no issues on a single valid external target", () => {
expect(run([MX("mail.external.tld.")])).toEqual([]);
});
it("normalizes a single record (not wrapped in array)", () => {
expect(run(MX("mail.external.tld."))).toEqual([]);
});
});
describe("MX compliance: null MX (RFC 7505)", () => {
it("accepts a sole null MX with preference 0", () => {
const issues = run([{ Mx: ".", Preference: 0 }]);
expect(issues).toEqual([]);
});
it("flags null MX coexisting with another record", () => {
const issues = run([{ Mx: ".", Preference: 0 }, MX("mail.external.tld.")]);
expect(ids(issues)).toContain("mx.null-mx-with-others");
});
it("warns when null MX preference is non-zero", () => {
const issues = run([{ Mx: ".", Preference: 10 }]);
expect(ids(issues)).toContain("mx.null-mx-non-zero-preference");
});
});
describe("MX compliance: target validity", () => {
it("flags an invalid hostname target", () => {
const issues = run([MX("mail server.tld.")]);
expect(ids(issues)).toContain("mx.invalid-target");
});
it("flags an out-of-range preference", () => {
const issues = run([{ Mx: "mail.external.tld.", Preference: 70000 }]);
expect(ids(issues)).toContain("mx.invalid-preference");
});
it("flags duplicate targets case-insensitively", () => {
const issues = run([MX("mail.external.tld.", 10), MX("MAIL.External.tld.", 20)]);
expect(ids(issues)).toContain("mx.duplicate-target");
});
});
describe("MX compliance: in-zone cross checks", () => {
it("flags MX target that is a CNAME owner in the zone", () => {
const z = zone({ mail: [svc("svcs.CNAME")] });
const issues = run([MX("mail.example.com.")], z);
expect(ids(issues)).toContain("mx.target-is-cname");
});
it("warns when in-zone target has no A/AAAA service", () => {
const z = zone({});
const issues = run([MX("mail.example.com.")], z);
expect(ids(issues)).toContain("mx.target-no-address");
});
it("does not warn when in-zone target has an abstract.Server", () => {
const z = zone({ mail: [svc("abstract.Server")] });
const issues = run([MX("mail.example.com.")], z);
expect(ids(issues)).not.toContain("mx.target-no-address");
});
it("does not warn for external targets", () => {
const z = zone({});
const issues = run([MX("mail.elsewhere.tld.")], z);
expect(ids(issues)).not.toContain("mx.target-no-address");
expect(ids(issues)).not.toContain("mx.target-is-cname");
});
it("matches apex target to subdomain ''", () => {
const z = zone({ "": [svc("abstract.Server")] });
const issues = run([MX("example.com.")], z);
expect(ids(issues)).not.toContain("mx.target-no-address");
});
});

View file

@ -0,0 +1,172 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 {
asArray,
type ComplianceContext,
type ComplianceIssue,
registerValidators,
} from "$lib/services/compliance";
interface MX {
Mx: string;
Preference: number;
Hdr?: { Name?: string };
}
const HOSTNAME_LABEL_RE = /^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
const RFC5321 = "https://www.rfc-editor.org/rfc/rfc5321#section-5.1";
const RFC7505 = "https://www.rfc-editor.org/rfc/rfc7505";
function normalizeFqdn(name: string): string {
return name.replace(/\.+$/, "").toLowerCase();
}
function isValidHostname(name: string): boolean {
if (!name || name.length > 253) return false;
const labels = name.split(".");
return labels.every((l) => HOSTNAME_LABEL_RE.test(l));
}
/**
* Returns the in-zone subdomain (relative to origin) for a target FQDN,
* or null when the target is outside the edited zone.
*/
function inZoneSubdomain(target: string, originFqdn: string): string | null {
const t = normalizeFqdn(target);
const o = normalizeFqdn(originFqdn);
if (!o) return null;
if (t === o) return "";
if (t.endsWith("." + o)) return t.slice(0, -(o.length + 1));
return null;
}
function mxSync(raw: Record<string, any>, ctx: ComplianceContext): ComplianceIssue[] {
const issues: ComplianceIssue[] = [];
const records = asArray<MX>(raw?.mx).filter((r) => r && typeof r === "object");
if (records.length === 0) return issues;
const originFqdn: string = (ctx.origin as { domain?: string })?.domain ?? "";
// RFC 7505 null MX: target ".", preference 0, MUST be the sole record.
const nullMxes = records.filter((r) => r.Mx?.trim() === "." || r.Mx?.trim() === "");
const hasNullMx = nullMxes.length > 0;
if (hasNullMx && records.length > 1) {
issues.push({
id: "mx.null-mx-with-others",
severity: "error",
docUrl: RFC7505,
});
}
for (const n of nullMxes) {
if (n.Preference !== 0) {
issues.push({
id: "mx.null-mx-non-zero-preference",
severity: "warning",
params: { preference: n.Preference },
docUrl: RFC7505,
});
}
}
// Per-record checks.
const seen = new Map<string, number>();
records.forEach((r, idx) => {
const target = (r.Mx ?? "").trim();
const field = `mx[${idx}]`;
if (target === "" || target === ".") {
// null MX, validated above.
return;
}
const norm = normalizeFqdn(target);
if (!isValidHostname(norm)) {
issues.push({
id: "mx.invalid-target",
severity: "error",
params: { target },
field,
});
return;
}
// Preference is uint16 per RFC 1035.
if (
typeof r.Preference !== "number" ||
!Number.isInteger(r.Preference) ||
r.Preference < 0 ||
r.Preference > 65535
) {
issues.push({
id: "mx.invalid-preference",
severity: "error",
params: { preference: String(r.Preference) },
field,
});
}
// Duplicate detection (case-insensitive on target).
const prev = seen.get(norm);
if (prev !== undefined) {
issues.push({
id: "mx.duplicate-target",
severity: "warning",
params: { target: norm, first: prev, second: idx },
field,
});
} else {
seen.set(norm, idx);
}
// Cross-zone checks when the target lives inside the edited zone.
const sub = inZoneSubdomain(norm, originFqdn);
if (sub === null) return;
// RFC 5321 sec. 5.1: MX target must not be a CNAME.
const cnames = ctx.findServices(sub, "svcs.CNAME");
if (cnames.length > 0) {
issues.push({
id: "mx.target-is-cname",
severity: "error",
params: { target: norm },
field,
docUrl: RFC5321,
});
}
// Heads-up when the in-zone target has no A/AAAA published.
const servers = ctx.findServices(sub, "abstract.Server");
if (servers.length === 0 && cnames.length === 0) {
issues.push({
id: "mx.target-no-address",
severity: "warning",
params: { target: norm },
field,
});
}
});
return issues;
}
registerValidators("svcs.MXs", { sync: mxSync });