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: +

+ + + +

+ 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: