compliance: Origin and NSOnlyOrigin validators
Some checks are pending
continuous-integration/drone/push Build is pending
Some checks are pending
continuous-integration/drone/push Build is pending
Add sync validators for the zone apex services. Coverage: - SOA structure: primary master and Mbox hostnames (no IP literals, FQDN form), Mbox MUST NOT contain '@' (RFC 1035 §3.3.13), serial is non-zero and warned about when 10 digits don't match YYYYMMDDNN (RFC 1912 §2.2 example), Refresh / Retry / Expire / Minttl ranges per RFC 1912 §2.2 and RFC 2308 §5, plus the Retry < Refresh and Expire >= Refresh + Retry cross-checks. - NS list: at least 1 NS (error) and at least 2 (warning, RFC 2182 §3.1), no IP literals, FQDN form, duplicate detection, and a warning when the SOA primary master is not also published as an NS (RFC 1912 §2.7). - abstract.NSOnlyOrigin reuses the NS-list checks without SOA. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
411916e434
commit
9e079f6c5c
5 changed files with 842 additions and 0 deletions
|
|
@ -1451,6 +1451,128 @@
|
|||
"title": "No _xmpp-server._tcp record",
|
||||
"detail": "Server-to-server federation relies on _xmpp-server._tcp; without it remote XMPP servers cannot reach yours."
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"missing-soa": {
|
||||
"title": "Origin without SOA record",
|
||||
"detail": "The zone apex must publish a Start Of Authority record."
|
||||
},
|
||||
"no-ns": {
|
||||
"title": "Origin without NS records",
|
||||
"detail": "A zone apex must list at least one authoritative name server."
|
||||
},
|
||||
"single-ns": {
|
||||
"title": "Origin has only one NS record",
|
||||
"detail": "RFC 2182 §3.1 recommends at least two name servers on diverse infrastructure."
|
||||
},
|
||||
"ns-missing-target": {
|
||||
"title": "NS record without a target",
|
||||
"detail": "Each NS must point to a hostname."
|
||||
},
|
||||
"ns-target-is-ip": {
|
||||
"title": "NS target is an IP address: {{target}}",
|
||||
"detail": "RFC 1034 forbids IP literals in NS targets — use a hostname that resolves to A/AAAA records."
|
||||
},
|
||||
"ns-invalid-hostname": {
|
||||
"title": "Invalid NS hostname: {{target}}",
|
||||
"detail": "NS targets must be valid DNS hostnames."
|
||||
},
|
||||
"ns-target-not-fqdn": {
|
||||
"title": "NS target ‘{{target}}’ is not absolute",
|
||||
"detail": "Add a trailing dot to make the target an FQDN, otherwise the zone origin will be appended."
|
||||
},
|
||||
"duplicate-ns": {
|
||||
"title": "Duplicate NS target: {{target}}",
|
||||
"detail": "The same hostname is listed twice; only the first is used."
|
||||
},
|
||||
"soa-primary-missing-from-ns": {
|
||||
"title": "SOA primary ‘{{primary}}’ is not listed as an NS",
|
||||
"detail": "RFC 1912 §2.7: the master listed in the MNAME field of the SOA should also appear as an NS at the apex."
|
||||
},
|
||||
"soa-missing-primary": {
|
||||
"title": "SOA primary master is empty",
|
||||
"detail": "Provide the FQDN of the authoritative primary name server."
|
||||
},
|
||||
"soa-primary-is-ip": {
|
||||
"title": "SOA primary is an IP literal: {{target}}",
|
||||
"detail": "RFC 1035 requires a hostname; place the IP behind an A/AAAA record and reference the hostname here."
|
||||
},
|
||||
"soa-primary-invalid-hostname": {
|
||||
"title": "Invalid SOA primary hostname: {{target}}",
|
||||
"detail": "The MNAME field must be a valid DNS hostname."
|
||||
},
|
||||
"soa-primary-not-fqdn": {
|
||||
"title": "SOA primary ‘{{target}}’ is not absolute",
|
||||
"detail": "Add a trailing dot to make the primary an FQDN."
|
||||
},
|
||||
"soa-missing-mbox": {
|
||||
"title": "SOA contact (Mbox) is empty",
|
||||
"detail": "Set the responsible-party mailbox in DNS form, e.g. hostmaster.example.com."
|
||||
},
|
||||
"soa-mbox-with-at": {
|
||||
"title": "SOA Mbox contains ‘@’: {{value}}",
|
||||
"detail": "RFC 1035 §3.3.13 encodes the address with a dot replacing the ‘@’: hostmaster.example.com., not hostmaster@example.com."
|
||||
},
|
||||
"soa-mbox-invalid": {
|
||||
"title": "Invalid SOA contact: {{value}}",
|
||||
"detail": "Mbox must be a valid DNS hostname (e.g. hostmaster.example.com.)."
|
||||
},
|
||||
"soa-mbox-not-fqdn": {
|
||||
"title": "SOA Mbox ‘{{value}}’ is not absolute",
|
||||
"detail": "Add a trailing dot so the Mbox does not get the zone origin appended."
|
||||
},
|
||||
"soa-invalid-serial": {
|
||||
"title": "Invalid SOA serial: {{value}}",
|
||||
"detail": "The serial must be a non-negative 32-bit integer."
|
||||
},
|
||||
"soa-zero-serial": {
|
||||
"title": "SOA serial is 0",
|
||||
"detail": "Slaves keep the zone version they already have if the master serial is 0; bump it to a real value."
|
||||
},
|
||||
"soa-serial-not-date": {
|
||||
"title": "Serial ‘{{value}}’ is 10 digits but not YYYYMMDDNN",
|
||||
"detail": "RFC 1912 §2.2 example uses date-based serials. With 10 digits the operator usually expects the YYYYMMDDNN convention."
|
||||
},
|
||||
"soa-invalid-refresh": {
|
||||
"title": "Invalid SOA refresh: {{value}}",
|
||||
"detail": "Refresh must be a non-negative integer in seconds."
|
||||
},
|
||||
"soa-refresh-out-of-range": {
|
||||
"title": "SOA refresh outside RFC 1912 range ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommends refresh between {{min}} and {{max}} seconds."
|
||||
},
|
||||
"soa-invalid-retry": {
|
||||
"title": "Invalid SOA retry: {{value}}",
|
||||
"detail": "Retry must be a non-negative integer in seconds."
|
||||
},
|
||||
"soa-retry-out-of-range": {
|
||||
"title": "SOA retry outside RFC 1912 range ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommends retry between {{min}} and {{max}} seconds."
|
||||
},
|
||||
"soa-retry-ge-refresh": {
|
||||
"title": "SOA retry is not smaller than refresh",
|
||||
"detail": "Slaves should retry on a shorter cycle than the regular refresh interval."
|
||||
},
|
||||
"soa-invalid-expire": {
|
||||
"title": "Invalid SOA expire: {{value}}",
|
||||
"detail": "Expire must be a non-negative integer in seconds."
|
||||
},
|
||||
"soa-expire-out-of-range": {
|
||||
"title": "SOA expire outside RFC 1912 range ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommends expire between {{min}} and {{max}} seconds (commonly 2 weeks)."
|
||||
},
|
||||
"soa-expire-lt-refresh-retry": {
|
||||
"title": "SOA expire is shorter than refresh + retry",
|
||||
"detail": "Slaves should not give up on the zone before they had a chance to retry the refresh."
|
||||
},
|
||||
"soa-invalid-minttl": {
|
||||
"title": "Invalid SOA minimum TTL: {{value}}",
|
||||
"detail": "Minimum/Negative TTL must be a non-negative integer in seconds."
|
||||
},
|
||||
"soa-minttl-out-of-range": {
|
||||
"title": "SOA minimum/negative TTL outside common range ({{value}}s)",
|
||||
"detail": "RFC 2308 §5 caps the negative-cache TTL effectively at a few hours; aim for a value between {{min}} and {{max}} seconds."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1222,6 +1222,128 @@
|
|||
"title": "Aucun enregistrement _xmpp-server._tcp",
|
||||
"detail": "La fédération serveur-à-serveur s'appuie sur _xmpp-server._tcp ; sans cela les serveurs distants ne peuvent pas joindre le vôtre."
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"missing-soa": {
|
||||
"title": "Origine sans enregistrement SOA",
|
||||
"detail": "L'apex de zone doit publier un enregistrement Start Of Authority."
|
||||
},
|
||||
"no-ns": {
|
||||
"title": "Origine sans enregistrement NS",
|
||||
"detail": "L'apex de zone doit lister au moins un serveur de noms autoritaire."
|
||||
},
|
||||
"single-ns": {
|
||||
"title": "Origine avec un seul NS",
|
||||
"detail": "RFC 2182 §3.1 recommande au moins deux serveurs de noms sur des infrastructures diverses."
|
||||
},
|
||||
"ns-missing-target": {
|
||||
"title": "NS sans cible",
|
||||
"detail": "Chaque NS doit pointer vers un nom d'hôte."
|
||||
},
|
||||
"ns-target-is-ip": {
|
||||
"title": "Cible NS est une adresse IP : {{target}}",
|
||||
"detail": "La RFC 1034 interdit les littéraux IP comme cible de NS — utilisez un nom d'hôte qui résout en A/AAAA."
|
||||
},
|
||||
"ns-invalid-hostname": {
|
||||
"title": "Nom d'hôte NS invalide : {{target}}",
|
||||
"detail": "Les cibles NS doivent être des noms DNS valides."
|
||||
},
|
||||
"ns-target-not-fqdn": {
|
||||
"title": "Cible NS ‘{{target}}’ non absolue",
|
||||
"detail": "Ajoutez un point final pour rendre la cible FQDN, sinon l'origine de zone sera ajoutée."
|
||||
},
|
||||
"duplicate-ns": {
|
||||
"title": "Cible NS dupliquée : {{target}}",
|
||||
"detail": "Le même hôte est listé deux fois ; seul le premier est utilisé."
|
||||
},
|
||||
"soa-primary-missing-from-ns": {
|
||||
"title": "Primaire SOA ‘{{primary}}’ absent des NS",
|
||||
"detail": "RFC 1912 §2.7 : le maître figurant en MNAME du SOA doit aussi être listé en NS à l'apex."
|
||||
},
|
||||
"soa-missing-primary": {
|
||||
"title": "Primaire SOA vide",
|
||||
"detail": "Renseignez le FQDN du serveur de noms primaire faisant autorité."
|
||||
},
|
||||
"soa-primary-is-ip": {
|
||||
"title": "Primaire SOA est une IP : {{target}}",
|
||||
"detail": "La RFC 1035 impose un nom d'hôte ; placez l'IP derrière un A/AAAA et référencez le nom ici."
|
||||
},
|
||||
"soa-primary-invalid-hostname": {
|
||||
"title": "Nom d'hôte SOA invalide : {{target}}",
|
||||
"detail": "Le champ MNAME doit être un nom DNS valide."
|
||||
},
|
||||
"soa-primary-not-fqdn": {
|
||||
"title": "Primaire SOA ‘{{target}}’ non absolu",
|
||||
"detail": "Ajoutez un point final pour rendre le nom FQDN."
|
||||
},
|
||||
"soa-missing-mbox": {
|
||||
"title": "Contact SOA (Mbox) vide",
|
||||
"detail": "Renseignez la boîte responsable au format DNS, par ex. hostmaster.example.com."
|
||||
},
|
||||
"soa-mbox-with-at": {
|
||||
"title": "Le Mbox SOA contient ‘@’ : {{value}}",
|
||||
"detail": "RFC 1035 §3.3.13 encode l'adresse avec un point à la place du ‘@’ : hostmaster.example.com., pas hostmaster@example.com."
|
||||
},
|
||||
"soa-mbox-invalid": {
|
||||
"title": "Contact SOA invalide : {{value}}",
|
||||
"detail": "Mbox doit être un nom DNS valide (ex. hostmaster.example.com.)."
|
||||
},
|
||||
"soa-mbox-not-fqdn": {
|
||||
"title": "Mbox SOA ‘{{value}}’ non absolu",
|
||||
"detail": "Ajoutez un point final pour que le Mbox ne reçoive pas l'origine de zone en suffixe."
|
||||
},
|
||||
"soa-invalid-serial": {
|
||||
"title": "Serial SOA invalide : {{value}}",
|
||||
"detail": "Le serial doit être un entier 32 bits non négatif."
|
||||
},
|
||||
"soa-zero-serial": {
|
||||
"title": "Serial SOA à 0",
|
||||
"detail": "Les esclaves conservent leur version actuelle si le serial maître est 0 ; passez à une valeur réelle."
|
||||
},
|
||||
"soa-serial-not-date": {
|
||||
"title": "Serial ‘{{value}}’ : 10 chiffres mais pas YYYYMMDDNN",
|
||||
"detail": "RFC 1912 §2.2 illustre la convention basée date. Avec 10 chiffres, l'opérateur attend généralement le format YYYYMMDDNN."
|
||||
},
|
||||
"soa-invalid-refresh": {
|
||||
"title": "Refresh SOA invalide : {{value}}",
|
||||
"detail": "Refresh doit être un entier non négatif en secondes."
|
||||
},
|
||||
"soa-refresh-out-of-range": {
|
||||
"title": "Refresh SOA hors plage RFC 1912 ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommande un refresh entre {{min}} et {{max}} secondes."
|
||||
},
|
||||
"soa-invalid-retry": {
|
||||
"title": "Retry SOA invalide : {{value}}",
|
||||
"detail": "Retry doit être un entier non négatif en secondes."
|
||||
},
|
||||
"soa-retry-out-of-range": {
|
||||
"title": "Retry SOA hors plage RFC 1912 ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommande un retry entre {{min}} et {{max}} secondes."
|
||||
},
|
||||
"soa-retry-ge-refresh": {
|
||||
"title": "Retry SOA pas inférieur au refresh",
|
||||
"detail": "Les esclaves doivent réessayer sur un cycle plus court que le refresh régulier."
|
||||
},
|
||||
"soa-invalid-expire": {
|
||||
"title": "Expire SOA invalide : {{value}}",
|
||||
"detail": "Expire doit être un entier non négatif en secondes."
|
||||
},
|
||||
"soa-expire-out-of-range": {
|
||||
"title": "Expire SOA hors plage RFC 1912 ({{value}}s)",
|
||||
"detail": "RFC 1912 §2.2 recommande un expire entre {{min}} et {{max}} secondes (typiquement 2 semaines)."
|
||||
},
|
||||
"soa-expire-lt-refresh-retry": {
|
||||
"title": "Expire SOA plus court que refresh + retry",
|
||||
"detail": "Les esclaves ne devraient pas abandonner la zone avant d'avoir eu l'occasion de retenter le refresh."
|
||||
},
|
||||
"soa-invalid-minttl": {
|
||||
"title": "TTL minimum SOA invalide : {{value}}",
|
||||
"detail": "Le TTL minimum/négatif doit être un entier non négatif en secondes."
|
||||
},
|
||||
"soa-minttl-out-of-range": {
|
||||
"title": "TTL minimum/négatif SOA hors plage usuelle ({{value}}s)",
|
||||
"detail": "RFC 2308 §5 plafonne le TTL négatif à quelques heures ; visez une valeur entre {{min}} et {{max}} secondes."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,5 +35,6 @@ import "../verification"; // abstract.ACMEChallenge + verification services
|
|||
import "../openpgp"; // abstract.OpenPGP + abstract.SMimeCert
|
||||
import "../delegation"; // abstract.Delegation
|
||||
import "../srv-abstract"; // abstract.RFC6186 + abstract.MatrixIM + abstract.XMPP
|
||||
import "../origin"; // abstract.Origin + abstract.NSOnlyOrigin
|
||||
|
||||
export {};
|
||||
|
|
|
|||
224
web/src/lib/services/origin.test.ts
Normal file
224
web/src/lib/services/origin.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// 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 { validateOrigin, validateNSOnlyOrigin } from "./origin";
|
||||
import type { ComplianceContext } from "./compliance";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
|
||||
const ctx: ComplianceContext = {
|
||||
dn: "@",
|
||||
origin: { id: "test", domain: "example.com" } as unknown as Domain,
|
||||
zone: null,
|
||||
findServices: () => [],
|
||||
findAllServices: () => [],
|
||||
};
|
||||
|
||||
const ids = (issues: { id: string }[]) => issues.map((i) => i.id);
|
||||
|
||||
const okSOA = {
|
||||
Ns: "ns1.example.com.",
|
||||
Mbox: "hostmaster.example.com.",
|
||||
Serial: 2026042501,
|
||||
Refresh: 28800,
|
||||
Retry: 3600,
|
||||
Expire: 1209600,
|
||||
Minttl: 3600,
|
||||
};
|
||||
|
||||
describe("validateOrigin", () => {
|
||||
it("accepts a clean RFC 1912-compliant origin", () => {
|
||||
expect(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: okSOA,
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags missing SOA", () => {
|
||||
expect(ids(validateOrigin({ ns: [{ Ns: "ns1.example.com." }] }, ctx))).toContain(
|
||||
"abstract.origin.missing-soa",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags Mbox with @", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Mbox: "hostmaster@example.com." },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-mbox-with-at");
|
||||
});
|
||||
|
||||
it("flags zero serial", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Serial: 0 },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-zero-serial");
|
||||
});
|
||||
|
||||
it("flags 10-digit serial that's not date-shaped", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Serial: 1234567890 },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-serial-not-date");
|
||||
});
|
||||
|
||||
it("flags refresh out of range", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Refresh: 60 },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-refresh-out-of-range");
|
||||
});
|
||||
|
||||
it("flags retry >= refresh", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Retry: 7200, Refresh: 7200 },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-retry-ge-refresh");
|
||||
});
|
||||
|
||||
it("flags expire shorter than refresh + retry", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: { ...okSOA, Expire: 1209600, Refresh: 86400, Retry: 7200 },
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).not.toContain("abstract.origin.soa-expire-lt-refresh-retry");
|
||||
});
|
||||
|
||||
it("flags single NS", () => {
|
||||
expect(
|
||||
ids(validateOrigin({ soa: okSOA, ns: [{ Ns: "ns1.example.com." }] }, ctx)),
|
||||
).toContain("abstract.origin.single-ns");
|
||||
});
|
||||
|
||||
it("flags SOA primary missing from NS list", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: okSOA,
|
||||
ns: [{ Ns: "ns2.example.com." }, { Ns: "ns3.example.com." }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.soa-primary-missing-from-ns");
|
||||
});
|
||||
|
||||
it("flags duplicate NS targets", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: okSOA,
|
||||
ns: [
|
||||
{ Ns: "ns1.example.com." },
|
||||
{ Ns: "ns1.example.com." },
|
||||
{ Ns: "ns2.example.com." },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.duplicate-ns");
|
||||
});
|
||||
|
||||
it("flags IP-literal NS", () => {
|
||||
expect(
|
||||
ids(
|
||||
validateOrigin(
|
||||
{
|
||||
soa: okSOA,
|
||||
ns: [{ Ns: "ns1.example.com." }, { Ns: "203.0.113.1" }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).toContain("abstract.origin.ns-target-is-ip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNSOnlyOrigin", () => {
|
||||
it("accepts a two-NS origin", () => {
|
||||
expect(
|
||||
validateNSOnlyOrigin(
|
||||
{ ns: [{ Ns: "ns1.example.com." }, { Ns: "ns2.example.com." }] },
|
||||
ctx,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags single NS", () => {
|
||||
expect(
|
||||
ids(validateNSOnlyOrigin({ ns: [{ Ns: "ns1.example.com." }] }, ctx)),
|
||||
).toContain("abstract.origin.single-ns");
|
||||
});
|
||||
|
||||
it("flags missing NS", () => {
|
||||
expect(ids(validateNSOnlyOrigin({ ns: [] }, ctx))).toContain(
|
||||
"abstract.origin.no-ns",
|
||||
);
|
||||
});
|
||||
});
|
||||
373
web/src/lib/services/origin.ts
Normal file
373
web/src/lib/services/origin.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// 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 { isFqdn, isIpAddress, isValidHostname } from "$lib/services/_helpers/hostname";
|
||||
import {
|
||||
registerValidators,
|
||||
type ComplianceContext,
|
||||
type ComplianceIssue,
|
||||
} from "$lib/services/compliance";
|
||||
|
||||
const RFC1912_URL = "https://datatracker.ietf.org/doc/html/rfc1912#section-2.2";
|
||||
const RFC2182_URL = "https://datatracker.ietf.org/doc/html/rfc2182#section-3.1";
|
||||
const RFC2308_URL = "https://datatracker.ietf.org/doc/html/rfc2308#section-5";
|
||||
const RFC1034_URL = "https://datatracker.ietf.org/doc/html/rfc1034#section-4.2.2";
|
||||
|
||||
interface NSRecord {
|
||||
Ns?: string;
|
||||
}
|
||||
interface SOARecord {
|
||||
Ns?: string;
|
||||
Mbox?: string;
|
||||
Serial?: number;
|
||||
Refresh?: number;
|
||||
Retry?: number;
|
||||
Expire?: number;
|
||||
Minttl?: number;
|
||||
}
|
||||
interface OriginValue {
|
||||
soa?: SOARecord | null;
|
||||
ns?: NSRecord[] | null;
|
||||
}
|
||||
|
||||
// RFC 1912 §2.2 ranges (in seconds).
|
||||
const REFRESH_MIN = 1200; // 20 min
|
||||
const REFRESH_MAX = 86400; // 1 day
|
||||
const RETRY_MIN = 120; // 2 min
|
||||
const RETRY_MAX = 7200; // 2 h
|
||||
const EXPIRE_MIN = 1209600; // 2 weeks
|
||||
const EXPIRE_MAX = 2419200; // 4 weeks
|
||||
// RFC 2308 §5: negative TTL is normally ≤ 3h; we allow up to 1d before flagging.
|
||||
const MINTTL_MIN = 60;
|
||||
const MINTTL_MAX = 86400;
|
||||
|
||||
// YYYYMMDDNN convention (RFC 1912 §2.2 example).
|
||||
const SERIAL_DATE_RE = /^(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{2}$/;
|
||||
|
||||
function normalizeHost(s: string): string {
|
||||
return s.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function validateNSList(
|
||||
ns: NSRecord[],
|
||||
issues: ComplianceIssue[],
|
||||
soaPrimary: string | undefined,
|
||||
): void {
|
||||
if (ns.length === 0) {
|
||||
issues.push({
|
||||
id: "abstract.origin.no-ns",
|
||||
severity: "error",
|
||||
field: "ns",
|
||||
docUrl: RFC1034_URL,
|
||||
});
|
||||
} else if (ns.length === 1) {
|
||||
issues.push({
|
||||
id: "abstract.origin.single-ns",
|
||||
severity: "warning",
|
||||
field: "ns",
|
||||
docUrl: RFC2182_URL,
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Map<string, number>();
|
||||
let primaryListed = soaPrimary === undefined;
|
||||
const normalizedPrimary = soaPrimary ? normalizeHost(soaPrimary) : "";
|
||||
|
||||
ns.forEach((r, i) => {
|
||||
const target = (r.Ns ?? "").trim();
|
||||
if (!target) {
|
||||
issues.push({
|
||||
id: "abstract.origin.ns-missing-target",
|
||||
severity: "error",
|
||||
field: `ns[${i}].Ns`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isIpAddress(target)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.ns-target-is-ip",
|
||||
severity: "error",
|
||||
field: `ns[${i}].Ns`,
|
||||
params: { target },
|
||||
docUrl: RFC1034_URL,
|
||||
});
|
||||
} else if (!isValidHostname(target)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.ns-invalid-hostname",
|
||||
severity: "error",
|
||||
field: `ns[${i}].Ns`,
|
||||
params: { target },
|
||||
});
|
||||
}
|
||||
if (!isFqdn(target)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.ns-target-not-fqdn",
|
||||
severity: "info",
|
||||
field: `ns[${i}].Ns`,
|
||||
params: { target },
|
||||
});
|
||||
}
|
||||
const key = normalizeHost(target);
|
||||
if (seen.has(key)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.duplicate-ns",
|
||||
severity: "info",
|
||||
field: `ns[${i}].Ns`,
|
||||
params: { target: key },
|
||||
});
|
||||
} else {
|
||||
seen.set(key, i);
|
||||
}
|
||||
if (normalizedPrimary && key === normalizedPrimary) {
|
||||
primaryListed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (soaPrimary && !primaryListed) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-primary-missing-from-ns",
|
||||
severity: "warning",
|
||||
params: { primary: soaPrimary },
|
||||
docUrl: "https://datatracker.ietf.org/doc/html/rfc1912#section-2.7",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateSOA(soa: SOARecord, issues: ComplianceIssue[]): void {
|
||||
const ns = (soa.Ns ?? "").trim();
|
||||
if (!ns) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-missing-primary",
|
||||
severity: "error",
|
||||
field: "soa.Ns",
|
||||
});
|
||||
} else if (isIpAddress(ns)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-primary-is-ip",
|
||||
severity: "error",
|
||||
field: "soa.Ns",
|
||||
params: { target: ns },
|
||||
});
|
||||
} else if (!isValidHostname(ns)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-primary-invalid-hostname",
|
||||
severity: "error",
|
||||
field: "soa.Ns",
|
||||
params: { target: ns },
|
||||
});
|
||||
} else if (!isFqdn(ns)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-primary-not-fqdn",
|
||||
severity: "info",
|
||||
field: "soa.Ns",
|
||||
params: { target: ns },
|
||||
});
|
||||
}
|
||||
|
||||
const mbox = (soa.Mbox ?? "").trim();
|
||||
if (!mbox) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-missing-mbox",
|
||||
severity: "error",
|
||||
field: "soa.Mbox",
|
||||
});
|
||||
} else if (mbox.includes("@")) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-mbox-with-at",
|
||||
severity: "error",
|
||||
field: "soa.Mbox",
|
||||
params: { value: mbox },
|
||||
});
|
||||
} else if (!isValidHostname(mbox)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-mbox-invalid",
|
||||
severity: "error",
|
||||
field: "soa.Mbox",
|
||||
params: { value: mbox },
|
||||
});
|
||||
} else if (!isFqdn(mbox)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-mbox-not-fqdn",
|
||||
severity: "info",
|
||||
field: "soa.Mbox",
|
||||
params: { value: mbox },
|
||||
});
|
||||
}
|
||||
|
||||
const serial = soa.Serial;
|
||||
if (serial === undefined || !Number.isInteger(serial) || serial < 0) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-invalid-serial",
|
||||
severity: "error",
|
||||
field: "soa.Serial",
|
||||
params: { value: serial ?? "?" },
|
||||
});
|
||||
} else if (serial === 0) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-zero-serial",
|
||||
severity: "warning",
|
||||
field: "soa.Serial",
|
||||
});
|
||||
} else {
|
||||
const s = String(serial);
|
||||
if (s.length === 10 && !SERIAL_DATE_RE.test(s)) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-serial-not-date",
|
||||
severity: "info",
|
||||
field: "soa.Serial",
|
||||
params: { value: serial },
|
||||
docUrl: RFC1912_URL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkRange = (
|
||||
value: number | undefined,
|
||||
field: keyof SOARecord,
|
||||
min: number,
|
||||
max: number,
|
||||
outOfRangeId: string,
|
||||
rfcUrl: string,
|
||||
) => {
|
||||
if (value === undefined || !Number.isInteger(value) || value < 0) {
|
||||
issues.push({
|
||||
id: `abstract.origin.soa-invalid-${field.toLowerCase()}`,
|
||||
severity: "error",
|
||||
field: `soa.${field}`,
|
||||
params: { value: value ?? "?" },
|
||||
docUrl: rfcUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value < min || value > max) {
|
||||
issues.push({
|
||||
id: outOfRangeId,
|
||||
severity: "warning",
|
||||
field: `soa.${field}`,
|
||||
params: { value, min, max },
|
||||
docUrl: rfcUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
soa.Refresh,
|
||||
"Refresh",
|
||||
REFRESH_MIN,
|
||||
REFRESH_MAX,
|
||||
"abstract.origin.soa-refresh-out-of-range",
|
||||
RFC1912_URL,
|
||||
);
|
||||
checkRange(
|
||||
soa.Retry,
|
||||
"Retry",
|
||||
RETRY_MIN,
|
||||
RETRY_MAX,
|
||||
"abstract.origin.soa-retry-out-of-range",
|
||||
RFC1912_URL,
|
||||
);
|
||||
checkRange(
|
||||
soa.Expire,
|
||||
"Expire",
|
||||
EXPIRE_MIN,
|
||||
EXPIRE_MAX,
|
||||
"abstract.origin.soa-expire-out-of-range",
|
||||
RFC1912_URL,
|
||||
);
|
||||
checkRange(
|
||||
soa.Minttl,
|
||||
"Minttl",
|
||||
MINTTL_MIN,
|
||||
MINTTL_MAX,
|
||||
"abstract.origin.soa-minttl-out-of-range",
|
||||
RFC2308_URL,
|
||||
);
|
||||
|
||||
if (
|
||||
Number.isInteger(soa.Retry) &&
|
||||
Number.isInteger(soa.Refresh) &&
|
||||
soa.Retry !== undefined &&
|
||||
soa.Refresh !== undefined &&
|
||||
soa.Retry >= soa.Refresh
|
||||
) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-retry-ge-refresh",
|
||||
severity: "warning",
|
||||
field: "soa.Retry",
|
||||
docUrl: RFC1912_URL,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isInteger(soa.Expire) &&
|
||||
Number.isInteger(soa.Refresh) &&
|
||||
Number.isInteger(soa.Retry) &&
|
||||
soa.Expire !== undefined &&
|
||||
soa.Refresh !== undefined &&
|
||||
soa.Retry !== undefined &&
|
||||
soa.Expire < soa.Refresh + soa.Retry
|
||||
) {
|
||||
issues.push({
|
||||
id: "abstract.origin.soa-expire-lt-refresh-retry",
|
||||
severity: "warning",
|
||||
field: "soa.Expire",
|
||||
docUrl: RFC1912_URL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOrigin(v: OriginValue, _ctx: ComplianceContext): ComplianceIssue[] {
|
||||
const issues: ComplianceIssue[] = [];
|
||||
const ns = Array.isArray(v.ns) ? v.ns : [];
|
||||
const soa = v.soa ?? null;
|
||||
|
||||
if (!soa) {
|
||||
issues.push({
|
||||
id: "abstract.origin.missing-soa",
|
||||
severity: "error",
|
||||
field: "soa",
|
||||
});
|
||||
} else {
|
||||
validateSOA(soa, issues);
|
||||
}
|
||||
|
||||
validateNSList(ns, issues, soa?.Ns);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateNSOnlyOrigin(
|
||||
v: { ns?: NSRecord[] | null },
|
||||
_ctx: ComplianceContext,
|
||||
): ComplianceIssue[] {
|
||||
const issues: ComplianceIssue[] = [];
|
||||
const ns = Array.isArray(v.ns) ? v.ns : [];
|
||||
validateNSList(ns, issues, undefined);
|
||||
return issues;
|
||||
}
|
||||
|
||||
registerValidators("abstract.Origin", {
|
||||
sync: (raw, ctx) => validateOrigin((raw ?? {}) as OriginValue, ctx),
|
||||
});
|
||||
|
||||
registerValidators("abstract.NSOnlyOrigin", {
|
||||
sync: (raw, ctx) => validateNSOnlyOrigin((raw ?? {}) as { ns?: NSRecord[] | null }, ctx),
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue