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:
parent
cec4f11c7d
commit
04d0d83965
5 changed files with 212 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
61
web/src/lib/services/mta_sts/compliance.test.ts
Normal file
61
web/src/lib/services/mta_sts/compliance.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
98
web/src/lib/services/mta_sts/compliance.ts
Normal file
98
web/src/lib/services/mta_sts/compliance.ts
Normal 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 });
|
||||
Loading…
Add table
Add a link
Reference in a new issue