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 = {
"/dmarc-creator": DmarcTool,
const records = {
"/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) {
const details = document.createElement("details");
details.innerHTML = `<summary>${Tool.categories[category]}</summary>`;
details.innerHTML = `<summary>${Record.categories[category]}</summary>`;
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) {
@ -36,9 +36,9 @@ document.getElementById("form").onchange = () => generate();
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) => {

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 { EnumField } from "../fields/EnumField.js";
import { IntField } from "../fields/IntField.js";
import { Field } from "../fields/Field.js";
import { DmarcUriListField } from "../fields/DmarcUriListField.js";
import { DnsTool } from "./DnsTool.js";
import { ValidationError } from "../ValidationError.js";
export class DmarcTool extends DnsTool {
static allowWhitespaceAroundSeparator = true;
import { ConstantTag } from "../tags/ConstantTag.js";
import { EnumTag } from "../tags/EnumTag.js";
import { IntTag } from "../tags/IntTag.js";
import { Tag } from "../tags/Tag.js";
import { DmarcUriListTag } from "../tags/DmarcUriListTag.js";
import { TagListRecord } from "./TagListRecord.js";
export class DmarcRecord extends TagListRecord {
static fields = [
new ConstantField("v", "DMARC1")
new ConstantTag("v", "DMARC1")
.required()
.pos(0),
new EnumField("p", ["none", "quarantine", "reject"])
new EnumTag("p", ["none", "quarantine", "reject"])
.label("Mail Receiver policy")
.desc("How to handle failed validations. The email may be quarantined (usually means sent to spam) or rejected")
.options(["None", "Quarantine", "Reject"])
.required()
.pos(1),
new EnumField("adkim", ["r", "s"])
new EnumTag("adkim", ["r", "s"])
.label("DKIM")
.desc("How strictly to handle DKIM validation")
.options(["Relaxed", "Strict"])
.default("r")
.pos(2),
new EnumField("aspf", ["r", "s"])
new EnumTag("aspf", ["r", "s"])
.label("SPF")
.desc("How strictly to handle SPF validation")
.options(["Relaxed", "Strict"])
.default("r")
.pos(2),
new EnumField("sp", ["none", "quarantine", "reject"])
new EnumTag("sp", ["none", "quarantine", "reject"])
.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")
.category("advanced")
.options(["None", "Quarantine", "Reject"])
.pos(2),
new IntField("pct", 0, 100)
new IntTag("pct", 0, 100)
.label("Percentage")
.desc("Percentage of emails to apply DMARC validation on. Useful for split-testing and continuous rollout")
.category("advanced")
.default(100)
.pos(2),
new DmarcUriListField("ruf")
new DmarcUriListTag("ruf")
.label("Send failure reports to")
.desc("When DMARC validation fails, reports are sent to this email")
.category("failure-reporting")
.pos(2),
new EnumField("fo", ["0", "1", "d", "s"])
new EnumTag("fo", ["0", "1", "d", "s"])
.label("Failure reporting options")
.desc("Define how reports will be generated")
.category("failure-reporting")
@ -68,19 +65,19 @@ export class DmarcTool extends DnsTool {
.default("0")
.pos(2),
new DmarcUriListField("rua")
new DmarcUriListTag("rua")
.label("Send aggregate feedback to")
.desc("Aggregate reports will be sent to this email, if defined")
.category("failure-reporting"),
new IntField("ri", 0, 2 ** 32)
new IntTag("ri", 0, 2 ** 32)
.label("Aggregate report interval")
.desc("Interval (in seconds) between aggregate reports")
.category("failure-reporting")
.default(86400)
.pos(2),
new Field("rf").default("afrf"), // Other values not supported
new Tag("rf").default("afrf"), // Other values not supported
];
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";
export class DnsTool {
static allowWhitespaceAroundSeparator;
/** Common class for DMARC/DKIM which both use semicolon-separated tag-value lists */
export class TagListRecord {
static fields = [];
constructor(text) {
@ -16,20 +16,10 @@ export class DnsTool {
const result = [];
for (const token of this.tokenize()) {
const key = token.match(/^\w*/)[0];
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];
const [key, value] = token.split(/\s*=\s*/);
if (!value) {
throw new ValidationError(`Field "${key}" is missing a value`);
throw new ValidationError(`Tag "${key}" is missing a value`);
}
result.push({ key, value });
@ -43,7 +33,7 @@ export class DnsTool {
for (const field of this.constructor.fields) {
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);
if (!field) {
throw new ValidationError(`Unknown field: ${input.key}`);
throw new ValidationError(`Unknown tag: ${input.key}`);
}
if (field.position < lastPos) {
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);

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";
export class ConstantField extends Field {
separator = "=";
export class ConstantTag extends Tag {
constructor(key, value) {
super(key);
this.value = value;

View File

@ -1,9 +1,7 @@
import { Field } from "./Field.js";
import { Tag } from "./Tag.js";
import { ValidationError } from "../ValidationError.js";
export class DmarcUriListField extends Field {
separator = "=";
export class DmarcUriListTag extends Tag {
constructor(key) {
super(key);
}
@ -17,7 +15,7 @@ export class DmarcUriListField extends Field {
try {
new URL(uri);
} 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";
export class EnumField extends Field {
separator = "=";
export class EnumTag extends Tag {
constructor(key, values) {
super(key);
this.values = values;
@ -14,7 +12,7 @@ export class EnumField extends Field {
if (this.values.includes(value))
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() {

View File

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

View File

@ -1,7 +1,5 @@
export class Field {
separator = null;
export class Tag {
isRequired = false;
allowMultiple = false;
defaultValue = null;
displayName = null;
description = null;
@ -10,7 +8,7 @@ export class Field {
constructor(key) {
this.key = key;
this.id = "field-" + key;
this.id = "tag-" + key;
}
// Virtual methods
@ -34,11 +32,6 @@ export class Field {
return this;
}
multiple() {
this.allowMultiple = true;
return this;
}
default(value) {
this.defaultValue = value;
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 { SpfTool } from "./tools/SpfTool.js";
import { DmarcRecord } from "./records/DmarcRecord.js";
import { SpfRecord } from "./records/SpfRecord.js";
const tools = {
"/dmarc-validator": DmarcTool,
"/spf-validator": SpfTool,
const records = {
"/dmarc-validator": DmarcRecord,
"/spf-validator": SpfRecord,
};
const Tool = tools[location.pathname];
const Record = records[location.pathname];
document.getElementById("record").oninput = event => validate(event.target.value);
@ -26,10 +26,10 @@ function validate(value) {
return;
}
const tool = new Tool(value);
const record = new Record(value);
try {
tool.validate();
record.validate();
document.getElementById("record").classList.add("valid");
document.getElementById("success").style.display = "flex";

View File

@ -28,7 +28,7 @@
<p id="result-placeholder" class="validation-result"></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
failures etc.
</p>

View File

@ -27,7 +27,40 @@
<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>
<h3>More tools:</h3>