diff --git a/assets/scripts/creator.js b/assets/scripts/creator.js
index ae749fb..b7ef1b3 100644
--- a/assets/scripts/creator.js
+++ b/assets/scripts/creator.js
@@ -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 = `${Tool.categories[category]}`;
+ details.innerHTML = `${Record.categories[category]}`;
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) => {
diff --git a/assets/scripts/fields/DomainField.js b/assets/scripts/fields/DomainField.js
deleted file mode 100644
index 634a152..0000000
--- a/assets/scripts/fields/DomainField.js
+++ /dev/null
@@ -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;
- }
-}
diff --git a/assets/scripts/tools/DmarcTool.js b/assets/scripts/records/DmarcRecord.js
similarity index 71%
rename from assets/scripts/tools/DmarcTool.js
rename to assets/scripts/records/DmarcRecord.js
index 3037c49..59ece76 100644
--- a/assets/scripts/tools/DmarcTool.js
+++ b/assets/scripts/records/DmarcRecord.js
@@ -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 = {
diff --git a/assets/scripts/records/SpfRecord.js b/assets/scripts/records/SpfRecord.js
new file mode 100644
index 0000000..799880f
--- /dev/null
+++ b/assets/scripts/records/SpfRecord.js
@@ -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
+ }
+}
diff --git a/assets/scripts/tools/DnsTool.js b/assets/scripts/records/TagListRecord.js
similarity index 56%
rename from assets/scripts/tools/DnsTool.js
rename to assets/scripts/records/TagListRecord.js
index 16e4b6a..1379ca8 100644
--- a/assets/scripts/tools/DnsTool.js
+++ b/assets/scripts/records/TagListRecord.js
@@ -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);
diff --git a/assets/scripts/spf/Mechanism.js b/assets/scripts/spf/Mechanism.js
new file mode 100644
index 0000000..e71ae53
--- /dev/null
+++ b/assets/scripts/spf/Mechanism.js
@@ -0,0 +1,9 @@
+import { Term } from "./Term.js";
+
+export class Mechanism extends Term {
+ separator = ":";
+
+ constructor(key) {
+ super(key);
+ }
+}
diff --git a/assets/scripts/spf/Modifier.js b/assets/scripts/spf/Modifier.js
new file mode 100644
index 0000000..a64ffe7
--- /dev/null
+++ b/assets/scripts/spf/Modifier.js
@@ -0,0 +1,9 @@
+import { Term } from "./Term.js";
+
+export class Modifier extends Term {
+ separator = "=";
+
+ constructor(key) {
+ super(key);
+ }
+}
diff --git a/assets/scripts/spf/Term.js b/assets/scripts/spf/Term.js
new file mode 100644
index 0000000..5cc81ed
--- /dev/null
+++ b/assets/scripts/spf/Term.js
@@ -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;
+ }
+}
diff --git a/assets/scripts/spf/ValueRequirement.js b/assets/scripts/spf/ValueRequirement.js
new file mode 100644
index 0000000..1686a57
--- /dev/null
+++ b/assets/scripts/spf/ValueRequirement.js
@@ -0,0 +1,5 @@
+export const ValueRequirement = {
+ REQUIRED: "required",
+ OPTIONAL: "optional",
+ PROHIBITED: "prohibited",
+};
diff --git a/assets/scripts/fields/ConstantField.js b/assets/scripts/tags/ConstantTag.js
similarity index 74%
rename from assets/scripts/fields/ConstantField.js
rename to assets/scripts/tags/ConstantTag.js
index 5ef2fcb..1dec3a9 100644
--- a/assets/scripts/fields/ConstantField.js
+++ b/assets/scripts/tags/ConstantTag.js
@@ -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;
diff --git a/assets/scripts/fields/DmarcUriListField.js b/assets/scripts/tags/DmarcUriListTag.js
similarity index 75%
rename from assets/scripts/fields/DmarcUriListField.js
rename to assets/scripts/tags/DmarcUriListTag.js
index 0f1f412..0b4647f 100644
--- a/assets/scripts/fields/DmarcUriListField.js
+++ b/assets/scripts/tags/DmarcUriListTag.js
@@ -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}`);
}
}
diff --git a/assets/scripts/fields/EnumField.js b/assets/scripts/tags/EnumTag.js
similarity index 79%
rename from assets/scripts/fields/EnumField.js
rename to assets/scripts/tags/EnumTag.js
index 8a5f2cc..1f91db2 100644
--- a/assets/scripts/fields/EnumField.js
+++ b/assets/scripts/tags/EnumTag.js
@@ -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() {
diff --git a/assets/scripts/fields/IntField.js b/assets/scripts/tags/IntTag.js
similarity index 90%
rename from assets/scripts/fields/IntField.js
rename to assets/scripts/tags/IntTag.js
index 4d29b2c..268a3fc 100644
--- a/assets/scripts/fields/IntField.js
+++ b/assets/scripts/tags/IntTag.js
@@ -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;
diff --git a/assets/scripts/fields/Field.js b/assets/scripts/tags/Tag.js
similarity index 82%
rename from assets/scripts/fields/Field.js
rename to assets/scripts/tags/Tag.js
index abdd651..e17cb04 100644
--- a/assets/scripts/fields/Field.js
+++ b/assets/scripts/tags/Tag.js
@@ -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;
diff --git a/assets/scripts/tools/SpfTool.js b/assets/scripts/tools/SpfTool.js
deleted file mode 100644
index ab0c396..0000000
--- a/assets/scripts/tools/SpfTool.js
+++ /dev/null
@@ -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+/);
- }
-}
diff --git a/assets/scripts/validator.js b/assets/scripts/validator.js
index 7ce3b1e..24757b5 100644
--- a/assets/scripts/validator.js
+++ b/assets/scripts/validator.js
@@ -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";
diff --git a/dmarc-validator/index.html b/dmarc-validator/index.html
index cfd81fd..f5d9f20 100644
--- a/dmarc-validator/index.html
+++ b/dmarc-validator/index.html
@@ -28,7 +28,7 @@
- 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.
diff --git a/spf-validator/index.html b/spf-validator/index.html
index 80829c2..63a814e 100644
--- a/spf-validator/index.html
+++ b/spf-validator/index.html
@@ -27,7 +27,40 @@
- Insert SPF description
+
+ 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.
+
+
+
+ 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.
+
+
+
+ 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:
+
+
+
+ - include: Includes the SPF definition from the specified domain
+ - a: Allows emails from the specified domain
+ - mx: Allows emails from the IP addresses specified in the MX records
+ - ipv6 / ipv6: Allows emails from the specified IP address
+
+
+
+ By default, mechanisms allow the specified IPs to send emails. You can add a qualifier, such as ~
+ or - to prohibit them instead.
+
+
+
+ Often, you would include some IP addresses (using e.g. the include, a and mx
+ mechanisms), and end with -all or ~all to reject emails from everywhere else. If the
+ all mechanism is used, it must come after the other mechanisms.
+
More tools: