compliance: MTA-STS async policy fetch

Wires the new POST /api/resolver/mta-sts-policy endpoint into the
MTA-STS validator. The async pass runs after the local TXT checks,
debounced and cancellable through EditorCompliance, and surfaces:

- Transport-level failures: dns-error, tls-error, fetch-error,
  too-large.
- HTTP-level failures: not-found (404), http-error (other non-2xx),
  redirect (server tried to redirect, RFC 8461 sec. 3.3 forbids it).
- Policy file content: missing/invalid version, missing/invalid mode,
  mode=none (warning, effectively disabled), mode=testing (info),
  missing mx in enforce/testing modes, missing/out-of-range max_age
  (0..31557600), short max_age (< 1 day, warning).

Adds a fetchMTAStsPolicy() wrapper to $lib/api/resolver.ts that accepts
an AbortSignal so the EditorCompliance debounce + abort plumbing covers
this validator like it does for SPF.

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

View file

@ -19,8 +19,14 @@
// 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 { postResolver, postResolverSpfFlatten } from "$lib/api-base/sdk.gen";
import {
postResolver,
postResolverMtaStsPolicy,
postResolverSpfFlatten,
} from "$lib/api-base/sdk.gen";
import type {
HappydnsMtastsPolicyRequest,
HappydnsMtastsPolicyResponse,
HappydnsResolverResponse,
HappydnsSpfFlattenRequest,
HappydnsSpfFlattenResponse,
@ -47,3 +53,15 @@ export async function flattenSPF(
}),
) as HappydnsSpfFlattenResponse;
}
export async function fetchMTAStsPolicy(
body: HappydnsMtastsPolicyRequest,
signal?: AbortSignal,
): Promise<HappydnsMtastsPolicyResponse> {
return unwrapSdkResponse(
await postResolverMtaStsPolicy({
body,
signal,
}),
) as HappydnsMtastsPolicyResponse;
}

View file

