Refactor names, separate SPF validator from others

This commit is contained in:
Reimar 2026-01-15 10:38:09 +01:00
parent 82c4579c58
commit 80ad069793
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
18 changed files with 321 additions and 159 deletions

View File

@ -1,22 +1,22 @@
import { DmarcTool } from "./tools/DmarcTool.js"; import { DmarcRecord } from "./records/DmarcRecord.js";
const tools = { const records = {
"/dmarc-creator": DmarcTool, "/dmarc-creator": DmarcRecord,
}; };
const Tool = tools[location.pathname]; const Record = records[location.pathname];
addFields(document.getElementById("form"), Tool.fields.filter(field => !field.categoryName)); addFields(document.getElementById("form"), Record.fields.filter(field => !field.categoryName));
const categories = Tool.fields.map(field => field.categoryName).filter(isUnique).filter(val => val); const categories = Record.fields.map(field => field.categoryName).filter(isUnique).filter(val => val);
for (const category of categories) { for (const category of categories) {
const details = document.createElement("details"); const details = document.createElement("details");
details.innerHTML = `<summary>${Tool.categories[category]}</summary>`; details.innerHTML = `<summary>${Record.categories[category]}</summary>`;
document.getElementById("form").appendChild(details); document.getElementById("form").appendChild(details);
addFields(details, Tool.fields.filter(field => field.categoryName === category)); addFields(details, Record.fields.filter(field => field.categoryName === category));
} }
function addFields(elem, fields) { function addFields(elem, fields) {
@ -36,9 +36,9 @@ document.getElementById("form").onchange = () => generate();
generate(); generate();
function generate() { function generate() {
const tool = new Tool(); const record = new Record();
document.getElementById("record").value = tool.fieldsToString(); document.getElementById("record").value = record.fieldsToString();
} }
document.getElementById("record").onclick = (e) => { document.getElementById("record").onclick = (e) => {

View File

@ -1,17 +0,0 @@
import { Field } from "./Field.js";
import { ValidationError } from "../ValidationError.js";
export class DomainField extends Field {
constructor(key, separator) {
super(key);
this.separator = separator;
}
validate(value) {
if (!value.match(/\w+(\.\w+)+/)) {
throw new ValidationError(`Field ${this.key} is not a valid domain`);
}
return true;
}
}

View File

@ -1,61 +1,58 @@
import { ConstantField } from "../fields/ConstantField.js"; import { ConstantTag } from "../tags/ConstantTag.js";
import { EnumField } from "../fields/EnumField.js"; import { EnumTag } from "../tags/EnumTag.js";
import { IntField } from "../fields/IntField.js"; import { IntTag } from "../tags/IntTag.js";
import { Field } from "../fields/Field.js"; import { Tag } from "../tags/Tag.js";
import { DmarcUriListField } from "../fields/DmarcUriListField.js"; import { DmarcUriListTag } from "../tags/DmarcUriListTag.js";
import { DnsTool } from "./DnsTool.js"; import { TagListRecord } from "./TagListRecord.js";
import { ValidationError } from "../ValidationError.js";
export class DmarcTool extends DnsTool {
static allowWhitespaceAroundSeparator = true;
export class DmarcRecord extends TagListRecord {
static fields = [ static fields = [
new ConstantField("v", "DMARC1") new ConstantTag("v", "DMARC1")
.required() .required()
.pos(0), .pos(0),
new EnumField("p", ["none", "quarantine", "reject"]) new EnumTag("p", ["none", "quarantine", "reject"])
.label("Mail Receiver policy") .label("Mail Receiver policy")
.desc("How to handle failed validations. The email may be quarantined (usually means sent to spam) or rejected") .desc("How to handle failed validations. The email may be quarantined (usually means sent to spam) or rejected")
.options(["None", "Quarantine", "Reject"]) .options(["None", "Quarantine", "Reject"])
.required() .required()
.pos(1), .pos(1),
new EnumField("adkim", ["r", "s"]) new EnumTag("adkim", ["r", "s"])
.label("DKIM") .label("DKIM")
.desc("How strictly to handle DKIM validation") .desc("How strictly to handle DKIM validation")
.options(["Relaxed", "Strict"]) .options(["Relaxed", "Strict"])
.default("r") .default("r")
.pos(2), .pos(2),
new EnumField("aspf", ["r", "s"]) new EnumTag("aspf", ["r", "s"])
.label("SPF") .label("SPF")
.desc("How strictly to handle SPF validation") .desc("How strictly to handle SPF validation")
.options(["Relaxed", "Strict"]) .options(["Relaxed", "Strict"])
.default("r") .default("r")
.pos(2), .pos(2),
new EnumField("sp", ["none", "quarantine", "reject"]) new EnumTag("sp", ["none", "quarantine", "reject"])
.label("Mail Receiver policy (for subdomains)") .label("Mail Receiver policy (for subdomains)")
.desc("Same as Mail Receiver policy, but applies only to subdomains. If not set, Mail Receiver policy applies to both top-level domain and subdomains") .desc("Same as Mail Receiver policy, but applies only to subdomains. If not set, Mail Receiver policy applies to both top-level domain and subdomains")
.category("advanced") .category("advanced")
.options(["None", "Quarantine", "Reject"]) .options(["None", "Quarantine", "Reject"])
.pos(2), .pos(2),
new IntField("pct", 0, 100) new IntTag("pct", 0, 100)
.label("Percentage") .label("Percentage")
.desc("Percentage of emails to apply DMARC validation on. Useful for split-testing and continuous rollout") .desc("Percentage of emails to apply DMARC validation on. Useful for split-testing and continuous rollout")
.category("advanced") .category("advanced")
.default(100) .default(100)
.pos(2), .pos(2),
new DmarcUriListField("ruf") new DmarcUriListTag("ruf")
.label("Send failure reports to") .label("Send failure reports to")
.desc("When DMARC validation fails, reports are sent to this email") .desc("When DMARC validation fails, reports are sent to this email")
.category("failure-reporting") .category("failure-reporting")
.pos(2), .pos(2),
new EnumField("fo", ["0", "1", "d", "s"]) new EnumTag("fo", ["0", "1", "d", "s"])
.label("Failure reporting options") .label("Failure reporting options")
.desc("Define how reports will be generated") .desc("Define how reports will be generated")
.category("failure-reporting") .category("failure-reporting")
@ -68,19 +65,19 @@ export class DmarcTool extends DnsTool {
.default("0") .default("0")
.pos(2), .pos(2),
new DmarcUriListField("rua") new DmarcUriListTag("rua")
.label("Send aggregate feedback to") .label("Send aggregate feedback to")
.desc("Aggregate reports will be sent to this email, if defined") .desc("Aggregate reports will be sent to this email, if defined")
.category("failure-reporting"), .category("failure-reporting"),
new IntField("ri", 0, 2 ** 32) new IntTag("ri", 0, 2 ** 32)
.label("Aggregate report interval") .label("Aggregate report interval")
.desc("Interval (in seconds) between aggregate reports") .desc("Interval (in seconds) between aggregate reports")
.category("failure-reporting") .category("failure-reporting")
.default(86400) .default(86400)
.pos(2), .pos(2),
new Field("rf").default("afrf"), // Other values not supported new Tag("rf").default("afrf"), // Other values not supported
]; ];
static categories = { static categories = {

View File

@ -0,0 +1,167 @@
import { Mechanism } from "../spf/Mechanism.js";
import { ValidationError } from "../ValidationError.js";
import { Modifier } from "../spf/Modifier.js";
import { ValueRequirement } from "../spf/ValueRequirement.js";
export class SpfRecord {
static fields = [
new Modifier("v")
.required()
.validate((key, val) => {
if (val !== "spf1") throw new ValidationError(`Version must be "spf1"`);
return true;
})
.pos(0),
new Mechanism("include")
.validate(this.validateDomain)
.multiple()
.pos(1),
new Mechanism("a")
.value(ValueRequirement.OPTIONAL)
.validate(this.validateDomain)
.multiple()
.pos(1),
new Mechanism("mx")
.value(ValueRequirement.OPTIONAL)
.validate(this.validateDomain)
.multiple()
.pos(2),
new Mechanism("ptr")
.value(ValueRequirement.OPTIONAL)
.validate(() => { throw new ValidationError(`"ptr" mechanism should not be used`) })
.multiple()
.pos(2),
new Mechanism("ipv4")
.validate(this.validateIPv4)
.multiple()
.pos(2),
new Mechanism("ipv6")
.validate(this.validateIPv6)
.multiple()
.pos(2),
new Mechanism("exists")
.validate(this.validateDomain)
.multiple()
.pos(2),
new Mechanism("all")
.value(ValueRequirement.PROHIBITED)
.pos(3),
new Modifier("redirect")
.validate(this.validateDomain)
.pos(4),
new Modifier("exp")
.validate(this.validateDomain)
.pos(4),
];
constructor(text) {
this.text = text;
}
tokenize() {
return this.text.trim().split(/\s+/);
}
getKeyValues() {
const result = [];
for (const token of this.tokenize()) {
const name = token.match(/^[-+?~]?(\w*)/)[1];
const term = this.constructor.fields.find(f => f.key === name);
if (!term) {
throw new ValidationError(`Unknown term: ${name}`);
}
const [directive, value] = token.split(term.separator);
const [, qualifier, key] = directive.match(/^([-+?~]?)(\w*)/);
result.push({ qualifier, key, value });
}
return result;
}
validate() {
const values = this.getKeyValues();
for (const term of this.constructor.fields) {
if (term.isRequired && !values.some(v => v.key === term.key)) {
throw new ValidationError(`Term "${term.key}" is required`);
}
}
let lastPos = 0;
for (let i = 0; i < values.length; i++) {
const input = values[i];
const term = this.constructor.fields.find(d => d.key === input.key);
if (!term) {
throw new ValidationError(`Unknown term: ${input.key}`);
}
if (term.position < lastPos) {
const lastDirective = this.constructor.fields.find(d => d.key === values[i-1].key);
throw new ValidationError(`Term "${lastDirective.key}" must come after "${term.key}"`);
}
if (term instanceof Modifier && input.qualifier) {
throw new ValidationError(`Modifier "${term.key}" must not have a qualifier`)
}
if (!input.value && term.valueRequirement === ValueRequirement.REQUIRED) {
throw new ValidationError(`Term "${term.key}" is missing a value`);
}
if (input.value && term.valueRequirement === ValueRequirement.PROHIBITED) {
throw new ValidationError(`Term "${term.key}" must not have a value`);
}
if (input.value) term.validationFunction(term.key, input.value);
lastPos = term.position;
}
}
static validateDomain(key, value) {
// https://www.rfc-editor.org/rfc/rfc7208#section-7.1
const valid = value
.split(".")
.every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/));
if (!valid) throw new ValidationError(`Value for "${key}" is not a valid domain name`);
return true;
}
static validateIPv4(key, value) {
const segments = value.split(".");
const valid = segments.every(segment => {
const number = parseInt(segment);
return !isNaN(number) && number >= 0 && number <= 255;
});
if (segments.length !== 4 || !valid) {
throw new ValidationError(`Value for ${key} is not a valid IPv4 address`);
}
return true;
}
static validateIPv6(key, value) {
return true; // TODO validate properly
}
}

View File

@ -1,7 +1,7 @@
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
export class DnsTool { /** Common class for DMARC/DKIM which both use semicolon-separated tag-value lists */
static allowWhitespaceAroundSeparator; export class TagListRecord {
static fields = []; static fields = [];
constructor(text) { constructor(text) {
@ -16,20 +16,10 @@ export class DnsTool {
const result = []; const result = [];
for (const token of this.tokenize()) { for (const token of this.tokenize()) {
const key = token.match(/^\w*/)[0]; const [key, value] = token.split(/\s*=\s*/);
const field = this.constructor.fields.find(f => f.key === key);
if (!field) {
throw new ValidationError(`Unknown field: ${key}`);
}
const wsp = this.constructor.allowWhitespaceAroundSeparator ? "\\s*" : "";
const separator = new RegExp(wsp + field.separator + wsp);
const value = token.split(separator)[1];
if (!value) { if (!value) {
throw new ValidationError(`Field "${key}" is missing a value`); throw new ValidationError(`Tag "${key}" is missing a value`);
} }
result.push({ key, value }); result.push({ key, value });
@ -43,7 +33,7 @@ export class DnsTool {
for (const field of this.constructor.fields) { for (const field of this.constructor.fields) {
if (field.isRequired && !values.some(v => v.key === field.key)) { if (field.isRequired && !values.some(v => v.key === field.key)) {
throw new ValidationError(`Field "${field.key}" is required`); throw new ValidationError(`Tag "${field.key}" is required`);
} }
} }
@ -53,13 +43,13 @@ export class DnsTool {
const field = this.constructor.fields.find(f => f.key === input.key); const field = this.constructor.fields.find(f => f.key === input.key);
if (!field) { if (!field) {
throw new ValidationError(`Unknown field: ${input.key}`); throw new ValidationError(`Unknown tag: ${input.key}`);
} }
if (field.position < lastPos) { if (field.position < lastPos) {
const lastField = this.constructor.fields.find(f => f.key === values[i-1].key); const lastField = this.constructor.fields.find(f => f.key === values[i-1].key);
throw new ValidationError(`Field "${lastField.key}" must come after "${field.key}"`); throw new ValidationError(`Tag "${lastField.key}" must come after "${field.key}"`);
} }
field.validate(input.value); field.validate(input.value);

View File

@ -0,0 +1,9 @@
import { Term } from "./Term.js";
export class Mechanism extends Term {
separator = ":";
constructor(key) {
super(key);
}
}

View File

@ -0,0 +1,9 @@
import { Term } from "./Term.js";
export class Modifier extends Term {
separator = "=";
constructor(key) {
super(key);
}
}

View File

@ -0,0 +1,41 @@
import { ValueRequirement } from "./ValueRequirement.js";
export class Term {
separator = null;
isRequired = false;
position = null;
allowMultiple = false;
valueRequirement = ValueRequirement.REQUIRED;
validationFunction = null;
constructor(key) {
this.key = key;
}
// Builder methods
required() {
this.isRequired = true;
return this;
}
pos(i) {
this.position = i;
return this;
}
multiple() {
this.allowMultiple = true;
return this;
}
value(requirement) {
this.valueRequirement = requirement;
return this;
}
validate(func) {
this.validationFunction = func;
return this;
}
}

View File

@ -0,0 +1,5 @@
export const ValueRequirement = {
REQUIRED: "required",
OPTIONAL: "optional",
PROHIBITED: "prohibited",
};

View File

@ -1,9 +1,7 @@
import { Field } from "./Field.js"; import { Tag } from "./Tag.js";
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
export class ConstantField extends Field { export class ConstantTag extends Tag {
separator = "=";
constructor(key, value) { constructor(key, value) {
super(key); super(key);
this.value = value; this.value = value;

View File

@ -1,9 +1,7 @@
import { Field } from "./Field.js"; import { Tag } from "./Tag.js";
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
export class DmarcUriListField extends Field { export class DmarcUriListTag extends Tag {
separator = "=";
constructor(key) { constructor(key) {
super(key); super(key);
} }
@ -17,7 +15,7 @@ export class DmarcUriListField extends Field {
try { try {
new URL(uri); new URL(uri);
} catch(e) { } catch(e) {
throw new ValidationError(`Invalid URI for field "${this.key}": ${uri}`); throw new ValidationError(`Invalid URI for tag "${this.key}": ${uri}`);
} }
} }

View File

@ -1,9 +1,7 @@
import { Field } from "./Field.js"; import { Tag } from "./Tag.js";
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
export class EnumField extends Field { export class EnumTag extends Tag {
separator = "=";
constructor(key, values) { constructor(key, values) {
super(key); super(key);
this.values = values; this.values = values;
@ -14,7 +12,7 @@ export class EnumField extends Field {
if (this.values.includes(value)) if (this.values.includes(value))
return true; return true;
throw new ValidationError(`Invalid value for field "${this.key}" - must be one of: ${this.values.join(", ")}`); throw new ValidationError(`Invalid value for tag "${this.key}" - must be one of: ${this.values.join(", ")}`);
} }
getInputHtml() { getInputHtml() {

View File

@ -1,9 +1,7 @@
import { Field } from "./Field.js"; import { Tag } from "./Tag.js";
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
export class IntField extends Field { export class IntTag extends Tag {
separator = "=";
constructor(key, min, max) { constructor(key, min, max) {
super(key); super(key);
this.min = min; this.min = min;

View File

@ -1,7 +1,5 @@
export class Field { export class Tag {
separator = null;
isRequired = false; isRequired = false;
allowMultiple = false;
defaultValue = null; defaultValue = null;
displayName = null; displayName = null;
description = null; description = null;
@ -10,7 +8,7 @@ export class Field {
constructor(key) { constructor(key) {
this.key = key; this.key = key;
this.id = "field-" + key; this.id = "tag-" + key;
} }
// Virtual methods // Virtual methods
@ -34,11 +32,6 @@ export class Field {
return this; return this;
} }
multiple() {
this.allowMultiple = true;
return this;
}
default(value) { default(value) {
this.defaultValue = value; this.defaultValue = value;
return this; return this;

View File

@ -1,57 +0,0 @@
import { ConstantField } from "../fields/ConstantField.js";
import { DomainField } from "../fields/DomainField.js";
import { DnsTool } from "./DnsTool.js";
export class SpfTool extends DnsTool {
static allowWhitespaceAroundSeparator = false;
static fields = [
new ConstantField("v", "spf1")
.required()
.pos(0),
new DomainField("include", ":")
.multiple()
.pos(1),
new DomainField("a", ":")
.multiple()
.pos(1),
new DomainField("mx", ":")
.multiple()
.pos(2),
new DomainField("ptr", ":")
.multiple()
.pos(2),
new DomainField("ipv4", ":")
.multiple()
.pos(2),
new DomainField("ipv6", ":")
.multiple()
.pos(2),
new DomainField("exists", ":")
.multiple()
.pos(2),
new DomainField("redirect", "=")
.pos(3),
new DomainField("exp", "=")
.pos(3),
// TODO all
];
constructor(text) {
super(text);
}
tokenize() {
return this.text.split(/\s+/);
}
}

View File

@ -1,12 +1,12 @@
import { DmarcTool } from "./tools/DmarcTool.js"; import { DmarcRecord } from "./records/DmarcRecord.js";
import { SpfTool } from "./tools/SpfTool.js"; import { SpfRecord } from "./records/SpfRecord.js";
const tools = { const records = {
"/dmarc-validator": DmarcTool, "/dmarc-validator": DmarcRecord,
"/spf-validator": SpfTool, "/spf-validator": SpfRecord,
}; };
const Tool = tools[location.pathname]; const Record = records[location.pathname];
document.getElementById("record").oninput = event => validate(event.target.value); document.getElementById("record").oninput = event => validate(event.target.value);
@ -26,10 +26,10 @@ function validate(value) {
return; return;
} }
const tool = new Tool(value); const record = new Record(value);
try { try {
tool.validate(); record.validate();
document.getElementById("record").classList.add("valid"); document.getElementById("record").classList.add("valid");
document.getElementById("success").style.display = "flex"; document.getElementById("success").style.display = "flex";

View File

@ -28,7 +28,7 @@
<p id="result-placeholder" class="validation-result"></p> <p id="result-placeholder" class="validation-result"></p>
<p> <p>
DMARC is a standard for web servers to tell how to handle validation errors in SPF and DKIM. DMARC is a standard for email servers to tell how to handle validation errors in SPF and DKIM.
It is a DNS TXT record with different values, defining rules on when to reject the email, how to report It is a DNS TXT record with different values, defining rules on when to reject the email, how to report
failures etc. failures etc.
</p> </p>

View File

@ -27,7 +27,40 @@
<p id="result-placeholder" class="validation-result"></p> <p id="result-placeholder" class="validation-result"></p>
<p>Insert SPF description</p> <p>
SPF is an email standard for authorizing hosts to send emails from a specific domain. Most email
servers, such as Gmail or Outlook, require SPF for security reasons, since without it, it is easy to
spoof email addresses.
</p>
<p>
SPF is defined with a TXT record on the email domain. It must start with "v=spf1" (to differentiate it
from other TXT records), and after that contains a set of mechanisms and modifiers (together called
terms), separated by space.
</p>
<p>
Mechanisms are colon-separated key-value pairs, whose primary purpose is to define which IPs are allowed
to send emails from the domain. The most used mechanisms are:
</p>
<ul>
<li><b>include</b>: Includes the SPF definition from the specified domain</li>
<li><b>a</b>: Allows emails from the specified domain</li>
<li><b>mx</b>: Allows emails from the IP addresses specified in the MX records</li>
<li><b>ipv6</b> / <b>ipv6</b>: Allows emails from the specified IP address</li>
</ul>
<p>
By default, mechanisms allow the specified IPs to send emails. You can add a qualifier, such as <b>~</b>
or <b>-</b> to prohibit them instead.
</p>
<p>
Often, you would include some IP addresses (using e.g. the <b>include</b>, <b>a</b> and <b>mx</b>
mechanisms), and end with <b>-all</b> or <b>~all</b> to reject emails from everywhere else. If the
<b>all</b> mechanism is used, it must come after the other mechanisms.
</p>
<center> <center>
<h3>More tools:</h3> <h3>More tools:</h3>