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:
parent
731dfbc3bf
commit
d57c0849a6
5 changed files with 358 additions and 0 deletions
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
125
web/src/lib/services/mx/compliance.test.ts
Normal file
125
web/src/lib/services/mx/compliance.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
172
web/src/lib/services/mx/compliance.ts
Normal file
172
web/src/lib/services/mx/compliance.ts
Normal 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 });
|
||||
Loading…
Add table
Add a link
Reference in a new issue