@ -946,6 +946,74 @@
"invalid-id": {
"title": "Invalid policy id \"{{id}}\"",
"detail": "id must be 1 to 32 alphanumeric characters per RFC 8461 sec. 3.1."
},
"policy-dns-error": {
"title": "Cannot resolve mta-sts host",
"detail": "DNS lookup for the mta-sts.<domain> hostname failed. Publish an A/AAAA record so the policy at {{url}} can be reached."
},
"policy-tls-error": {
"title": "TLS handshake failed for the policy host",
"detail": "{{error}}"
},
"policy-not-found": {
"title": "No policy file at {{url}}",
"detail": "RFC 8461 sec. 3.3: the policy MUST be served at this URL. Publish the file or remove the TXT record."
},
"policy-http-error": {
"title": "Policy fetch returned HTTP {{code}}",
"detail": "The policy host responded with a non-2xx status; receivers will treat the policy as missing."
},
"policy-redirect": {
"title": "Policy host attempted a redirect (HTTP {{code}})",
"detail": "RFC 8461 sec. 3.3 forbids redirects on the policy URL. Serve the file directly at {{url}}."
},
"policy-fetch-error": {
"title": "Could not fetch the policy",
"detail": "{{error}}"
},
"policy-too-large": {
"title": "Policy body is too large",
"detail": "The file at {{url}} exceeds the size cap (64 KiB). MTA-STS policies are very small; trim the file."
},
"policy-missing-version": {
"title": "Policy file is missing the version key",
"detail": "Add a 'version: STSv1' line at the top of {{url}}."
},
"policy-invalid-version": {
"title": "Policy version \"{{version}}\" is unknown",
"detail": "Only \"STSv1\" is defined by RFC 8461."
},
"policy-missing-mode": {
"title": "Policy file is missing the mode key",
"detail": "Add a 'mode: enforce' (or testing / none) line in {{url}}."
},
"policy-invalid-mode": {
"title": "Policy mode \"{{mode}}\" is unknown",
"detail": "Mode must be one of: enforce, testing, none."
},
"policy-mode-none": {
"title": "Policy mode is \"none\" (MTA-STS effectively disabled)",
"detail": "Receivers will treat this as if no policy were published. Switch to enforce once you trust the policy."
},
"policy-mode-testing": {
"title": "Policy mode is \"testing\"",
"detail": "Receivers will report failures but still deliver. Switch to enforce once reports are clean."
},
"policy-missing-mx": {
"title": "Policy is missing mx entries",
"detail": "A {{mode}} policy must list at least one mx pattern; otherwise no mail can be delivered."
},
"policy-missing-max-age": {
"title": "Policy file is missing max_age",
"detail": "Add a 'max_age: <seconds>' line. Common values are 86400 (1 day) to 604800 (1 week) during rollout, then larger."
},
"policy-invalid-max-age": {
"title": "Policy max_age \"{{maxAge}}\" is out of range",
"detail": "max_age must be between 0 and 31557600 seconds (1 year)."
},
"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."
}
},
"tlsrpt": {

View file

@ -717,6 +717,74 @@
"invalid-id": {
"title": "Identifiant de politique invalide « {{id}} »",
"detail": "id doit contenir 1 à 32 caractères alphanumériques (RFC 8461 sec. 3.1)."
},
"policy-dns-error": {
"title": "Impossible de résoudre l'hôte mta-sts",
"detail": "La résolution DNS de mta-sts.<domaine> a échoué. Publiez un enregistrement A/AAAA pour que la politique à {{url}} soit joignable."
},
"policy-tls-error": {
"title": "Échec du handshake TLS pour l'hôte de politique",
"detail": "{{error}}"
},
"policy-not-found": {
"title": "Aucun fichier de politique à {{url}}",
"detail": "RFC 8461 sec. 3.3 : la politique DOIT être servie à cette URL. Publiez le fichier ou retirez l'enregistrement TXT."
},
"policy-http-error": {
"title": "Le téléchargement de la politique a renvoyé HTTP {{code}}",
"detail": "Le serveur de politique a répondu avec un statut non-2xx ; les destinataires considéreront la politique comme manquante."
},
"policy-redirect": {
"title": "L'hôte de politique a tenté une redirection (HTTP {{code}})",
"detail": "RFC 8461 sec. 3.3 interdit les redirections sur l'URL de politique. Servez le fichier directement à {{url}}."
},
"policy-fetch-error": {
"title": "Impossible de télécharger la politique",
"detail": "{{error}}"
},
"policy-too-large": {
"title": "Le fichier de politique est trop volumineux",
"detail": "Le fichier à {{url}} dépasse la limite de taille (64 Kio). Les politiques MTA-STS sont très courtes ; allégez le fichier."
},
"policy-missing-version": {
"title": "Le fichier de politique n'a pas de clé version",
"detail": "Ajoutez une ligne « version: STSv1 » en tête de {{url}}."
},
"policy-invalid-version": {
"title": "Version de politique « {{version}} » inconnue",
"detail": "Seule « STSv1 » est définie par la RFC 8461."
},
"policy-missing-mode": {
"title": "Le fichier de politique n'a pas de clé mode",
"detail": "Ajoutez une ligne « mode: enforce » (ou testing / none) dans {{url}}."
},
"policy-invalid-mode": {
"title": "Mode de politique « {{mode}} » inconnu",
"detail": "Le mode doit être : enforce, testing ou none."
},
"policy-mode-none": {
"title": "Le mode de politique est « none » (MTA-STS effectivement désactivé)",
"detail": "Les destinataires se comporteront comme si aucune politique n'était publiée. Passez à enforce quand vous aurez confiance."
},
"policy-mode-testing": {
"title": "Mode de politique « testing »",
"detail": "Les destinataires rapporteront les échecs mais livreront tout de même. Passez à enforce une fois les rapports propres."
},
"policy-missing-mx": {
"title": "La politique n'a pas d'entrées mx",
"detail": "Une politique {{mode}} doit lister au moins un motif mx ; sinon aucun courrier ne peut être livré."
},
"policy-missing-max-age": {
"title": "Le fichier de politique n'a pas de max_age",
"detail": "Ajoutez une ligne « max_age: <secondes> ». Valeurs usuelles : 86400 (1 jour) à 604800 (1 semaine) en déploiement, puis plus."
},
"policy-invalid-max-age": {
"title": "max_age « {{maxAge}} » hors plage",
"detail": "max_age doit être compris entre 0 et 31557600 secondes (1 an)."
},
"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."
}
},
"tlsrpt": {

View file

@ -19,6 +19,7 @@
// 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 { fetchMTAStsPolicy } from "$lib/api/resolver";
import {
type ComplianceContext,
type ComplianceIssue,
@ -29,6 +30,10 @@ 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}$/;
const VALID_MODES = new Set(["enforce", "testing", "none"]);
// RFC 8461 sec. 3.2: max_age is in [0, 31557600] (1 year).
const MAX_AGE_HARD_LIMIT = 31557600;
const MAX_AGE_RECOMMENDED_MIN = 86400; // sec. 3.2 recommends "at least one week" but anything below a day is suspicious.
function mtaStsSync(raw: Record<string, any>, _ctx: ComplianceContext): ComplianceIssue[] {
const issues: ComplianceIssue[] = [];
@ -95,4 +100,158 @@ function mtaStsSync(raw: Record<string, any>, _ctx: ComplianceContext): Complian
return issues;
}
registerValidators("svcs.MTA_STS", { sync: mtaStsSync });
async function mtaStsAsync(
_raw: Record<string, any>,
ctx: ComplianceContext,
signal: AbortSignal,
): Promise<ComplianceIssue[]> {
const domain = ctx.origin?.domain;
if (!domain) return [];
const cleanDomain = domain.replace(/\.$/, "");
if (!cleanDomain) return [];
const issues: ComplianceIssue[] = [];
const resp = await fetchMTAStsPolicy({ domain: cleanDomain }, signal);
const url = resp.url ?? "";
switch (resp.status) {
case "ok":
break;
case "dns-error":
issues.push({
id: "mta_sts.policy-dns-error",
severity: "error",
params: { url },
docUrl: RFC + "#section-3.3",
});
return issues;
case "tls-error":
issues.push({
id: "mta_sts.policy-tls-error",
severity: "error",
params: { url, error: resp.errorMsg ?? "" },
docUrl: RFC + "#section-3.3",
});
return issues;
case "not-found":
issues.push({
id: "mta_sts.policy-not-found",
severity: "error",
params: { url },
docUrl: RFC + "#section-3.3",
});
return issues;
case "http-error":
issues.push({
id: resp.redirected ? "mta_sts.policy-redirect" : "mta_sts.policy-http-error",
severity: "warning",
params: { url, code: resp.httpCode ?? 0 },
docUrl: RFC + "#section-3.3",
});
return issues;
case "fetch-error":
issues.push({
id: "mta_sts.policy-fetch-error",
severity: "warning",
params: { url, error: resp.errorMsg ?? "" },
});
return issues;
case "too-large":
issues.push({
id: "mta_sts.policy-too-large",
severity: "error",
params: { url },
});
return issues;
default:
// Unknown status: ignore so a future backend addition does not
// surface a localized "undefined" string.
return issues;
}
// status === "ok": validate parsed policy fields.
if (!resp.version) {
issues.push({
id: "mta_sts.policy-missing-version",
severity: "error",
params: { url },
docUrl: RFC + "#section-3.2",
});
} else if (resp.version !== "STSv1") {
issues.push({
id: "mta_sts.policy-invalid-version",
severity: "error",
params: { url, version: resp.version },
docUrl: RFC + "#section-3.2",
});
}
const mode = resp.mode ?? "";
if (!mode) {
issues.push({
id: "mta_sts.policy-missing-mode",
severity: "error",
params: { url },
docUrl: RFC + "#section-3.2",
});
} else if (!VALID_MODES.has(mode)) {
issues.push({
id: "mta_sts.policy-invalid-mode",
severity: "error",
params: { url, mode },
docUrl: RFC + "#section-3.2",
});
} else if (mode === "none") {
issues.push({
id: "mta_sts.policy-mode-none",
severity: "warning",
params: { url },
docUrl: RFC + "#section-3.2",
});
} else if (mode === "testing") {
issues.push({
id: "mta_sts.policy-mode-testing",
severity: "info",
params: { url },
docUrl: RFC + "#section-3.2",
});
}
const mxList = resp.mx ?? [];
if ((mode === "enforce" || mode === "testing") && mxList.length === 0) {
issues.push({
id: "mta_sts.policy-missing-mx",
severity: "error",
params: { url, mode },
docUrl: RFC + "#section-3.2",
});
}
const maxAge = resp.maxAge ?? 0;
if (!maxAge) {
issues.push({
id: "mta_sts.policy-missing-max-age",
severity: "error",
params: { url },
docUrl: RFC + "#section-3.2",
});
} else if (maxAge < 0 || maxAge > MAX_AGE_HARD_LIMIT) {
issues.push({
id: "mta_sts.policy-invalid-max-age",
severity: "error",
params: { url, maxAge },
docUrl: RFC + "#section-3.2",
});
} else if (maxAge < MAX_AGE_RECOMMENDED_MIN) {
issues.push({
id: "mta_sts.policy-short-max-age",
severity: "warning",
params: { url, maxAge },
docUrl: RFC + "#section-3.2",
});
}
return issues;
}
registerValidators("svcs.MTA_STS", { sync: mtaStsSync, async: mtaStsAsync });