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:
parent
c9ad96f4a8
commit
f4fd57facc
4 changed files with 315 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue