compliance: MTA-STS record sync validators

Adds checks for svcs.MTA_STS against RFC 8461 sec. 3.1.

The validator surfaces:

- Wrong owner name (must be _mta-sts.<domain>).
- Missing or non-STSv1 v= tag.
- Missing id= tag.
- id= containing characters outside [A-Za-z0-9] or longer than 32 chars.

The TXT only carries the policy pointer; the actual policy file at
mta-sts.<domain>/.well-known/mta-sts.txt is out of scope here and will
need an HTTPS fetch (out of scope for the sync pass).

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

View file

@ -922,6 +922,32 @@
"detail": "Expected mailto:user@example.com (an optional !size suffix is allowed)."
}
},
"mta_sts": {
"wrong-owner-name": {
"title": "MTA-STS record is not at _mta-sts.{{name}}",
"detail": "MTA-STS records must be published at _mta-sts.<domain>."
},
"parse-error": {
"title": "Could not parse the MTA-STS record",
"detail": "The TXT value does not look like a key=value MTA-STS record."
},
"missing-version": {
"title": "Missing v=STSv1",
"detail": "An MTA-STS TXT record must start with v=STSv1."
},
"invalid-version": {
"title": "Unsupported MTA-STS version \"{{version}}\"",
"detail": "Only \"STSv1\" is defined by RFC 8461."
},
"missing-id": {
"title": "Missing policy id (id=)",
"detail": "MTA-STS requires a policy identifier; bump it whenever the policy file changes."
},
"invalid-id": {
"title": "Invalid policy id \"{{id}}\"",
"detail": "id must be 1 to 32 alphanumeric characters per RFC 8461 sec. 3.1."
}
},
"spf": {
"missing-version": {
"title": "Missing v=spf1 prefix",

View file

@ -693,6 +693,32 @@
"detail": "Format attendu : mailto:utilisateur@exemple.fr (un suffixe !taille est autorisé)."
}
},
"mta_sts": {
"wrong-owner-name": {
"title": "L'enregistrement MTA-STS n'est pas sous _mta-sts.{{name}}",
"detail": "Les enregistrements MTA-STS doivent être publiés sous _mta-sts.<domaine>."
},
"parse-error": {
"title": "Impossible d'analyser l'enregistrement MTA-STS",
"detail": "La valeur TXT ne ressemble pas à un enregistrement MTA-STS clé=valeur."
},
"missing-version": {
"title": "Tag v=STSv1 manquant",
"detail": "Un enregistrement TXT MTA-STS doit commencer par v=STSv1."
},
"invalid-version": {
"title": "Version MTA-STS non supportée « {{version}} »",
"detail": "Seule « STSv1 » est définie par la RFC 8461."
},
"missing-id": {
"title": "Identifiant de politique manquant (id=)",
"detail": "MTA-STS requiert un identifiant de politique ; incrémentez-le à chaque changement du fichier de politique."
},
"invalid-id": {
"title": "Identifiant de politique invalide « {{id}} »",
"detail": "id doit contenir 1 à 32 caractères alphanumériques (RFC 8461 sec. 3.1)."
}
},
"spf": {
"missing-version": {
"title": "Préfixe v=spf1 manquant",

View file

@ -26,6 +26,7 @@
// per-record support.
import "$lib/services/dkim/compliance";
import "$lib/services/dmarc/compliance";
import "$lib/services/mta_sts/compliance";
import "$lib/services/spf";
export {};

View file

@ -0,0 +1,61 @@
// 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";
const ORIGIN = { domain: "example.com." } as unknown as Domain;
const CTX = buildContext("_mta-sts", ORIGIN, null);
function run(txt: string, name = "_mta-sts.example.com."): ComplianceIssue[] {
const v = getValidators("svcs.MTA_STS");
return v!.sync!({ txt: { Hdr: { Name: name }, Txt: txt } }, CTX);
}
const ids = (issues: ComplianceIssue[]) => issues.map((i) => i.id);
describe("MTA-STS compliance", () => {
it("accepts a clean record", () => {
expect(ids(run("v=STSv1;id=20240101"))).toEqual([]);
});
it("flags a wrong owner name", () => {
expect(ids(run("v=STSv1;id=2024", "example.com."))).toContain("mta_sts.wrong-owner-name");
});
it("flags missing version", () => {
expect(ids(run("id=2024"))).toContain("mta_sts.missing-version");
});
it("flags non-STSv1 version", () => {
expect(ids(run("v=STSv2;id=2024"))).toContain("mta_sts.invalid-version");
});
it("flags missing id", () => {
expect(ids(run("v=STSv1"))).toContain("mta_sts.missing-id");
});
it("flags an id with non-alphanumeric chars", () => {
expect(ids(run("v=STSv1;id=2024-01-01"))).toContain("mta_sts.invalid-id");
});
it("flags an id longer than 32 chars", () => {
expect(ids(run("v=STSv1;id=" + "a".repeat(33)))).toContain("mta_sts.invalid-id");
});
it("returns no issue on empty TXT", () => {
expect(run("")).toEqual([]);
});
});

View file

@ -0,0 +1,98 @@
// 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 {
type ComplianceContext,
type ComplianceIssue,
registerValidators,
} from "$lib/services/compliance";
import { parseMTASTS, type MTASTSValue } from "$lib/services/mta_sts";
const RFC = "https://www.rfc-editor.org/rfc/rfc8461";
// RFC 8461 sec. 3.1: id is 1..32 alphanumeric characters.
const ID_RE = /^[A-Za-z0-9]{1,32}$/;
function mtaStsSync(raw: Record<string, any>, _ctx: ComplianceContext): ComplianceIssue[] {
const issues: ComplianceIssue[] = [];
const txt = raw?.txt;
if (!txt) return issues;
const txtValue: string = typeof txt.Txt === "string" ? txt.Txt : "";
const name: string = typeof txt.Hdr?.Name === "string" ? txt.Hdr.Name : "";
// Owner name must be _mta-sts.<domain>.
if (name && !/^_mta-sts(\.|$)/i.test(name)) {
issues.push({
id: "mta_sts.wrong-owner-name",
severity: "error",
params: { name },
docUrl: RFC + "#section-3.1",
});
}
if (!txtValue.trim()) return issues;
let val: MTASTSValue;
try {
val = parseMTASTS(txtValue);
} catch {
issues.push({ id: "mta_sts.parse-error", severity: "error", field: "txt" });
return issues;
}
if (!val.v) {
issues.push({
id: "mta_sts.missing-version",
severity: "error",
field: "v",
docUrl: RFC + "#section-3.1",
});
} else if (val.v !== "STSv1") {
issues.push({
id: "mta_sts.invalid-version",
severity: "error",
params: { version: val.v },
field: "v",
docUrl: RFC + "#section-3.1",
});
}
if (val.id === undefined || val.id === "") {
issues.push({
id: "mta_sts.missing-id",
severity: "error",
field: "id",
docUrl: RFC + "#section-3.1",
});
} else if (!ID_RE.test(val.id)) {
issues.push({
id: "mta_sts.invalid-id",
severity: "error",
params: { id: val.id },
field: "id",
docUrl: RFC + "#section-3.1",
});
}
return issues;
}
registerValidators("svcs.MTA_STS", { sync: mtaStsSync });